diff --git a/internal/auth/auth.go b/internal/auth/auth.go index 9d2999e..da038ca 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -18,6 +18,8 @@ import ( "github.com/gofrs/uuid" "golang.org/x/oauth2" "golang.org/x/sync/errgroup" + + "github.com/jetstack/jsctl/internal/config" ) const ( @@ -152,25 +154,18 @@ func WaitForOAuthToken(ctx context.Context, conf *oauth2.Config, state string) ( // SaveOAuthToken writes the provided token to a JSON file in the user's config directory. This location changes based // on the host operating system. See the documentation for os.UserConfigDir for specifics on where the token file will // be placed. -func SaveOAuthToken(token *oauth2.Token) error { - configDir, err := os.UserConfigDir() +func SaveOAuthToken(ctx context.Context, token *oauth2.Token) error { + jsonBytes, err := json.Marshal(token) if err != nil { - return err + return fmt.Errorf("failed to marshal token to JSON: %w", err) } - tokenDir := filepath.Join(configDir, "jsctl") - if err = os.MkdirAll(tokenDir, 0755); err != nil { - return err - } - - tokenFile := filepath.Join(tokenDir, tokenFileName) - file, err := os.Create(tokenFile) + err = config.WriteConfigFile(ctx, tokenFileName, jsonBytes) if err != nil { - return err + return fmt.Errorf("failed to write token file: %w", err) } - defer file.Close() - return json.NewEncoder(file).Encode(token) + return nil } // ErrNoToken is the error given when attempting to load an oauth token from disk that cannot be found. @@ -179,24 +174,19 @@ var ErrNoToken = errors.New("no oauth token") // LoadOAuthToken attempts to load an oauth token from the configuration directory. The location of the token file changes // based on the host operating system. See the documentation for os.UserConfigDir for specifics on where the token file will // be loaded from. Returns ErrNoToken if a token file cannot be found. -func LoadOAuthToken() (*oauth2.Token, error) { - tokenFile, err := DetermineTokenFilePath() - if err != nil { - return &oauth2.Token{}, fmt.Errorf("failed to determine token file path: %w", err) - } - - file, err := os.Open(tokenFile) +func LoadOAuthToken(ctx context.Context) (*oauth2.Token, error) { + data, err := config.ReadConfigFile(ctx, tokenFileName) switch { case errors.Is(err, os.ErrNotExist): return nil, ErrNoToken case err != nil: return nil, err } - defer file.Close() var token oauth2.Token - if err = json.NewDecoder(file).Decode(&token); err != nil { - return nil, err + err = json.Unmarshal(data, &token) + if err != nil { + return nil, fmt.Errorf("failed to unmarshal token from JSON: %w", err) } return &token, nil @@ -205,8 +195,8 @@ func LoadOAuthToken() (*oauth2.Token, error) { // DeleteOAuthToken attempts to remove an oauth token from the configuration directory. The location of the token file changes // based on the host operating system. See the documentation for os.UserConfigDir for specifics on where the token file will // be located. Returns ErrNoToken if a token file cannot be found. -func DeleteOAuthToken() error { - tokenFile, err := DetermineTokenFilePath() +func DeleteOAuthToken(ctx context.Context) error { + tokenFile, err := DetermineTokenFilePath(ctx) if err != nil { return fmt.Errorf("failed to determine token file path: %w", err) } @@ -223,13 +213,13 @@ func DeleteOAuthToken() error { } // DetermineTokenFilePath attempts to determine the path to the oauth token file. -func DetermineTokenFilePath() (string, error) { - configDir, err := os.UserConfigDir() - if err != nil { - return "", fmt.Errorf("failed to determine user config directory: %w", err) +func DetermineTokenFilePath(ctx context.Context) (string, error) { + configDir, ok := ctx.Value(config.ContextKey{}).(string) + if !ok { + return "", fmt.Errorf("no config path provided") } - tokenFile := filepath.Join(configDir, "jsctl", tokenFileName) + tokenFile := filepath.Join(configDir, tokenFileName) return tokenFile, nil } diff --git a/internal/auth/auth_test.go b/internal/auth/auth_test.go index 93bf320..f4f11c9 100644 --- a/internal/auth/auth_test.go +++ b/internal/auth/auth_test.go @@ -1,12 +1,17 @@ package auth_test import ( + "context" "encoding/json" "os" "testing" "time" + "github.com/stretchr/testify/require" + "github.com/jetstack/jsctl/internal/auth" + "github.com/jetstack/jsctl/internal/config" + "github.com/stretchr/testify/assert" "golang.org/x/oauth2" ) @@ -19,8 +24,15 @@ func TestLoadSaveOAuthToken(t *testing.T) { Expiry: time.Date(2021, 1, 1, 0, 0, 0, 0, time.UTC), } - assert.NoError(t, auth.SaveOAuthToken(expected)) - actual, err := auth.LoadOAuthToken() + tempConfigDir, err := os.MkdirTemp("", "token-test-*") + require.NoError(t, err) + defer os.Remove(tempConfigDir) + + ctx := config.ToContext(context.Background(), &config.Config{Organization: "example"}) + ctx = context.WithValue(ctx, config.ContextKey{}, tempConfigDir) + + assert.NoError(t, auth.SaveOAuthToken(ctx, expected)) + actual, err := auth.LoadOAuthToken(ctx) assert.NoError(t, err) assert.EqualValues(t, expected, actual) } @@ -36,19 +48,26 @@ func TestGetOAuthConfig(t *testing.T) { } func TestDeleteOAuthToken(t *testing.T) { + tempConfigDir, err := os.MkdirTemp("", "token-test-*") + require.NoError(t, err) + defer os.Remove(tempConfigDir) + + ctx := config.ToContext(context.Background(), &config.Config{Organization: "example"}) + ctx = context.WithValue(ctx, config.ContextKey{}, tempConfigDir) + t.Run("It should remove an oauth token", func(t *testing.T) { - assert.NoError(t, auth.SaveOAuthToken(&oauth2.Token{ + assert.NoError(t, auth.SaveOAuthToken(ctx, &oauth2.Token{ AccessToken: "test", TokenType: "test", RefreshToken: "test", Expiry: time.Date(2021, 1, 1, 0, 0, 0, 0, time.UTC), })) - assert.NoError(t, auth.DeleteOAuthToken()) + assert.NoError(t, auth.DeleteOAuthToken(ctx)) }) t.Run("It should return an error if there is no oauth token", func(t *testing.T) { - assert.Error(t, auth.DeleteOAuthToken()) + assert.Error(t, auth.DeleteOAuthToken(ctx)) }) } diff --git a/internal/command/auth.go b/internal/command/auth.go index 582fd17..2e8e064 100644 --- a/internal/command/auth.go +++ b/internal/command/auth.go @@ -49,13 +49,17 @@ func authStatus() *cobra.Command { return fmt.Errorf("failed to login with credentials file %q: %w", credentials, err) } } else { - tokenPath, err = auth.DetermineTokenFilePath() + tokenPath, err = auth.DetermineTokenFilePath(ctx) if err != nil { - fmt.Println("Can't find token", err) + return fmt.Errorf("failed to determine token path: %w", err) } + if _, err := os.Stat(tokenPath); errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("token missing at %s", tokenPath) + } + fmt.Println("Token path:", tokenPath) - token, err = auth.LoadOAuthToken() + token, err = auth.LoadOAuthToken(ctx) if err != nil { fmt.Println("Not logged in") return nil @@ -117,18 +121,15 @@ func authLogin() *cobra.Command { return fmt.Errorf("failed to obtain token: %w", err) } - if err = auth.SaveOAuthToken(token); err != nil { + if err = auth.SaveOAuthToken(ctx, token); err != nil { return fmt.Errorf("failed to save token: %w", err) } fmt.Println("Login succeeded") - err = config.Create(&config.Config{}) - switch { - case errors.Is(err, config.ErrConfigExists): - break - case err != nil: - return fmt.Errorf("failed to create configuration file: %w", err) + err = config.Save(ctx, &config.Config{}) + if err != nil { + return fmt.Errorf("failed to save configuration: %w", err) } cnf, ok := config.FromContext(ctx) @@ -157,7 +158,7 @@ func authLogout() *cobra.Command { Use: "logout", Args: cobra.ExactArgs(0), Run: run(func(ctx context.Context, args []string) error { - err := auth.DeleteOAuthToken() + err := auth.DeleteOAuthToken(ctx) switch { case errors.Is(err, auth.ErrNoToken): return fmt.Errorf("host contains no authentication data") diff --git a/internal/command/command.go b/internal/command/command.go index e9657da..ce6805b 100644 --- a/internal/command/command.go +++ b/internal/command/command.go @@ -17,21 +17,32 @@ var ( stdout bool kubeConfig string apiURL string + configDir string errNoOrganizationName = errors.New("You do not have an organization selected, select one using: \n\n\tjsctl config set organization [name]") ) // Command returns the root cobra.Command instance for the entire command-line interface. func Command() *cobra.Command { + var err error + cmd := &cobra.Command{ Use: "jsctl", Short: "Command-line tool for the Jetstack Secure Control Plane", } + // determine the default location of the jsctl config file + defaultConfigDir, err := config.DefaultConfigDir() + if err != nil { + fmt.Fprintf(os.Stderr, "failed to determine default user config directory, using current directory") + defaultConfigDir = "." + } + flags := cmd.PersistentFlags() flags.BoolVar(&stdout, "stdout", false, "If provided, manifests are written to stdout rather than applied to the current cluster") flags.StringVar(&kubeConfig, "kubeconfig", defaultKubeConfig(), "Location of the user's kubeconfig file for applying directly to the cluster") flags.StringVar(&apiURL, "api-url", "https://platform.jetstack.io", "Base URL of the control-plane API") + flags.StringVar(&configDir, "config", defaultConfigDir, "Base URL of the control-plane API") cmd.AddCommand( Auth(), @@ -49,9 +60,38 @@ func Command() *cobra.Command { func run(fn func(ctx context.Context, args []string) error) func(cmd *cobra.Command, args []string) { return func(cmd *cobra.Command, args []string) { + var err error ctx := cmd.Context() - token, err := auth.LoadOAuthToken() + defaultConfigDir, err := config.DefaultConfigDir() + if err != nil { + fmt.Fprintf(os.Stderr, "failed to determine default user config directory, using current directory\n") + defaultConfigDir = "." + } + + // if the user is using configDir defaulting, then we need to check for + // legacy config directories and migrate them if they exist + if configDir == defaultConfigDir { + err := config.MigrateDefaultConfig(configDir) + if err != nil { + fmt.Fprintf(os.Stderr, "failed to migrate legacy config directory: %s\n", err) + os.Exit(1) + } + } + + // write the configuration directory to the context so that it can be + // used in subcommands + ctx = context.WithValue(ctx, config.ContextKey{}, configDir) + + // ensure that the config dir specified exists, this allows other + // commands to write to sub paths of this directory without concern + // for the config dir existing + err = os.MkdirAll(configDir, 0700) + if err != nil { + exitf("failed to create config directory: %s", err) + } + + token, err := auth.LoadOAuthToken(ctx) switch { case errors.Is(err, auth.ErrNoToken): break @@ -61,7 +101,7 @@ func run(fn func(ctx context.Context, args []string) error) func(cmd *cobra.Comm ctx = auth.TokenToContext(ctx, token) } - cnf, err := config.Load() + cnf, err := config.Load(ctx) switch { case errors.Is(err, config.ErrNoConfiguration): break diff --git a/internal/command/config.go b/internal/command/config.go index c3f4bc6..08fd99f 100644 --- a/internal/command/config.go +++ b/internal/command/config.go @@ -4,11 +4,15 @@ import ( "context" "errors" "fmt" + "os" + "github.com/spf13/cobra" + "gopkg.in/yaml.v2" + + "github.com/jetstack/jsctl/internal/auth" "github.com/jetstack/jsctl/internal/client" "github.com/jetstack/jsctl/internal/config" "github.com/jetstack/jsctl/internal/organization" - "github.com/spf13/cobra" ) // Config returns a cobra.Command instance that is the root for all "jsctl config" subcommands. @@ -21,6 +25,7 @@ func Config() *cobra.Command { cmd.AddCommand( configSet(), + configShow(), ) return cmd @@ -39,6 +44,36 @@ func configSet() *cobra.Command { return cmd } +func configShow() *cobra.Command { + return &cobra.Command{ + Use: "show", + Short: "View your current configuration values", + Args: cobra.ExactValidArgs(0), + Run: run(func(ctx context.Context, args []string) error { + cnf, ok := config.FromContext(ctx) + if !ok { + return errors.New("config was not present, have you logged in? try jsctl auth login") + } + + configDir, ok := ctx.Value(config.ContextKey{}).(string) + if !ok { + configDir = "unknown" + } + + fmt.Fprintln(os.Stderr, "Configuration loaded from", configDir) + + yamlBytes, err := yaml.Marshal(cnf) + if err != nil { + return fmt.Errorf("failed to marshal configuration: %w", err) + } + + fmt.Fprintf(os.Stderr, string(yamlBytes)) + + return nil + }), + } +} + func configSetOrganization() *cobra.Command { return &cobra.Command{ Use: "organization [value]", @@ -50,6 +85,13 @@ func configSetOrganization() *cobra.Command { return errors.New("you must specify an organization name") } + // users must be logged in to run this command. Organizations can + // only be selected from the current token's organizations. + _, ok := auth.TokenFromContext(ctx) + if !ok { + return fmt.Errorf("you must be logged in to run this command, run jsctl auth login") + } + http := client.New(ctx, apiURL) organizations, err := organization.List(ctx, http) if err != nil { @@ -75,7 +117,7 @@ func configSetOrganization() *cobra.Command { } cnf.Organization = name - if err = config.Save(cnf); err != nil { + if err = config.Save(ctx, cnf); err != nil { return fmt.Errorf("failed to save configuration: %w", err) } diff --git a/internal/command/operator.go b/internal/command/operator.go index 3c7ab61..e83e011 100644 --- a/internal/command/operator.go +++ b/internal/command/operator.go @@ -95,13 +95,7 @@ Note: If --auto-registry-credentials and --registry-credentials-path are unset, http := client.New(ctx, apiURL) - // TODO: this would ideally come from the config in ctx - configDir, err := os.UserConfigDir() - if err != nil { - return err - } - - registryCredentialsBytes, err := registry.FetchOrLoadJetstackSecureEnterpriseRegistryCredentials(ctx, http, configDir) + registryCredentialsBytes, err := registry.FetchOrLoadJetstackSecureEnterpriseRegistryCredentials(ctx, http) if err != nil { return fmt.Errorf("failed to fetch or load registry credentials: %s", err) } @@ -242,13 +236,7 @@ Note: If --auto-registry-credentials and --registry-credentials-path are unset, http := client.New(ctx, apiURL) - // TODO: this would ideally come from the config in ctx - configDir, err := os.UserConfigDir() - if err != nil { - return err - } - - registryCredentialsBytes, err := registry.FetchOrLoadJetstackSecureEnterpriseRegistryCredentials(ctx, http, configDir) + registryCredentialsBytes, err := registry.FetchOrLoadJetstackSecureEnterpriseRegistryCredentials(ctx, http) if err != nil { return fmt.Errorf("failed to fetch or load registry credentials: %s", err) } diff --git a/internal/command/registry.go b/internal/command/registry.go index 5676568..d19e9a9 100644 --- a/internal/command/registry.go +++ b/internal/command/registry.go @@ -4,10 +4,14 @@ import ( "context" "fmt" "os" + "strings" "github.com/spf13/cobra" + "sigs.k8s.io/yaml" + "github.com/jetstack/jsctl/internal/auth" "github.com/jetstack/jsctl/internal/client" + "github.com/jetstack/jsctl/internal/config" "github.com/jetstack/jsctl/internal/registry" ) @@ -30,6 +34,7 @@ func registryAuth() *cobra.Command { cmd.AddCommand(registryAuthStatus()) cmd.AddCommand(registryAuthInit()) + cmd.AddCommand(registryAuthOutput()) return cmd } @@ -40,21 +45,24 @@ func registryAuthInit() *cobra.Command { Short: "Fetch or check the local registry credentials for the Jetstack Secure Enterprise registry", Args: cobra.ExactArgs(0), Run: run(func(ctx context.Context, args []string) error { - configDir, err := os.UserConfigDir() - if err != nil { - return err + var err error + + // users must be logged in to run this command + _, ok := auth.TokenFromContext(ctx) + if !ok { + return fmt.Errorf("you must be logged in to run this command, run jsctl auth login") } - fmt.Println("Checking for existing credentials in", configDir) + fmt.Fprintf(os.Stderr, "Checking for existing credentials in %s\n", configDir) jscpClient := client.New(ctx, apiURL) - _, err = registry.FetchOrLoadJetstackSecureEnterpriseRegistryCredentials(ctx, jscpClient, configDir) + _, err = registry.FetchOrLoadJetstackSecureEnterpriseRegistryCredentials(ctx, jscpClient) if err != nil { return err } - status, err := registry.StatusJetstackSecureEnterpriseRegistry(configDir) + status, err := registry.StatusJetstackSecureEnterpriseRegistry(ctx) if err != nil { return err } @@ -72,23 +80,85 @@ func registryAuthStatus() *cobra.Command { Short: "Print the status of the local registry credentials", Args: cobra.ExactArgs(0), Run: run(func(ctx context.Context, args []string) error { - // TODO: it'd be nice to get this from the ctx config so that - // operations can be performed relative to the loaded config - configDir, err := os.UserConfigDir() + configDir, ok := ctx.Value(config.ContextKey{}).(string) + if !ok { + return fmt.Errorf("no config path provided") + } + + fmt.Fprintf(os.Stderr, "Checking for existing credentials at path: %s\n", configDir) + + status, err := registry.StatusJetstackSecureEnterpriseRegistry(ctx) if err != nil { return err } - fmt.Println("Checking for existing credentials in", configDir) + fmt.Println(status) + + path, err := registry.PathJetstackSecureEnterpriseRegistry(ctx) + if err != nil { + return fmt.Errorf("failed to get path to registry credentials: %s", err) + } + + fmt.Fprintf(os.Stderr, "Path to registry credentials: %s\n", path) + + return nil + }), + } +} + +func registryAuthOutput() *cobra.Command { + var format string + + cmd := &cobra.Command{ + Use: "output", + Short: "output the registry credentials in various formats", + Args: cobra.ExactArgs(0), + Run: run(func(ctx context.Context, args []string) error { + var err error + + _, ok := auth.TokenFromContext(ctx) + if !ok { + return fmt.Errorf("you must be logged in to run this command, run jsctl auth login") + } + + jscpClient := client.New(ctx, apiURL) - status, err := registry.StatusJetstackSecureEnterpriseRegistry(configDir) + keyBytes, err := registry.FetchOrLoadJetstackSecureEnterpriseRegistryCredentials(ctx, jscpClient) if err != nil { return err } - fmt.Println(status) + if format == "json" { + fmt.Println(strings.TrimSpace(string(keyBytes))) + } else if format == "secret" { + secret, err := registry.ImagePullSecret(string(keyBytes)) + if err != nil { + return fmt.Errorf("failed to create image pull secret: %s", err) + } + + secretYAMLBytes, err := yaml.Marshal(secret) + if err != nil { + return fmt.Errorf("failed to marshal image pull secret: %s", err) + } + + fmt.Println(strings.TrimSpace(string(secretYAMLBytes))) + } else if format == "dockerconfig" { + dockerConfigJSON, err := registry.DockerConfigJSON(string(keyBytes)) + if err != nil { + return fmt.Errorf("failed to create docker config json: %s", err) + } + + fmt.Println(strings.TrimSpace(string(dockerConfigJSON))) + } else { + return fmt.Errorf("unknown format: %s", format) + } return nil }), } + + flags := cmd.PersistentFlags() + flags.StringVar(&format, "format", "json", "Format to output the registry credentials in. Valid options are: json, secret, dockerconfig") + + return cmd } diff --git a/internal/config/config.go b/internal/config/config.go index 854f2fc..6ef5542 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -5,6 +5,8 @@ import ( "context" "encoding/json" "errors" + "fmt" + "io" "os" "path/filepath" ) @@ -13,6 +15,9 @@ const ( configFileName = "config.json" ) +// ContextKey is a type used as a key for the config path in the context +type ContextKey struct{} + // The Config type describes the structure of the user's local configuration file. These values are used for performing // operations against the control-plane API. type Config struct { @@ -23,83 +28,214 @@ type Config struct { // ErrNoConfiguration is the error given when a configuration file cannot be found in the config directory. var ErrNoConfiguration = errors.New("no configuration file") -// ErrConfigExists is the error given when a configuration file is already present in the config directory. -var ErrConfigExists = errors.New("config exists") +// DefaultConfigDir returns the preferred config directory for the current platform +func DefaultConfigDir() (string, error) { + dir, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("failed to get user home directory: %s", err) + } + + dir = filepath.Join(dir, ".jsctl") + + return dir, nil +} + +// MigrateDefaultConfig has been implemented in response to: +// https://github.com/jetstack/jsctl/issues/52 +// We were using UserConfigDir from the os package, however, users did not +// expect this. So we have moved to ~/.jsctl or something equivalent instead. +func MigrateDefaultConfig(newConfigDir string) error { + legacyDirs, err := legacyConfigDirs() + if err != nil { + return fmt.Errorf("legacy config dirs needed in migration: %s", err) + } + + // there might be many legacy dirs to try, however, we can only migrate at + // most one. If newConfigDir is present, then we will not overwrite it. + for _, legacyDir := range legacyDirs { + if _, err := os.Stat(legacyDir); os.IsNotExist(err) { + // then there is no work to do + continue + } + + if _, err := os.Stat(newConfigDir); !os.IsNotExist(err) { + // then we can't continue because we don't want to overwrite the new config dir + return fmt.Errorf("config dir %q already exists, please remove either %q or %q", newConfigDir, newConfigDir, legacyDir) + } + + // move the config to the new dir + err := os.Rename(legacyDir, newConfigDir) + if err != nil { + return fmt.Errorf("failed to move config dir from %q to %q: %s", legacyDir, newConfigDir, err) + } + + fmt.Fprintf(os.Stderr, "Migrated config from %q to %q\n", legacyDir, newConfigDir) + } + + return nil +} -// Load the configuration file from the config directory, decoding it into a Config type. The location of the configuration -// file changes based on the host operating system. See the documentation for os.UserConfigDir for specifics on where -// the config file is loaded from. Returns ErrNoConfiguration if the config file cannot be found. -func Load() (*Config, error) { +// legacyConfigDir returns the possible legacy config directory for the +// current platform which might have been used in a previous version of jsctl. +// Currently, this only returns the value of UserConfigDir() +func legacyConfigDirs() ([]string, error) { configDir, err := os.UserConfigDir() if err != nil { - return nil, err + return nil, fmt.Errorf("failed to determine the legacy config directory: %s", err) } - configFile := filepath.Join(configDir, "jsctl", configFileName) - file, err := os.Open(configFile) + configDir = filepath.Join(configDir, "jsctl") + + return []string{configDir}, nil +} + +// Load the configuration file from the config directory specified in the provided context.Context. +// Returns ErrNoConfiguration if the config file cannot be found. +func Load(ctx context.Context) (*Config, error) { + configDir, ok := ctx.Value(ContextKey{}).(string) + if !ok { + return nil, fmt.Errorf("no config path provided") + } + configFile := filepath.Join(configDir, configFileName) + + data, err := ReadConfigFile(ctx, configFileName) switch { case errors.Is(err, os.ErrNotExist): return nil, ErrNoConfiguration case err != nil: return nil, err } - defer file.Close() var config Config - if err = json.NewDecoder(file).Decode(&config); err != nil { - return nil, err + err = json.Unmarshal(data, &config) + if err != nil { + return nil, fmt.Errorf("failed to unmarshal config file %q: %w", configFile, err) } return &config, nil } -// Create a new configuration file in the config directory containing the contents of the given Config type. The location -// of the configuration file changes based on the host operating system. See the documentation for os.UserConfigDir for -// specifics on where the config file is written to. Returns ErrConfigExists if a config file already exists. -func Create(config *Config) error { - configDir, err := os.UserConfigDir() +// Delete will remove the config file if one exists at the path set in the context +func Delete(ctx context.Context) error { + var err error + + configDir, ok := ctx.Value(ContextKey{}).(string) + if !ok { + return fmt.Errorf("no config path provided") + } + configFile := filepath.Join(configDir, configFileName) + + _, err = os.Stat(configFile) + if errors.Is(err, os.ErrNotExist) { + return nil + } if err != nil { return err } - jsctlDir := filepath.Join(configDir, "jsctl") - if _, err = os.Stat(jsctlDir); errors.Is(err, os.ErrNotExist) { - if err = os.MkdirAll(jsctlDir, 0755); err != nil { - return err - } - } + return os.Remove(configFile) +} - configFile := filepath.Join(jsctlDir, configFileName) - if _, err = os.Stat(configFile); err == nil { - return ErrConfigExists +// Save the provided configuration, updating an existing file if it already exists. +func Save(ctx context.Context, cfg *Config) error { + jsonBytes, err := json.MarshalIndent(cfg, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal config: %s", err) } - file, err := os.Create(configFile) + err = WriteConfigFile(ctx, configFileName, jsonBytes) if err != nil { - return err + return fmt.Errorf("failed to write config: %s", err) } - defer file.Close() - return json.NewEncoder(file).Encode(config) + return nil } -// Save the provided configuration, updating an existing file if it already exists. The location of the configuration -// file changes based on the host operating system. See the documentation for os.UserConfigDir for specifics on where -// the config file is written to. -func Save(config *Config) error { - configDir, err := os.UserConfigDir() +// ReadConfigFile reads a file from the config directory specified in the +// provided context +func ReadConfigFile(ctx context.Context, path string) ([]byte, error) { + var err error + + configDir, ok := ctx.Value(ContextKey{}).(string) + if !ok { + return nil, fmt.Errorf("no config path provided") + } + configFile := filepath.Join(configDir, path) + + // check that the file is not a symlink + // https://github.com/jetstack/jsctl/issues/43 + configFileInfo, err := os.Lstat(configFile) if err != nil { - return err + return nil, fmt.Errorf("failed to stat config file %q: %w", configFile, err) + } + if configFileInfo.Mode()&os.ModeSymlink != 0 { + return nil, fmt.Errorf("config file %q is a symlink, refusing to read", configFile) + } + + // check the file permissions and update them if not 0600 + if configFileInfo.Mode().Perm() != 0600 { + // TODO: we should error here in future. This is here to gracefully + // handle config files from older versions + fmt.Fprintf(os.Stderr, "warning: config file %q has insecure file permissions, correcting them\n", configFile) + err = os.Chmod(configFile, 0600) + if err != nil { + return nil, fmt.Errorf("failed to correct config file permissions for %q", configFile) + } } - configFile := filepath.Join(configDir, "jsctl", configFileName) - file, err := os.Create(configFile) + file, err := os.Open(configFile) if err != nil { - return err + return nil, fmt.Errorf("failed to open config file %q: %w", configFile, err) } defer file.Close() - return json.NewEncoder(file).Encode(config) + data, err := io.ReadAll(file) + if err != nil { + return nil, fmt.Errorf("failed to read config file %q: %w", configFile, err) + } + + return data, nil +} + +// WriteConfigFile writes a file with the correct permissions to the +// config directory specified in the provided context +func WriteConfigFile(ctx context.Context, path string, data []byte) error { + var err error + + configDir, ok := ctx.Value(ContextKey{}).(string) + if !ok { + return fmt.Errorf("no config path provided") + } + configFile := filepath.Join(configDir, path) + + // check that the file is not a symlink + // https://github.com/jetstack/jsctl/issues/43 + configFileInfo, err := os.Lstat(configFile) + if err != nil && !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("failed to stat config file %q: %w", configFile, err) + } + if err == nil { // file exists + if configFileInfo.Mode()&os.ModeSymlink != 0 { + return fmt.Errorf("config file %q is a symlink, refusing to write", configFile) + } + // check the file permissions and update them if not 0600 + if configFileInfo.Mode().Perm() != 0600 { + // TODO: we should error here in future. This is here to gracefully + // handle config files from older versions + fmt.Fprintf(os.Stderr, "warning: config file %q has insecure file permissions, correcting them\n", configFile) + err = os.Chmod(configFile, 0600) + if err != nil { + return fmt.Errorf("failed to correct config file permissions for %q", configFile) + } + } + } + + err = os.WriteFile(configFile, data, 0600) + if err != nil { + return fmt.Errorf("failed to write config %q: %w", configFile, err) + } + + return nil } type ctxKey struct{} diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 7cc3323..65f2f9b 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -1,37 +1,51 @@ package config_test import ( + "context" + "errors" "os" "testing" - "github.com/jetstack/jsctl/internal/config" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/jetstack/jsctl/internal/config" ) func TestConfiguration(t *testing.T) { - // TODO: Instead of skipping this test, parameterize config file - // creation so that it's possible to test it locally - if os.Getenv("CI") == "" { - t.Skip("Skip testing config file creation when running locally to avoid overwriting the actual config") - } + // tempConfigDir is created in order to test that credentials are put in the correct place + tempConfigDir, err := os.MkdirTemp("", "config-test-*") + require.NoError(t, err) + defer os.Remove(tempConfigDir) + + ctx := config.ToContext(context.Background(), &config.Config{Organization: "example"}) + ctx = context.WithValue(ctx, config.ContextKey{}, tempConfigDir) + expected := &config.Config{ Organization: "test", } - // Create a new configuration - assert.NoError(t, config.Create(expected)) + // Create a config + assert.NoError(t, config.Save(ctx, expected)) // Load the configuration and ensure it matches what we saved - actual, err := config.Load() + actual, err := config.Load(ctx) assert.NoError(t, err) assert.EqualValues(t, expected, actual) // Update the configuration and save it expected.Organization = "test2" - assert.NoError(t, config.Save(expected)) + assert.NoError(t, config.Save(ctx, expected)) // Load it again and ensure it matches. - actual, err = config.Load() + actual, err = config.Load(ctx) assert.NoError(t, err) assert.EqualValues(t, expected, actual) + + // clean up the file + assert.NoError(t, config.Delete(ctx)) + + if _, err := os.Stat(tempConfigDir + "/config.json"); !errors.Is(err, os.ErrNotExist) { + t.Errorf("config file was not deleted") + } } diff --git a/internal/operator/operator.go b/internal/operator/operator.go index cc86008..dc0cd76 100644 --- a/internal/operator/operator.go +++ b/internal/operator/operator.go @@ -5,8 +5,6 @@ import ( "bytes" "context" "embed" - "encoding/base64" - "encoding/json" "errors" "fmt" "io" @@ -29,8 +27,8 @@ import ( "k8s.io/client-go/rest" "sigs.k8s.io/yaml" - "github.com/jetstack/jsctl/internal/docker" "github.com/jetstack/jsctl/internal/prompt" + "github.com/jetstack/jsctl/internal/registry" "github.com/jetstack/jsctl/internal/venafi" ) @@ -69,7 +67,7 @@ func ApplyOperatorYAML(ctx context.Context, applier Applier, options ApplyOperat // pulled from a public registry or that the image pull secrets are already // in place. if options.RegistryCredentials != "" { - secret, err := ImagePullSecret(options.RegistryCredentials) + secret, err := registry.ImagePullSecret(options.RegistryCredentials) if err != nil { return err } @@ -187,60 +185,6 @@ func Versions() ([]string, error) { // ErrNoKeyFile is the error given when generating an image pull secret for a key that does not exist. var ErrNoKeyFile = errors.New("no key file") -// ImagePullSecret returns an io.Reader implementation that contains the byte representation of the Kubernetes secret -// YAML that can be used as an image pull secret for the jetstack operator. The keyData parameter should contain the JSON -// Google Service account to use in the secret. -func ImagePullSecret(keyData string) (*corev1.Secret, error) { - // When constructing a docker config for GCR, you must use the _json_key username and provide - // any valid looking email address. Methodology for building this secret was taken from the kubectl - // create secret command: - // https://github.com/kubernetes/kubernetes/blob/master/staging/src/k8s.io/kubectl/pkg/cmd/create/create_secret_docker.go - const ( - username = "_json_key" - email = "auth@jetstack.io" - ) - - auth := username + ":" + keyData - config := docker.ConfigJSON{ - Auths: map[string]docker.ConfigEntry{ - "eu.gcr.io": { - Username: username, - Password: string(keyData), - Email: email, - Auth: base64.StdEncoding.EncodeToString([]byte(auth)), - }, - }, - } - - configJSON, err := json.Marshal(config) - if err != nil { - return nil, fmt.Errorf("failed to encode docker config: %w", err) - } - - const ( - secretName = "jse-gcr-creds" - namespace = "jetstack-secure" - ) - - secret := &corev1.Secret{ - TypeMeta: metav1.TypeMeta{ - APIVersion: corev1.SchemeGroupVersion.String(), - Kind: "Secret", - }, - ObjectMeta: metav1.ObjectMeta{ - Name: secretName, - Namespace: namespace, - }, - Type: corev1.SecretTypeDockerConfigJson, - Data: map[string][]byte{ - corev1.DockerConfigJsonKey: configJSON, - }, - } - - return secret, nil - -} - type ( // The ApplyInstallationYAMLOptions type describes additional configuration options for the operator's Installation // custom resource. @@ -321,7 +265,7 @@ func ApplyInstallationYAML(ctx context.Context, applier Applier, options ApplyIn } if registryCredentials != "" { - secret, err := ImagePullSecret(registryCredentials) + secret, err := registry.ImagePullSecret(registryCredentials) if err != nil { return fmt.Errorf("failed to parse image pull secret: %w", err) } @@ -383,11 +327,11 @@ func marshalManifests(mf *manifests) (io.Reader, error) { // Add all Secrets to the buffer first to ensure that they get applied // to the cluster before any Deployments that might want to use them. for _, secret := range mf.secrets { - secretJson, err := yaml.Marshal(secret) + secretYAML, err := yaml.Marshal(secret) if err != nil { return nil, fmt.Errorf("failed to marshal Secret data: %w", err) } - secretReader := bytes.NewBuffer(secretJson) + secretReader := bytes.NewBuffer(secretYAML) if _, err = io.Copy(buf, secretReader); err != nil { return nil, fmt.Errorf("error writing secret data to buffer: %w", err) } diff --git a/internal/operator/operator_test.go b/internal/operator/operator_test.go index 641d87d..d2069a9 100644 --- a/internal/operator/operator_test.go +++ b/internal/operator/operator_test.go @@ -3,7 +3,6 @@ package operator_test import ( "bytes" "context" - "encoding/json" "io" "strings" "testing" @@ -15,7 +14,6 @@ import ( corev1 "k8s.io/api/core/v1" "sigs.k8s.io/yaml" - "github.com/jetstack/jsctl/internal/docker" "github.com/jetstack/jsctl/internal/operator" "github.com/jetstack/jsctl/internal/venafi" ) @@ -57,30 +55,6 @@ func TestVersions(t *testing.T) { assert.NotEmpty(t, versions) } -func TestImagePullSecret(t *testing.T) { - t.Parallel() - - t.Run("It should load valid credentials and generate a secret", func(t *testing.T) { - secret, err := operator.ImagePullSecret("./testdata/key.json") - assert.NoError(t, err) - - assert.EqualValues(t, "jetstack-secure", secret.Namespace) - assert.EqualValues(t, "jse-gcr-creds", secret.Name) - assert.EqualValues(t, corev1.SecretTypeDockerConfigJson, secret.Type) - assert.NotEmpty(t, secret.Data[corev1.DockerConfigJsonKey]) - - var actualConfig docker.ConfigJSON - assert.NoError(t, json.Unmarshal(secret.Data[corev1.DockerConfigJsonKey], &actualConfig)) - assert.NotEmpty(t, actualConfig.Auths) - - actualGCR := actualConfig.Auths["eu.gcr.io"] - assert.NotEmpty(t, actualGCR.Email) - assert.NotEmpty(t, actualGCR.Password) - assert.NotEmpty(t, actualGCR.Auth) - assert.NotEmpty(t, actualGCR.Username) - }) -} - func TestApplyInstallationYAML(t *testing.T) { t.Parallel() ctx := context.Background() @@ -141,7 +115,7 @@ func TestApplyInstallationYAML(t *testing.T) { applier := &TestApplier{} options := operator.ApplyInstallationYAMLOptions{ InstallApproverPolicyEnterprise: true, - RegistryCredentialsPath: "./testdata/key.json", + RegistryCredentialsPath: "../registry/testdata/key.json", } err := operator.ApplyInstallationYAML(ctx, applier, options) @@ -177,7 +151,7 @@ func TestApplyInstallationYAML(t *testing.T) { applier := &TestApplier{} options := operator.ApplyInstallationYAMLOptions{ InstallVenafiOauthHelper: true, - RegistryCredentialsPath: "./testdata/key.json", + RegistryCredentialsPath: "../registry/testdata/key.json", } err := operator.ApplyInstallationYAML(ctx, applier, options) @@ -244,7 +218,7 @@ func TestApplyInstallationYAML(t *testing.T) { } options := operator.ApplyInstallationYAMLOptions{ CertDiscoveryVenafi: cdv, - RegistryCredentialsPath: "./testdata/key.json", + RegistryCredentialsPath: "../registry/testdata/key.json", } err := operator.ApplyInstallationYAML(ctx, applier, options) diff --git a/internal/registry/credentials.go b/internal/registry/credentials.go new file mode 100644 index 0000000..30b6285 --- /dev/null +++ b/internal/registry/credentials.go @@ -0,0 +1,74 @@ +package registry + +import ( + "encoding/base64" + "encoding/json" + "fmt" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "github.com/jetstack/jsctl/internal/docker" +) + +// DockerConfifJSON returns a valid docker config JSON for the given JSON Google Service Account key data +func DockerConfigJSON(keyData string) ([]byte, error) { + // When constructing a docker config for GCR, you must use the _json_key username and provide + // any valid looking email address. Methodology for building this secret was taken from the kubectl + // create secret command: + // https://github.com/kubernetes/kubernetes/blob/master/staging/src/k8s.io/kubectl/pkg/cmd/create/create_secret_docker.go + const ( + username = "_json_key" + email = "auth@jetstack.io" + ) + + auth := username + ":" + keyData + config := docker.ConfigJSON{ + Auths: map[string]docker.ConfigEntry{ + "eu.gcr.io": { + Username: username, + Password: string(keyData), + Email: email, + Auth: base64.StdEncoding.EncodeToString([]byte(auth)), + }, + }, + } + + configJSON, err := json.Marshal(config) + if err != nil { + return nil, fmt.Errorf("failed to encode docker config: %w", err) + } + + return configJSON, nil +} + +// ImagePullSecret returns a Kubernetes Secret resource that can be used to pull images from the Jetstack Secure +// The keyData parameter should contain the JSON Google Service account to use in the secret. +func ImagePullSecret(keyData string) (*corev1.Secret, error) { + configJSON, err := DockerConfigJSON(keyData) + if err != nil { + return nil, fmt.Errorf("failed to generate docker config: %w", err) + } + + const ( + secretName = "jse-gcr-creds" + namespace = "jetstack-secure" + ) + + secret := &corev1.Secret{ + TypeMeta: metav1.TypeMeta{ + APIVersion: corev1.SchemeGroupVersion.String(), + Kind: "Secret", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: secretName, + Namespace: namespace, + }, + Type: corev1.SecretTypeDockerConfigJson, + Data: map[string][]byte{ + corev1.DockerConfigJsonKey: configJSON, + }, + } + + return secret, nil +} diff --git a/internal/registry/credentials_test.go b/internal/registry/credentials_test.go new file mode 100644 index 0000000..5f652a6 --- /dev/null +++ b/internal/registry/credentials_test.go @@ -0,0 +1,53 @@ +package registry + +import ( + "encoding/json" + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" + + "github.com/jetstack/jsctl/internal/docker" +) + +func TestDockerConfigJSON(t *testing.T) { + dockerConfig, err := DockerConfigJSON("./testdata/key.json") + assert.NoError(t, err) + + var actualConfig docker.ConfigJSON + assert.NoError(t, json.Unmarshal(dockerConfig, &actualConfig)) + assert.NotEmpty(t, actualConfig.Auths) + + actualGCR := actualConfig.Auths["eu.gcr.io"] + assert.NotEmpty(t, actualGCR.Email) + assert.NotEmpty(t, actualGCR.Password) + assert.NotEmpty(t, actualGCR.Auth) + assert.NotEmpty(t, actualGCR.Username) +} + +func TestImagePullSecret(t *testing.T) { + t.Run("It should load valid credentials and generate a secret", func(t *testing.T) { + keyData, err := os.ReadFile("./testdata/key.json") + require.NoError(t, err) + + secret, err := ImagePullSecret(string(keyData)) + assert.NoError(t, err) + + assert.EqualValues(t, "jetstack-secure", secret.Namespace) + assert.EqualValues(t, "jse-gcr-creds", secret.Name) + assert.EqualValues(t, corev1.SecretTypeDockerConfigJson, secret.Type) + assert.NotEmpty(t, secret.Data[corev1.DockerConfigJsonKey]) + + var actualConfig docker.ConfigJSON + assert.NoError(t, json.Unmarshal(secret.Data[corev1.DockerConfigJsonKey], &actualConfig)) + assert.NotEmpty(t, actualConfig.Auths) + + actualGCR := actualConfig.Auths["eu.gcr.io"] + assert.NotEmpty(t, actualGCR.Email) + assert.NotEmpty(t, actualGCR.Password) + assert.NotEmpty(t, actualGCR.Auth) + assert.NotEmpty(t, actualGCR.Username) + }) +} diff --git a/internal/registry/registry.go b/internal/registry/registry.go index d6d705b..f75698e 100644 --- a/internal/registry/registry.go +++ b/internal/registry/registry.go @@ -20,40 +20,45 @@ const jetstackSecureRegistryFileKey = "eu.gcr.io--jetstack-secure-enterprise" // StatusJetstackSecureEnterpriseRegistry will return the status of the registry // credentials for the Jetstack Secure Enterprise registry stashed to disk -func StatusJetstackSecureEnterpriseRegistry(configDir string) (string, error) { - registryCredentialsPath := filepath.Join(configDir, "jsctl", fmt.Sprintf("%s.json", jetstackSecureRegistryFileKey)) - - _, err := os.Stat(registryCredentialsPath) - if errors.Is(err, os.ErrNotExist) { +func StatusJetstackSecureEnterpriseRegistry(ctx context.Context) (string, error) { + _, err := config.ReadConfigFile(ctx, fmt.Sprintf("%s.json", jetstackSecureRegistryFileKey)) + if err != nil && !errors.Is(err, os.ErrNotExist) { + return "", fmt.Errorf("error reading registry credentials: %s", err) + } else if errors.Is(err, os.ErrNotExist) { return "not authenticated", nil } - if err != nil { - return "", fmt.Errorf("error checking if registry credentials exist: %s", err) + return "authenticated", nil +} + +// PathJetstackSecureEnterpriseRegistry will return the path where the credentials for the registry are located +func PathJetstackSecureEnterpriseRegistry(ctx context.Context) (string, error) { + configDir, ok := ctx.Value(config.ContextKey{}).(string) + if !ok { + return "", fmt.Errorf("no config directory found in context") } - return "authenticated", nil + return filepath.Join(configDir, fmt.Sprintf("%s.json", jetstackSecureRegistryFileKey)), nil } // FetchOrLoadJetstackSecureEnterpriseRegistryCredentials will check of there are // a local copy of registry credentials. If there is, then these are returned, // if not, then a new set is fetched and stashed in the jsctl config dir specified -func FetchOrLoadJetstackSecureEnterpriseRegistryCredentials(ctx context.Context, httpClient subscription.HTTPClient, configDir string) ([]byte, error) { - err := os.MkdirAll(filepath.Join(configDir, "jsctl"), os.ModePerm) - if err != nil { - return nil, fmt.Errorf("error creating jsctl config dir: %s", err) - } +func FetchOrLoadJetstackSecureEnterpriseRegistryCredentials(ctx context.Context, httpClient subscription.HTTPClient) ([]byte, error) { + var err error - registryCredentialsPath := filepath.Join(configDir, "jsctl", fmt.Sprintf("%s.json", jetstackSecureRegistryFileKey)) + configDir, ok := ctx.Value(config.ContextKey{}).(string) + if !ok { + return nil, fmt.Errorf("no config directory found in context") + } - _, err = os.Stat(registryCredentialsPath) - if !errors.Is(err, os.ErrNotExist) { - // then we can just load and return the file - bytes, err := os.ReadFile(registryCredentialsPath) - if err != nil { - return nil, fmt.Errorf("error reading registry credentials file: %s", err) - } + registryCredentialsPath := filepath.Join(configDir, fmt.Sprintf("%s.json", jetstackSecureRegistryFileKey)) - return bytes, nil + data, err := config.ReadConfigFile(ctx, fmt.Sprintf("%s.json", jetstackSecureRegistryFileKey)) + if err != nil && !errors.Is(err, os.ErrNotExist) { + return nil, fmt.Errorf("error reading registry credentials: %s", err) + } + if err == nil { + return data, nil } cnf, ok := config.FromContext(ctx) @@ -87,8 +92,7 @@ func FetchOrLoadJetstackSecureEnterpriseRegistryCredentials(ctx context.Context, return nil, fmt.Errorf("failed to decode registry credentials: %w", err) } - // stash the bytes in the config dir for use in future invocations - err = os.WriteFile(registryCredentialsPath, registryCredentialsBytes, 0600) + err = config.WriteConfigFile(ctx, fmt.Sprintf("%s.json", jetstackSecureRegistryFileKey), registryCredentialsBytes) if err != nil { return nil, fmt.Errorf("failed to write registry credentials to path %q: %w", registryCredentialsPath, err) } diff --git a/internal/registry/registry_test.go b/internal/registry/registry_test.go index 7e4ece1..24c8d29 100644 --- a/internal/registry/registry_test.go +++ b/internal/registry/registry_test.go @@ -31,18 +31,19 @@ func TestRegistryAuthInit(t *testing.T) { defer os.Remove(tempConfigDir) ctx := config.ToContext(context.Background(), &config.Config{Organization: "example"}) + ctx = context.WithValue(ctx, config.ContextKey{}, tempConfigDir) - bytes, err := registry.FetchOrLoadJetstackSecureEnterpriseRegistryCredentials(ctx, httpClient, tempConfigDir) + bytes, err := registry.FetchOrLoadJetstackSecureEnterpriseRegistryCredentials(ctx, httpClient) require.NoError(t, err) assert.Equal(t, "1\n", string(bytes)) // call it again to make sure that the file is reused - bytes, err = registry.FetchOrLoadJetstackSecureEnterpriseRegistryCredentials(ctx, httpClient, tempConfigDir) + bytes, err = registry.FetchOrLoadJetstackSecureEnterpriseRegistryCredentials(ctx, httpClient) require.NoError(t, err) assert.Equal(t, "1\n", string(bytes)) // check that the contents on disk is also correct - bytes, err = os.ReadFile(tempConfigDir + "/jsctl/eu.gcr.io--jetstack-secure-enterprise.json") + bytes, err = os.ReadFile(tempConfigDir + "/eu.gcr.io--jetstack-secure-enterprise.json") require.NoError(t, err) assert.Equal(t, "1\n", string(bytes)) diff --git a/internal/operator/testdata/key.json b/internal/registry/testdata/key.json similarity index 100% rename from internal/operator/testdata/key.json rename to internal/registry/testdata/key.json