From 0dbe1e52132bce0a012d6b36da8e09620f89d2a2 Mon Sep 17 00:00:00 2001 From: Supreeth Rao Date: Mon, 11 Mar 2024 10:40:57 +0000 Subject: [PATCH] Add support multiple config for token providers (#99) * Add support multiple config for token providers - This is a change to the existing functionality but is backwards compatible and hence feature parity. - Code before this change was supporting just one configuration for an ID provider. So, if an org had multiple azure configurations which needed supporting, that was not possible. The usecase could be to have different azure configuration for say prod and non-prod. - The configurations could now be provided as a list against the ID provider. The targets can then be added within that configuration rather than a global configuration for all the targets which was the case before. Readme has been updated to reflect the new configuration along with the deprecation information of the old config format. --- README.md | 210 ++++++++++++++++++++-------------- client/azure.go | 24 ++-- client/config.go | 230 ++++++++++++++++++++----------------- client/config_snapshot.go | 23 +++- client/group.go | 27 +++-- client/legacy_config.go | 44 ++++++++ client/osprey.go | 8 +- client/provider.go | 16 +++ client/target.go | 19 +--- cmd/login.go | 10 +- cmd/logout.go | 16 ++- cmd/targets.go | 12 +- cmd/user.go | 26 +++-- e2e/Dockerfile.localtest | 4 +- e2e/e2e_suite_test.go | 31 +++-- e2e/legacy_login_test.go | 232 ++++++++++++++++++++++++++++++++++++++ e2e/login_test.go | 1 - e2e/logout_test.go | 3 +- e2e/ospreytest/server.go | 81 +++++++++---- go.mod | 3 +- go.sum | 6 +- 21 files changed, 722 insertions(+), 304 deletions(-) create mode 100644 client/legacy_config.go create mode 100644 client/provider.go create mode 100644 e2e/legacy_login_test.go diff --git a/README.md b/README.md index 5bdaf4f..ce3cb3a 100644 --- a/README.md +++ b/README.md @@ -100,16 +100,16 @@ With a [configuration](#client-configuration) file like: ```yaml providers: osprey: - targets: - local.cluster: - server: https://osprey.local.cluster - foo.cluster: - server: https://osprey.foo.cluster - alias: [foo] - groups: [foo, foobar] - bar.cluster: - server: https://osprey.bar.cluster - groups: [bar, foobar] + - targets: + local.cluster: + server: https://osprey.local.cluster + foo.cluster: + server: https://osprey.foo.cluster + alias: [foo] + groups: [foo, foobar] + bar.cluster: + server: https://osprey.bar.cluster + groups: [bar, foobar] ``` The `groups` are labels that allow the targets to be organised into categories. @@ -239,7 +239,12 @@ installed version. The client uses a YAML configuration file. Its recommended location is: `$HOME/.osprey/config`. Its contents are as follows: +### V2 Config +The structure of the osprey configuration supports multiple configuration for a provider type. +This structure will support scenarios where different azure providers can be configured for prod and non-prod targets. ```yaml +apiVersion: v2 + # Optional path to the kubeconfig file to load/update when loging in. # Uses kubectl defaults if absent ($HOME/.kube/config). # kubeconfig: /home/jdoe/.kube/config @@ -248,75 +253,107 @@ The client uses a YAML configuration file. Its recommended location is: # When this value is defined, all targets must define at least one group. # default-group: my-group -# Named map of supported providers (currently `osprey` and `azure`) +## Named map of supported providers (currently `osprey` and `azure`) providers: osprey: - # CA cert to use for HTTPS connections to Osprey. - # Uses system's CA certs if absent. - # certificate-authority: /tmp/osprey-238319279/cluster_ca.crt - - # Alternatively, a Base64-encoded PEM format certificate. - # This will override certificate-authority if specified. - # certificate-authority-data: LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk5vdCB2YWxpZAotLS0tLUVORCBDRVJUSUZJQ0FURS0tLS0tCg== - - # Named map of target Osprey servers to contact for access-tokens - targets: - # Target Osprey's environment name. - # Used for the name of the cluster, context, and users generated - foo.cluster: - # hostname:port of the target osprey server - server: https://osprey.foo.cluster - - # list of names to generate additional contexts against the target. - aliases: [foo.alias] - - # list of names that can be used to logically group different Osprey servers. - groups: [foo] - - # CA cert to use for HTTPS connections to Osprey. - # Uses system's CA certs if absent. - # certificate-authority: /tmp/osprey-238319279/cluster_ca.crt - - # Alternatively, a Base64-encoded PEM format certificate. - # This will override certificate-authority if specified. - # certificate-authority-data: LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk5vdCB2YWxpZAotLS0tLUVORCBDRVJUSUZJQ0FURS0tLS0tCg== - + - provider-name: (Optional) + # CA cert to use for HTTPS connections to Osprey. + # Uses system's CA certs if absent. + # certificate-authority: /tmp/osprey-238319279/cluster_ca.crt + + # Alternatively, a Base64-encoded PEM format certificate. + # This will override certificate-authority if specified. + # certificate-authority-data: LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk5vdCB2YWxpZAotLS0tLUVORCBDRVJUSUZJQ0FURS0tLS0tCg== + + # Named map of target Osprey servers to contact for access-tokens + targets: + # Target Osprey's environment name. + # Used for the name of the cluster, context, and users generated + foo.cluster: + # hostname:port of the target osprey server + server: https://osprey.foo.cluster + + # list of names to generate additional contexts against the target. + aliases: [foo.alias] + + # list of names that can be used to logically group different Osprey servers. + groups: [foo] + + # CA cert to use for HTTPS connections to Osprey. + # Uses system's CA certs if absent. + # certificate-authority: /tmp/osprey-238319279/cluster_ca.crt + + # Alternatively, a Base64-encoded PEM format certificate. + # This will override certificate-authority if specified. + # certificate-authority-data: LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk5vdCB2YWxpZAotLS0tLUVORCBDRVJUSUZJQ0FURS0tLS0tCg== # Authenticating against Azure AD azure: - # These settings are required when authenticating against Azure - tenant-id: your-azure-tenant-id - server-application-id: azure-ad-server-application-id - client-id: azure-ad-client-id - client-secret: azure-ad-client-secret - - # List of scopes to request as part of the request. This should be an Azure link to the API exposed on the server application - scopes: - - "api://azure-tenant-id/Kubernetes.API.All" - - # This is required for the browser-based authentication flow. The port is configurable, but it must conform to - # the format: http://localhost:/auth/callback - redirect-uri: http://localhost:65525/auth/callback - targets: - foo.cluster: - server: http://osprey.foo.cluster - # If "use-gke-clientconfig" is specified (default false) Osprey will fetch the API server URL and its - # CA cert from the GKE-specific ClientConfig resource in kube-public. This resource is created automatically - # by GKE when you enable to OIDC Identity Service. The "api-server" config element is also required. - # Usually "api-server" would be set to the public API server endpoint; the fetched API server URL will be - # the internal load balancer that proxies requests through the OIDC service. - # use-gke-clientconfig: true - # - # If "skip-tls-verify" is specified (default false) Osprey will skip TLS verification when attempting - # to make the connection to the specified server. This can be used in conjunction with `server` or `api-server`. - # skip-tls-verify: true - # - # If api-server is specified (default ""), Osprey will fetch the CA cert from the API server itself. - # Overrides "server". A ConfigMap in kube-public called kube-root-ca.crt should be made accessible - # to the system:anonymous group. This ConfigMap is created automatically with the Kubernetes feature - # gate RootCAConfigMap which was alpha in Kubernetes v1.13 and became enabled by default in v1.20+ - # api-server: http://apiserver.foo.cluster - aliases: [foo.alias] - groups: [foo] + - name: (Optional) + # These settings are required when authenticating against Azure + tenant-id: your-azure-tenant-id + server-application-id: azure-ad-server-application-id + client-id: azure-ad-client-id + client-secret: azure-ad-client-secret + + # List of scopes to request as part of the request. This should be an Azure link to the API exposed on the server application + scopes: + - "api://azure-tenant-id/Kubernetes.API.All" + + # This is required for the browser-based authentication flow. The port is configurable, but it must conform to + # the format: http://localhost:/auth/callback + redirect-uri: http://localhost:65525/auth/callback + targets: + foo.cluster: + server: http://osprey.foo.cluster + # If "use-gke-clientconfig" is specified (default false) Osprey will fetch the API server URL and its + # CA cert from the GKE-specific ClientConfig resource in kube-public. This resource is created automatically + # by GKE when you enable to OIDC Identity Service. The "api-server" config element is also required. + # Usually "api-server" would be set to the public API server endpoint; the fetched API server URL will be + # the internal load balancer that proxies requests through the OIDC service. + # use-gke-clientconfig: true + # + # If "skip-tls-verify" is specified (default false) Osprey will skip TLS verification when attempting + # to make the connection to the specified server. This can be used in conjunction with `server` or `api-server`. + # skip-tls-verify: true + # + # If api-server is specified (default ""), Osprey will fetch the CA cert from the API server itself. + # Overrides "server". A ConfigMap in kube-public called kube-root-ca.crt should be made accessible + # to the system:anonymous group. This ConfigMap is created automatically with the Kubernetes feature + # gate RootCAConfigMap which was alpha in Kubernetes v1.13 and became enabled by default in v1.20+ + # api-server: http://apiserver.foo.cluster + aliases: [foo.alias] + groups: [foo] +``` + +### V1 Config (Deprecated) +This is the previously supported format. +The fields are the same but, the provider configuration is mapped to a provider type as opposed to being a list. +The config parsing will use this format unless specified to v2 on the apiVersion field in the config. +```yaml +providers: + osprey: + targets: + local.cluster: + server: https://osprey.local.cluster + foo.cluster: + server: https://osprey.foo.cluster + alias: [foo] + groups: [foo, foobar] + bar.cluster: + server: https://osprey.bar.cluster + groups: [bar, foobar] + + # Authenticating against Azure AD + azure: + tenant-id: your-tenant-id + server-application-id: api://SERVER-APPLICATION-ID # Application ID of the "Osprey - Kubernetes APIserver" + client-id: azure-application-client-id # Client ID for the "Osprey - Client" application + client-secret: azure-application-client-secret # Client Secret for the "Osprey - Client" application + scopes: + # This must be in the format "api://" due to non-interactive logins appending this to the audience in the JWT. + - "api://SERVER-APPLICATION-ID/Kubernetes.API.All" + redirect-uri: http://localhost:65525/auth/callback # Redirect URI configured for the "Osprey - Client" application + targets: ... ``` The name of the configured targets will be used to name the managed clusters, @@ -625,8 +662,12 @@ $ make We use [Cobra](https://github.com/spf13/cobra), to generate the client and server commands. ### E2E tests + The e2e tests are executed against local Dex and LDAP servers. +Note: The below docker image is only for linux/amd64. You might be able to get it working with other architectures, but it's not officially supported yet. +The e2e tests are executed against local Dex and LDAP servers. There is a Dockerfile located in the `e2e/` directory that will handle the dependencies for you. (This came around due to dependency issues with older versions of openldap in ubuntu). + The setup is as follows: Osprey Client (1) -> (*) Osprey Server (1) -> (1) Dex (*) -> (1) LDAP @@ -654,8 +695,7 @@ To run the test locally, run the following command `docker run -it -v :/osprey local-osprey-e2etest:1` 3. Inside the container run make test ``` - cd /osprey - export PATH=$PATH:/osprey/build/bin/linux_amd64 + make build make test ``` @@ -730,14 +770,14 @@ The client ID and secrets generated in this section are used to fill out the Osp ```yaml providers: azure: - tenant-id: your-tenant-id - server-application-id: api://SERVER-APPLICATION-ID # Application ID of the "Osprey - Kubernetes APIserver" - client-id: azure-application-client-id # Client ID for the "Osprey - Client" application - client-secret: azure-application-client-secret # Client Secret for the "Osprey - Client" application - scopes: - # This must be in the format "api://" due to non-interactive logins appending this to the audience in the JWT. - - "api://SERVER-APPLICATION-ID/Kubernetes.API.All" - redirect-uri: http://localhost:65525/auth/callback # Redirect URI configured for the "Osprey - Client" application + - tenant-id: your-tenant-id + server-application-id: api://SERVER-APPLICATION-ID # Application ID of the "Osprey - Kubernetes APIserver" + client-id: azure-application-client-id # Client ID for the "Osprey - Client" application + client-secret: azure-application-client-secret # Client Secret for the "Osprey - Client" application + scopes: + # This must be in the format "api://" due to non-interactive logins appending this to the audience in the JWT. + - "api://SERVER-APPLICATION-ID/Kubernetes.API.All" + redirect-uri: http://localhost:65525/auth/callback # Redirect URI configured for the "Osprey - Client" application ``` Kubernetes API server flags: diff --git a/client/azure.go b/client/azure.go index 98d5e11..7f98045 100644 --- a/client/azure.go +++ b/client/azure.go @@ -26,6 +26,8 @@ const ( // AzureConfig holds the configuration for Azure type AzureConfig struct { + // Name provides a named reference to the provider. For e.g sky-azure, nbcu-azure etc. Optional field + Name string `yaml:"name,omitempty"` // ServerApplicationID is the oidc-client-id used on the apiserver configuration ServerApplicationID string `yaml:"server-application-id,omitempty"` // ClientID is the oidc client id used for osprey @@ -78,27 +80,27 @@ func (ac *AzureConfig) ValidateConfig() error { } // NewAzureRetriever creates new Azure oAuth client -func NewAzureRetriever(provider *AzureConfig, options RetrieverOptions) (Retriever, error) { +func NewAzureRetriever(provider *ProviderConfig, options RetrieverOptions) (Retriever, error) { config := oauth2.Config{ - ClientID: provider.ClientID, - ClientSecret: provider.ClientSecret, - RedirectURL: provider.RedirectURI, - Scopes: provider.Scopes, + ClientID: provider.clientID, + ClientSecret: provider.clientSecret, + RedirectURL: provider.redirectURI, + Scopes: provider.scopes, } - if provider.IssuerURL == "" { - provider.IssuerURL = fmt.Sprintf("https://login.microsoftonline.com/%s/%s", provider.AzureTenantID, wellKnownConfigurationURI) + if provider.issuerURL == "" { + provider.issuerURL = fmt.Sprintf("https://login.microsoftonline.com/%s/%s", provider.azureTenantID, wellKnownConfigurationURI) } else { - provider.IssuerURL = fmt.Sprintf("%s/%s", provider.IssuerURL, wellKnownConfigurationURI) + provider.issuerURL = fmt.Sprintf("%s/%s", provider.issuerURL, wellKnownConfigurationURI) } - oidcEndpoint, err := oidc.GetWellKnownConfig(provider.IssuerURL) + oidcEndpoint, err := oidc.GetWellKnownConfig(provider.issuerURL) if err != nil { return nil, fmt.Errorf("unable to query well-known oidc config: %w", err) } config.Endpoint = *oidcEndpoint retriever := &azureRetriever{ - oidc: oidc.New(config, provider.ServerApplicationID), - tenantID: provider.AzureTenantID, + oidc: oidc.New(config, provider.serverApplicationID), + tenantID: provider.azureTenantID, } retriever.useDeviceCode = options.UseDeviceCode retriever.loginTimeout = options.LoginTimeout diff --git a/client/config.go b/client/config.go index ffe76e6..2f6b3ca 100644 --- a/client/config.go +++ b/client/config.go @@ -2,18 +2,22 @@ package client import ( "fmt" - "io/ioutil" "os" - "path/filepath" + "strconv" - "github.com/mitchellh/go-homedir" - log "github.com/sirupsen/logrus" "github.com/sky-uk/osprey/v2/common/web" "gopkg.in/yaml.v2" ) +// VersionConfig is used to unmarshal just the apiVersion field from the config file +type VersionConfig struct { + APIVersion string `yaml:"apiVersion,omitempty"` +} + // Config holds the information needed to connect to remote OIDC providers type Config struct { + // APIVersion specifies the version of osprey config file used + APIVersion string `yaml:"apiVersion,omitempty"` // Kubeconfig specifies the path to read/write the kubeconfig file. // +optional Kubeconfig string `yaml:"kubeconfig,omitempty"` @@ -26,8 +30,8 @@ type Config struct { // Providers holds the configuration structs for the supported providers type Providers struct { - Azure *AzureConfig `yaml:"azure,omitempty"` - Osprey *OspreyConfig `yaml:"osprey,omitempty"` + Azure []*AzureConfig `yaml:"azure,omitempty"` + Osprey []*OspreyConfig `yaml:"osprey,omitempty"` } // TargetEntry contains information about how to communicate with an osprey server @@ -60,33 +64,38 @@ type TargetEntry struct { Groups []string `yaml:"groups,omitempty"` } -// NewConfig is a convenience function that returns a new Config object with non-nil maps -func NewConfig() *Config { - return &Config{} -} - // LoadConfig reads and parses the Config file func LoadConfig(path string) (*Config, error) { - in, err := ioutil.ReadFile(path) + configData, err := os.ReadFile(path) if err != nil { return nil, fmt.Errorf("failed to read config file %s: %w", path, err) } - config := &Config{} - err = yaml.Unmarshal(in, config) + versionConfig := &VersionConfig{} + err = yaml.Unmarshal(configData, versionConfig) if err != nil { - return nil, fmt.Errorf("failed to unmarshal config file %s: %w", path, err) + return nil, fmt.Errorf("failed to unmarshal version config file %s: %w", path, err) + } + + config := &Config{} + if versionConfig.APIVersion == "v2" { + err = yaml.Unmarshal(configData, config) + if err != nil { + return nil, fmt.Errorf("failed to unmarshal v2 config file %s: %w", path, err) + } + } else { + config, err = parseLegacyConfig(configData) + if err != nil { + return nil, fmt.Errorf("failed to unmarshal v1 config file %s: %w", path, err) + } } - if config.Providers.Azure != nil { - azureConfig := config.Providers.Azure + for _, azureConfig := range config.Providers.Azure { err = azureConfig.ValidateConfig() if err == nil { err = setTargetCA(azureConfig.CertificateAuthority, azureConfig.CertificateAuthorityData, azureConfig.Targets) } } - - if config.Providers.Osprey != nil { - ospreyConfig := config.Providers.Osprey + for _, ospreyConfig := range config.Providers.Osprey { err = ospreyConfig.ValidateConfig() if err == nil { err = setTargetCA(ospreyConfig.CertificateAuthority, ospreyConfig.CertificateAuthorityData, ospreyConfig.Targets) @@ -103,23 +112,6 @@ func LoadConfig(path string) (*Config, error) { return config, err } -// SaveConfig writes the osprey config to the specified path. -func SaveConfig(config *Config, path string) error { - err := os.MkdirAll(filepath.Dir(path), 0755) - if err != nil { - return fmt.Errorf("failed to access config dir %s: %w", path, err) - } - out, err := yaml.Marshal(config) - if err != nil { - return fmt.Errorf("failed to marshal config file %s: %w", path, err) - } - err = ioutil.WriteFile(path, out, 0755) - if err != nil { - return fmt.Errorf("failed to write config file %s: %w", path, err) - } - return nil -} - func (c *Config) validateGroups() error { for _, group := range c.Snapshot().groupsByName { if group.name == "" && c.DefaultGroup != "" { @@ -130,35 +122,115 @@ func (c *Config) validateGroups() error { } // GetRetrievers returns a map of providers to retrievers -func (c *Config) GetRetrievers(options RetrieverOptions) (map[string]Retriever, error) { +// Can return just a single retriever as it can be called just in time. +// The disadvantage being login can fail for a different provider after having succeeded for the first. +func (c *Config) GetRetrievers(providerConfigs map[string]*ProviderConfig, options RetrieverOptions) (map[string]Retriever, error) { retrievers := make(map[string]Retriever) - var err error - if c.Providers.Azure != nil { - retrievers[AzureProviderName], err = NewAzureRetriever(c.Providers.Azure, options) - if err != nil { - return nil, err + + for _, providerConfig := range providerConfigs { + switch providerConfig.providerType { + case AzureProviderName: + result, err := NewAzureRetriever(providerConfig, options) + if err != nil { + return nil, err + } + retrievers[providerConfig.name] = result + case OspreyProviderName: + result, err := NewOspreyRetriever(providerConfig, options) + if err != nil { + return nil, err + } + retrievers[providerConfig.name] = result } } - if c.Providers.Osprey != nil { - retrievers[OspreyProviderName] = NewOspreyRetriever(c.Providers.Osprey, options) - } return retrievers, nil } // Snapshot creates or returns a ConfigSnapshot func (c *Config) Snapshot() *ConfigSnapshot { - groupedTargets := make(map[string]map[string]*TargetEntry) - if c.Providers.Azure != nil { - groupedTargets[AzureProviderName] = c.Providers.Azure.Targets - } - if c.Providers.Osprey != nil { - groupedTargets[OspreyProviderName] = c.Providers.Osprey.Targets + groupsByName := make(map[string]Group) + providerConfigByName := make(map[string]*ProviderConfig) + + // build the target list by group name for Azure provider + if c.Providers != nil { + for i, azureProvider := range c.Providers.Azure { + givenName := azureProvider.Name + if givenName == "" { + givenName = "provider-" + strconv.Itoa(i) + } + providerName := AzureProviderName + ":" + givenName + providerConfigByName[providerName] = &ProviderConfig{ + name: providerName, + serverApplicationID: azureProvider.ServerApplicationID, + clientID: azureProvider.ClientID, + clientSecret: azureProvider.ClientSecret, + certificateAuthority: azureProvider.CertificateAuthority, + certificateAuthorityData: azureProvider.CertificateAuthorityData, + redirectURI: azureProvider.RedirectURI, + scopes: azureProvider.Scopes, + azureTenantID: azureProvider.AzureTenantID, + issuerURL: azureProvider.IssuerURL, + providerType: AzureProviderName, + } + + c.groupTargetsByProvider(azureProvider.Targets, providerName, groupsByName) + } + + for i, ospreyProvider := range c.Providers.Osprey { + givenName := ospreyProvider.Name + if givenName == "" { + givenName = "provider-" + strconv.Itoa(i) + } + providerName := OspreyProviderName + ":" + givenName + providerConfigByName[providerName] = &ProviderConfig{ + name: providerName, + certificateAuthority: ospreyProvider.CertificateAuthority, + certificateAuthorityData: ospreyProvider.CertificateAuthorityData, + providerType: OspreyProviderName, + } + + c.groupTargetsByProvider(ospreyProvider.Targets, providerName, groupsByName) + } } - groupsByName := groupTargetsByName(groupedTargets, c.DefaultGroup) return &ConfigSnapshot{ - groupsByName: groupsByName, - defaultGroupName: c.DefaultGroup, + groupsByName: groupsByName, + providerConfigByName: providerConfigByName, + defaultGroupName: c.DefaultGroup, + } +} + +func (c *Config) groupTargetsByProvider(targets map[string]*TargetEntry, providerName string, groupsByName map[string]Group) { + groupedTargets := make(map[string][]Target) + + // arranges the list of targets for a provider by their group(s). A target can be part of 0 or more groups + for targetName, targetEntry := range targets { + // a target not belonging to any group and a config not having a default group is a valid scenario + if len(targetEntry.Groups) == 0 { + groupName := "" + updatedTargets := append(groupedTargets[groupName], Target{name: targetName, targetEntry: targetEntry}) + groupedTargets[groupName] = updatedTargets + } + for _, groupName := range targetEntry.Groups { + updatedTargets := append(groupedTargets[groupName], Target{name: targetName, targetEntry: targetEntry}) + groupedTargets[groupName] = updatedTargets + } + } + + // after grouping the targets within a provider above, the below loop merges that map with a map for all providers. + // So, the map will be targets arranged by their groups but also mapped to their provider configuration + for groupName, targets := range groupedTargets { + if group, present := groupsByName[groupName]; present { + group.targetsByProvider[providerName] = targets + } else { + groupsByName[groupName] = Group{ + name: groupName, + isDefault: groupName == c.DefaultGroup, + targetsByProvider: map[string][]Target{ + providerName: targets, + }, + } + } } } @@ -198,51 +270,3 @@ func setTargetCA(certificateAuthority, certificateAuthorityData string, targets } return nil } - -func groupTargetsByName(groupedTargets map[string]map[string]*TargetEntry, defaultGroup string) map[string]Group { - groupsByName := make(map[string]Group) - for providerName, targetEntries := range groupedTargets { - for groupName, group := range groupTargetsByProvider(targetEntries, defaultGroup, providerName) { - if existingGroup, ok := groupsByName[groupName]; ok { - existingGroup.targets = append(existingGroup.targets, group.targets...) - groupsByName[groupName] = existingGroup - } else { - groupsByName[groupName] = group - } - } - } - - return groupsByName -} - -func groupTargetsByProvider(targetEntries map[string]*TargetEntry, defaultGroup string, providerType string) map[string]Group { - groupsByName := make(map[string]Group) - var groups []Group - for key, targetEntry := range targetEntries { - targetEntryGroups := targetEntry.Groups - if len(targetEntryGroups) == 0 { - targetEntryGroups = []string{""} - } - - target := Target{name: key, targetEntry: *targetEntry, providerType: providerType} - for _, groupName := range targetEntryGroups { - group, ok := groupsByName[groupName] - if !ok { - isDefault := groupName == defaultGroup - group = Group{name: groupName, isDefault: isDefault} - groups = append(groups, group) - } - group.targets = append(group.targets, target) - groupsByName[groupName] = group - } - } - return groupsByName -} - -func homeDir() string { - home, err := homedir.Dir() - if err != nil { - log.Fatalf("Failed to read home dir: %v", err) - } - return home -} diff --git a/client/config_snapshot.go b/client/config_snapshot.go index 17b83df..7acd09b 100644 --- a/client/config_snapshot.go +++ b/client/config_snapshot.go @@ -1,10 +1,13 @@ package client +import "fmt" + // ConfigSnapshot is a snapshot view of the configuration to organize the targets per group. // It does not reflect changes to the configuration after it has been taken. type ConfigSnapshot struct { - defaultGroupName string - groupsByName map[string]Group + defaultGroupName string + groupsByName map[string]Group + providerConfigByName map[string]*ProviderConfig } // Groups returns all defined groups sorted alphabetically by name. @@ -18,6 +21,20 @@ func (t *ConfigSnapshot) Groups() []Group { return sortGroups(groups) } +// ProviderConfigs is the config for the providers +func (t *ConfigSnapshot) ProviderConfigs() map[string]*ProviderConfig { + return t.providerConfigByName +} + +// GetProviderType provides the name of the provider. azure, osprey etc +func (t *ConfigSnapshot) GetProviderType(providerName string) (string, error) { + config := t.providerConfigByName[providerName] + if config == nil { + return "", fmt.Errorf("unable to lookup provider for name: %s", providerName) + } + return config.providerType, nil +} + // HaveGroups returns true if there is at least one defined group. func (t *ConfigSnapshot) HaveGroups() bool { // the special group "" does not count as a group @@ -35,7 +52,7 @@ func (t *ConfigSnapshot) Targets() []Target { var targets []Target set := make(map[string]*interface{}) for _, group := range t.groupsByName { - for _, target := range group.targets { + for _, target := range group.Targets() { if _, ok := set[target.name]; !ok { set[target.name] = nil targets = append(targets, target) diff --git a/client/group.go b/client/group.go index 1023bb3..fd8b848 100644 --- a/client/group.go +++ b/client/group.go @@ -6,9 +6,10 @@ import ( // Group organizes the targetEntry targets type Group struct { - name string - isDefault bool - targets []Target + name string + isDefault bool + _targets []Target + targetsByProvider map[string][]Target } // IsDefault returns true if this is the default group in the configuration @@ -17,12 +18,20 @@ func (g *Group) IsDefault() bool { } // Targets returns the list of targets belonging to this group -func (g *Group) Targets() map[string][]Target { - groupMap := make(map[string][]Target) - for _, target := range g.targets { - groupMap[target.ProviderType()] = append(groupMap[target.ProviderType()], target) +func (g *Group) Targets() []Target { + if len(g._targets) == 0 { + var allTargets []Target + for _, targets := range g.targetsByProvider { + allTargets = append(allTargets, targets...) + } + g._targets = sortTargets(allTargets) } - return getSortedTargetsByProvider(groupMap) + return g._targets +} + +// TargetsForProvider returns the list of targets by provider belonging to this group +func (g *Group) TargetsForProvider() map[string][]Target { + return getSortedTargetsByProvider(g.targetsByProvider) } func getSortedTargetsByProvider(targetMap map[string][]Target) map[string][]Target { @@ -39,7 +48,7 @@ func (g *Group) Name() string { // Contains returns true if it contains the target func (g *Group) Contains(target Target) bool { - for _, current := range g.targets { + for _, current := range g.Targets() { if target.name == current.name { return true } diff --git a/client/legacy_config.go b/client/legacy_config.go new file mode 100644 index 0000000..7b03532 --- /dev/null +++ b/client/legacy_config.go @@ -0,0 +1,44 @@ +package client + +import "gopkg.in/yaml.v2" + +// ConfigV1 is the v1 version of the config file +// Deprecated: This config format is now deprecated. Use `Config` format instead +type ConfigV1 struct { + // Kubeconfig specifies the path to read/write the kubeconfig file. + // +optional + Kubeconfig string `yaml:"kubeconfig,omitempty"` + // DefaultGroup specifies the group to log in to if none provided. + // +optional + DefaultGroup string `yaml:"default-group,omitempty"` + // Providers is a map of OIDC provider config + Providers *ProvidersV1 `yaml:"providers,omitempty"` +} + +// ProvidersV1 Single Provider config +// Deprecated: This format is now deprecated. Use `Providers` instead +type ProvidersV1 struct { + Azure *AzureConfig `yaml:"azure,omitempty"` + Osprey *OspreyConfig `yaml:"osprey,omitempty"` +} + +func parseLegacyConfig(configData []byte) (*Config, error) { + config := &Config{} + configV1 := &ConfigV1{} + err := yaml.Unmarshal(configData, configV1) + if err != nil { + return nil, err + } + config.Kubeconfig = configV1.Kubeconfig + config.DefaultGroup = configV1.DefaultGroup + config.Providers = &Providers{} + + if configV1.Providers.Azure != nil { + config.Providers.Azure = []*AzureConfig{configV1.Providers.Azure} + } + + if configV1.Providers.Osprey != nil { + config.Providers.Osprey = []*OspreyConfig{configV1.Providers.Osprey} + } + return config, nil +} diff --git a/client/osprey.go b/client/osprey.go index 4df4989..a67bf19 100644 --- a/client/osprey.go +++ b/client/osprey.go @@ -27,6 +27,8 @@ type OspreyConfig struct { CertificateAuthorityData string `yaml:"certificate-authority-data,omitempty"` // AzureTenantID is the Azure Tenant ID assigned to your organisation Targets map[string]*TargetEntry `yaml:"targets"` + // Provider name + Name string `yaml:"provider-name,omitempty"` } // ValidateConfig checks that the required configuration has been provided for Osprey @@ -46,14 +48,14 @@ func (oc *OspreyConfig) ValidateConfig() error { } // NewOspreyRetriever creates new osprey client -func NewOspreyRetriever(provider *OspreyConfig, options RetrieverOptions) Retriever { +func NewOspreyRetriever(provider *ProviderConfig, options RetrieverOptions) (Retriever, error) { return &ospreyRetriever{ - serverCertificateAuthorityData: provider.CertificateAuthorityData, + serverCertificateAuthorityData: provider.certificateAuthorityData, credentials: &LoginCredentials{ Username: options.Username, Password: options.Password, }, - } + }, nil } type ospreyRetriever struct { diff --git a/client/provider.go b/client/provider.go new file mode 100644 index 0000000..93c83bd --- /dev/null +++ b/client/provider.go @@ -0,0 +1,16 @@ +package client + +// ProviderConfig is a super struct i.e many fields don't apply for osprey config/setup. Maybe there's a better way :shrug: +type ProviderConfig struct { + name string + serverApplicationID string + clientID string + clientSecret string + certificateAuthority string + certificateAuthorityData string + redirectURI string + scopes []string + azureTenantID string + issuerURL string + providerType string +} diff --git a/client/target.go b/client/target.go index 7dbaac3..5254559 100644 --- a/client/target.go +++ b/client/target.go @@ -6,9 +6,8 @@ import ( // Target has the information of an TargetEntry target server type Target struct { - name string - targetEntry TargetEntry - providerType string + name string + targetEntry *TargetEntry } // Aliases returns the list of aliases of the Target alphabetically sorted @@ -55,11 +54,6 @@ func (m *Target) ShouldFetchCAFromAPIServer() bool { return m.targetEntry.APIServer != "" } -// ProviderType returns the authentication provider of the Target -func (m *Target) ProviderType() string { - return m.providerType -} - // CertificateAuthorityData returns the CertificateAuthorityData of the Target func (m *Target) CertificateAuthorityData() string { return m.targetEntry.CertificateAuthorityData @@ -71,12 +65,3 @@ func sortTargets(targets []Target) []Target { }) return targets } - -// CreateTarget returns an initiliased Target object -func CreateTarget(name string, targetEntry TargetEntry, providerType string) Target { - return Target{ - name: name, - targetEntry: targetEntry, - providerType: providerType, - } -} diff --git a/cmd/login.go b/cmd/login.go index 2c4cdc2..ae60f7a 100644 --- a/cmd/login.go +++ b/cmd/login.go @@ -83,15 +83,15 @@ func login(_ *cobra.Command, _ []string) { success := true - retrievers, err := ospreyconfig.GetRetrievers(retrieverOptions) + retrievers, err := ospreyconfig.GetRetrievers(snapshot.ProviderConfigs(), retrieverOptions) if err != nil { - log.Errorf("Unable to initialise providers: %v", err) + log.Fatalf("Unable to initialise retrievers: %v", err) } - for provider, targets := range group.Targets() { - retriever, ok := retrievers[provider] + for providerName, targets := range group.TargetsForProvider() { + retriever, ok := retrievers[providerName] if !ok { - log.Fatalf("Unsupported provider: %s", provider) + log.Fatalf("Unsupported provider: %s", providerName) } for _, target := range targets { targetData, err := retriever.RetrieveClusterDetailsAndAuthTokens(target) diff --git a/cmd/logout.go b/cmd/logout.go index 46c104b..395ef28 100644 --- a/cmd/logout.go +++ b/cmd/logout.go @@ -43,15 +43,13 @@ func logout(_ *cobra.Command, _ []string) { displayActiveGroup(targetGroup, ospreyconfig.DefaultGroup) success := true - for _, targets := range group.Targets() { - for _, target := range targets { - err = kubeconfig.Remove(target.Name()) - if err != nil { - log.Errorf("Failed to remove %s from kubeconfig: %v", target.Name(), err) - success = false - } else { - log.Infof("Logged out from %s", target.Name()) - } + for _, target := range group.Targets() { + err = kubeconfig.Remove(target.Name()) + if err != nil { + log.Errorf("Failed to remove %s from kubeconfig: %v", target.Name(), err) + success = false + } else { + log.Infof("Logged out from %s", target.Name()) } } diff --git a/cmd/targets.go b/cmd/targets.go index 4874614..8922504 100644 --- a/cmd/targets.go +++ b/cmd/targets.go @@ -91,14 +91,12 @@ func displayGroup(group client.Group, listTargets bool) []string { } outputLines = append(outputLines, fmt.Sprintf("%s %s", highlight, name)) if listTargets { - for _, targets := range group.Targets() { - for _, target := range targets { - aliases := "" - if target.HasAliases() { - aliases = fmt.Sprintf(" | %s", strings.Join(target.Aliases(), " | ")) - } - outputLines = append(outputLines, fmt.Sprintf(" %s%s", target.Name(), aliases)) + for _, target := range group.Targets() { + aliases := "" + if target.HasAliases() { + aliases = fmt.Sprintf(" | %s", strings.Join(target.Aliases(), " | ")) } + outputLines = append(outputLines, fmt.Sprintf(" %s%s", target.Name(), aliases)) } } return outputLines diff --git a/cmd/user.go b/cmd/user.go index dafdc9b..9cbf2da 100644 --- a/cmd/user.go +++ b/cmd/user.go @@ -1,14 +1,13 @@ package cmd import ( + "os" "path/filepath" "github.com/sky-uk/osprey/v2/client" "github.com/sky-uk/osprey/v2/client/kubeconfig" "github.com/spf13/cobra" - "os" - log "github.com/sirupsen/logrus" ) @@ -62,27 +61,30 @@ func user(_ *cobra.Command, _ []string) { log.Fatalf("failed to load existing kubeconfig at %s: %v", kubeconfig.GetPathOptions().GetDefaultFilename(), err) } - retrievers, err := ospreyconfig.GetRetrievers(client.RetrieverOptions{}) + retrievers, err := ospreyconfig.GetRetrievers(snapshot.ProviderConfigs(), client.RetrieverOptions{}) if err != nil { log.Errorf("Unable to initialise providers: %v", err) } - for _, targets := range group.Targets() { + for providerName, targets := range group.TargetsForProvider() { for _, target := range targets { - retriever := retrievers[target.ProviderType()] + retriever := retrievers[providerName] authInfo := retriever.GetAuthInfo(config, target) if authInfo != nil { userInfo, err := retriever.RetrieveUserDetails(target, *authInfo) if err != nil { log.Errorf("%s: %v", target.Name(), err) + continue + } + provider, err := snapshot.GetProviderType(providerName) + if err != nil { + log.Errorf("%s: %v", target.Name(), err) + continue } - if userInfo != nil { - switch target.ProviderType() { - case client.OspreyProviderName: - log.Infof("%s: %s %s", target.Name(), userInfo.Username, userInfo.Roles) - default: - log.Infof("%s: %s", target.Name(), userInfo.Username) - } + if provider == client.OspreyProviderName { + log.Infof("%s: %s %s", target.Name(), userInfo.Username, userInfo.Roles) + } else { + log.Infof("%s: %s", target.Name(), userInfo.Username) } } else { log.Infof("%s: none", target.Name()) diff --git a/e2e/Dockerfile.localtest b/e2e/Dockerfile.localtest index 91404fa..901cf3e 100644 --- a/e2e/Dockerfile.localtest +++ b/e2e/Dockerfile.localtest @@ -12,4 +12,6 @@ RUN apt-get -y update && \ RUN curl -fsSL https://go.dev/dl/go1.18.10.linux-amd64.tar.gz -o /tmp/go1.18.10.linux-amd64.tar.gz && \ tar -C /usr/local -xzf /tmp/go1.18.10.linux-amd64.tar.gz -ENV PATH=$PATH:/usr/local/go/bin +WORKDIR /osprey + +ENV PATH=$PATH:/usr/local/go/bin:/osprey/build/bin/linux_amd64 diff --git a/e2e/e2e_suite_test.go b/e2e/e2e_suite_test.go index 3ee2fbc..0d06386 100644 --- a/e2e/e2e_suite_test.go +++ b/e2e/e2e_suite_test.go @@ -49,16 +49,17 @@ var ( testDir string // Suite variables modifiable per test scenario - err error - environmentsToUse map[string][]string - targetedOspreys []*ospreytest.TestOsprey - ospreyconfig *ospreytest.TestConfig - ospreyconfigFlag string - defaultGroup string - targetGroup string - targetGroupFlag string - apiServerURL string - useGKEClientConfig bool + err error + environmentsToUse map[string][]string + targetedOspreys []*ospreytest.TestOsprey + ospreyconfig *ospreytest.TestConfig + ospreyconfigFlag string + legacyOspreyconfigFlag string + defaultGroup string + targetGroup string + targetGroupFlag string + apiServerURL string + useGKEClientConfig bool ) var _ = BeforeSuite(func() { @@ -112,6 +113,7 @@ func setupClientForEnvironments(providerName string, envs map[string][]string, c ospreyconfig, err = ospreytest.BuildConfig(testDir, providerName, defaultGroup, envs, ospreys, clientID, apiServerURL, useGKEClientConfig) Expect(err).To(BeNil(), "Creates the osprey config with groups") ospreyconfigFlag = "--ospreyconfig=" + ospreyconfig.ConfigFile + legacyOspreyconfigFlag = "--ospreyconfig=" + ospreyconfig.LegacyConfigFile if targetGroup != "" { targetGroupFlag = "--group=" + targetGroup @@ -133,7 +135,12 @@ func resetDefaults() { } func cleanup() { - if err := os.Remove(ospreyconfig.Kubeconfig); err != nil { - Expect(os.IsNotExist(err)).To(BeTrue()) + if ospreyconfig != nil { + if err := os.Remove(ospreyconfig.Kubeconfig); err != nil { + Expect(os.IsNotExist(err)).To(BeTrue()) + } + if err := os.Remove(ospreyconfig.LegacyConfig.Kubeconfig); err != nil { + Expect(os.IsNotExist(err)).To(BeTrue()) + } } } diff --git a/e2e/legacy_login_test.go b/e2e/legacy_login_test.go new file mode 100644 index 0000000..d7e57fb --- /dev/null +++ b/e2e/legacy_login_test.go @@ -0,0 +1,232 @@ +package e2e + +import ( + "fmt" + "os" + + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" + "github.com/sky-uk/osprey/v2/client/kubeconfig" + "github.com/sky-uk/osprey/v2/e2e/clitest" + . "github.com/sky-uk/osprey/v2/e2e/ospreytest" + clientgo "k8s.io/client-go/tools/clientcmd/api" +) + +var _ = Describe("Sanity check Login using a legacy osprey config file format", func() { + var login clitest.LoginCommand + + BeforeEach(func() { + resetDefaults() + }) + + JustBeforeEach(func() { + setupClientForEnvironments(ospreyProviderName, environmentsToUse, "", "", false) + login = Login("user", "login", legacyOspreyconfigFlag, targetGroupFlag, "--disable-browser-popup") + }) + + AfterEach(func() { + cleanup() + }) + + It("fails to login with invalid credentials when using a legacy osprey config file", func() { + login.LoginAndAssertFailure("admin", "wrong") + }) + + It("logs in successfully with good credentials when using a legacy osprey config file", func() { + login.LoginAndAssertSuccess("jane", "foo") + }) + + It("creates a kubeconfig file on the specified location when using a legacy osprey config file", func() { + login.LoginAndAssertSuccess("jane", "foo") + Expect(ospreyconfig.LegacyConfig.Kubeconfig).To(BeAnExistingFile()) + }) + + It("logs in with certificate-authority-data when using a legacy config file", func() { + caDataConfig, err := BuildCADataConfig(testDir, ospreyProviderName, ospreys, true, "", "", "", false) + Expect(err).To(BeNil(), "Creates the osprey config") + caDataConfigFlag := "--ospreyconfig=" + caDataConfig.LegacyConfigFile + caDataLogin := Login("user", "login", caDataConfigFlag) + + caDataLogin.LoginAndAssertSuccess("jane", "foo") + }) + + It("logs in overriding certificate-authority with certificate-authority-data when using a legacy osprey config file", func() { + caDataConfig, err := BuildCADataConfig(testDir, ospreyProviderName, ospreys, true, dexes[0].DexCA, "", "", false) + Expect(err).To(BeNil(), "Creates the osprey config") + caDataConfigFlag := "--ospreyconfig=" + caDataConfig.LegacyConfigFile + caDataLogin := Login("user", "login", caDataConfigFlag) + + caDataLogin.LoginAndAssertSuccess("jane", "foo") + }) + + It("does not allow fetching CA from API Server for Osprey targets when using a legacy osprey config file", func() { + caDataConfig, err := BuildCADataConfig(testDir, ospreyProviderName, ospreys, true, + dexes[0].DexCA, "", fmt.Sprintf("http://localhost:%d", apiServerPort), false) + Expect(err).To(BeNil(), "Creates the osprey config") + caDataConfigFlag := "--ospreyconfig=" + caDataConfig.LegacyConfigFile + caDataLogin := Login("user", "login", caDataConfigFlag) + + caDataLogin.LoginAndAssertFailure("jane", "foo") + Expect(caDataLogin.GetOutput()).To(ContainSubstring("Osprey targets may not fetch the CA from the API Server")) + }) + + Context("kubeconfig file validations when using a legacy osprey config file", func() { + var ( + generatedConfig *clientgo.Config + expectedEnvironments []string + ) + + AssertKubeconfigContents := func() { + JustBeforeEach(func() { + login.LoginAndAssertSuccess("jane", "foo") + Expect(ospreyconfig.LegacyConfig.Kubeconfig).To(BeAnExistingFile()) + + err := kubeconfig.LoadConfig(ospreyconfig.LegacyConfig.Kubeconfig) + Expect(err).To(BeNil(), "expected to successfully Load a kubeconfig") + generatedConfig, err = kubeconfig.GetConfig() + Expect(err).To(BeNil(), "expected to successfully get a kubeconfig") + }) + + It("logs in to the expected targets", func() { + for _, expectedEnv := range expectedEnvironments { + Expect(generatedConfig.Clusters).To(HaveKey(OspreyconfigTargetName(expectedEnv))) + } + }) + + It("contains a cluster per osprey", func() { + for _, osprey := range targetedOspreys { + expectedCluster := osprey.ToKubeconfigCluster(ospreyconfig.LegacyConfig.Kubeconfig) + target := osprey.OspreyconfigTargetName() + Expect(generatedConfig.Clusters).To(HaveKeyWithValue(target, expectedCluster)) + } + Expect(len(generatedConfig.Clusters)).To(Equal(len(targetedOspreys)), "expected number of clusters") + }) + + It("contains a user per osprey", func() { + for _, osprey := range targetedOspreys { + expectedAuthInfo := osprey.ToKubeconfigUserWithoutToken(ospreyconfig.LegacyConfig.Kubeconfig) + authInfoID := osprey.OspreyconfigTargetName() + Expect(generatedConfig.AuthInfos).To(HaveKey(authInfoID)) + Expect(generatedConfig.AuthInfos[authInfoID]).To(WithTransform(WithoutToken, Equal(expectedAuthInfo))) + Expect(osprey.ToGroupClaims(generatedConfig.AuthInfos[authInfoID])).To(BeEquivalentTo([]string{"admins", "developers"}), "Is a valid token") + } + Expect(len(generatedConfig.AuthInfos)).To(Equal(len(targetedOspreys)), "expected number of users") + }) + + It("contains a context per osprey", func() { + for _, osprey := range targetedOspreys { + kcontext := osprey.ToKubeconfigContext(ospreyconfig.LegacyConfig.Kubeconfig) + target := osprey.OspreyconfigTargetName() + Expect(generatedConfig.Contexts).To(HaveKeyWithValue(target, kcontext)) + } + // Each context has an alias + Expect(len(generatedConfig.Contexts)).To(Equal(len(targetedOspreys)*2), "expected number of contexts") + }) + + It("contains an alias per context", func() { + for _, osprey := range targetedOspreys { + kcontext := osprey.ToKubeconfigContext(ospreyconfig.LegacyConfig.Kubeconfig) + targetAlias := osprey.OspreyconfigAliasName() + Expect(generatedConfig.Contexts).To(HaveKeyWithValue(targetAlias, kcontext)) + } + // Each alias has a corresponding context + Expect(len(generatedConfig.Contexts)).To(Equal(len(targetedOspreys)*2), "expected number of alias") + }) + + } + + Context("context with configured namespace when using a legacy osprey config file", func() { + JustBeforeEach(func() { + By("Customizing the generated contexts") + login.LoginAndAssertSuccess("jane", "foo") + err = AddCustomNamespaceToContexts("-namespace", ospreyconfig.LegacyConfig.Kubeconfig, targetedOspreys) + Expect(err).ToNot(HaveOccurred(), "successfully updates kubeconfig contexts") + + By("logging in again") + login.LoginAndAssertSuccess("jane", "foo") + + err := kubeconfig.LoadConfig(ospreyconfig.LegacyConfig.Kubeconfig) + Expect(err).To(BeNil(), "successfully creates a kubeconfig") + generatedConfig, err = kubeconfig.GetConfig() + Expect(err).To(BeNil(), "successfully creates a kubeconfig") + }) + + It("preserves namespace per context", func() { + for _, osprey := range targetedOspreys { + kcontext := osprey.ToKubeconfigContext(ospreyconfig.LegacyConfig.Kubeconfig) + kcontext.Namespace = osprey.CustomTargetNamespace("-namespace") + target := osprey.OspreyconfigTargetName() + Expect(generatedConfig.Contexts).To(HaveKeyWithValue(target, kcontext)) + } + // Each context has an alias + Expect(len(generatedConfig.Contexts)).To(Equal(len(targetedOspreys)*2), "expected number of contexts") + }) + + It("preserves namespace per alias", func() { + for _, osprey := range targetedOspreys { + kcontext := osprey.ToKubeconfigContext(ospreyconfig.LegacyConfig.Kubeconfig) + kcontext.Namespace = osprey.CustomAliasNamespace("-namespace") + targetAlias := osprey.OspreyconfigAliasName() + Expect(generatedConfig.Contexts).To(HaveKeyWithValue(targetAlias, kcontext)) + } + // Each alias has a corresponding context + Expect(len(generatedConfig.Contexts)).To(Equal(len(targetedOspreys)*2), "expected number of alias") + }) + }) + + Context("no group provided and using a legacy osprey config file", func() { + Context("no default group and using a legacy osprey config file", func() { + BeforeEach(func() { + defaultGroup = "" + expectedEnvironments = []string{"local"} + }) + + AssertKubeconfigContents() + }) + + Context("with default group and using a legacy osprey config file", func() { + BeforeEach(func() { + environmentsToUse = map[string][]string{ + "prod": {"production"}, + "dev": {"development"}, + } + defaultGroup = "production" + expectedEnvironments = []string{"prod"} + }) + + AssertKubeconfigContents() + }) + }) + + Context("group provided and using a legacy osprey config file", func() { + BeforeEach(func() { + targetGroup = "development" + expectedEnvironments = []string{"dev", "stage"} + }) + + AssertKubeconfigContents() + }) + + Context("non existent group provided and using a legacy osprey config file", func() { + BeforeEach(func() { + targetGroup = "non_existent" + }) + + It("displays error when login with group not found and using a legacy osprey config file", func() { + login.LoginAndAssertFailure("jane", "foo") + + _, err := os.Stat(ospreyconfig.LegacyConfig.Kubeconfig) + Expect(os.IsNotExist(err)).To(BeTrue()) + + Expect(login.GetOutput()).To(ContainSubstring("Group not found")) + }) + }) + }) + + Context("output", func() { + assertSharedOutputTest(func() clitest.TestCommand { + cmd := Login("user", "login", ospreyconfigFlag, targetGroupFlag) + return cmd.WithCredentials("jane", "foo") + }) + }) +}) diff --git a/e2e/login_test.go b/e2e/login_test.go index d26afaf..4a77f70 100644 --- a/e2e/login_test.go +++ b/e2e/login_test.go @@ -38,7 +38,6 @@ var _ = Describe("Login", func() { It("creates a kubeconfig file on the specified location", func() { login.LoginAndAssertSuccess("jane", "foo") - Expect(ospreyconfig.Kubeconfig).To(BeAnExistingFile()) }) diff --git a/e2e/logout_test.go b/e2e/logout_test.go index 0b79d29..ef6705e 100644 --- a/e2e/logout_test.go +++ b/e2e/logout_test.go @@ -17,6 +17,7 @@ var _ = Describe("Logout", func() { BeforeEach(func() { resetDefaults() + cleanup() }) JustBeforeEach(func() { @@ -34,7 +35,7 @@ var _ = Describe("Logout", func() { It("is a no-op", func() { logout.RunAndAssertSuccess() - kubeconfig.LoadConfig(ospreyconfig.ConfigFile) + Expect(kubeconfig.LoadConfig(ospreyconfig.Kubeconfig)).To(Succeed()) loggedOutConfig, err := kubeconfig.GetConfig() Expect(err).To(BeNil(), "no-op") Expect(loggedOutConfig.AuthInfos).To(BeEmpty()) diff --git a/e2e/ospreytest/server.go b/e2e/ospreytest/server.go index e3bbb23..f1c2400 100644 --- a/e2e/ospreytest/server.go +++ b/e2e/ospreytest/server.go @@ -2,8 +2,11 @@ package ospreytest import ( "fmt" + "os" "path/filepath" + "gopkg.in/yaml.v2" + "github.com/sky-uk/osprey/v2/client" "github.com/onsi/ginkgo" @@ -12,6 +15,7 @@ import ( "github.com/sky-uk/osprey/v2/e2e/dextest" "github.com/sky-uk/osprey/v2/e2e/ssltest" "github.com/sky-uk/osprey/v2/e2e/util" + "go.uber.org/multierr" ) const ospreyBinary = "osprey" @@ -36,7 +40,9 @@ type TestOsprey struct { // TestConfig represents an Osprey client configuration file used for testing. type TestConfig struct { *client.Config - ConfigFile string + LegacyConfig *client.ConfigV1 + ConfigFile string + LegacyConfigFile string } // StartOspreys creates one Osprey test server per TestDex provided, using ports starting from portsFrom. @@ -138,13 +144,18 @@ func BuildCADataConfig(testDir, providerName string, servers []*TestOsprey, func BuildFullConfig(testDir, providerName, defaultGroup string, targetGroups map[string][]string, servers []*TestOsprey, caData bool, caPath, clientID, apiServerURL string, useGKEClientConfig bool) (*TestConfig, error) { - config := client.NewConfig() - config.Kubeconfig = fmt.Sprintf("%s/.kube/config", testDir) + config := &client.Config{ + Kubeconfig: fmt.Sprintf("%s/.kube/config", testDir), + APIVersion: "v2", + DefaultGroup: defaultGroup, + } + configV1 := &client.ConfigV1{ + Kubeconfig: fmt.Sprintf("%s/.kube/configv1", testDir), + DefaultGroup: defaultGroup, + } ospreyconfigFile := fmt.Sprintf("%s/.osprey/config", testDir) + legacyOspreyconfigFile := fmt.Sprintf("%s/.osprey/configv1", testDir) - if defaultGroup != "" { - config.DefaultGroup = defaultGroup - } targets := make(map[string]*client.TargetEntry) var certData string var err error @@ -169,6 +180,7 @@ func BuildFullConfig(testDir, providerName, defaultGroup string, if caData { ospreyconfigFile = fmt.Sprintf("%s/.osprey/config-data", testDir) + legacyOspreyconfigFile = fmt.Sprintf("%s/.osprey/config-data", testDir) certData, err = web.LoadTLSCert(osprey.CertFile) if err != nil { return nil, err @@ -188,30 +200,55 @@ func BuildFullConfig(testDir, providerName, defaultGroup string, switch providerName { case client.AzureProviderName: + azureConfig := &client.AzureConfig{ + ClientID: clientID, + ClientSecret: "some-client-secret", + RedirectURI: "http://localhost:65525/auth/callback", + Scopes: []string{"api://some-dummy-scope"}, + AzureTenantID: "some-tenant-id", + ServerApplicationID: "some-server-application-id", + IssuerURL: "http://localhost:14980", + Targets: targets, + } config.Providers = &client.Providers{ - Azure: &client.AzureConfig{ - ClientID: clientID, - ClientSecret: "some-client-secret", - RedirectURI: "http://localhost:65525/auth/callback", - Scopes: []string{"api://some-dummy-scope"}, - AzureTenantID: "some-tenant-id", - ServerApplicationID: "some-server-application-id", - IssuerURL: "http://localhost:14980", - Targets: targets, - }, + Azure: []*client.AzureConfig{azureConfig}, + } + configV1.Providers = &client.ProvidersV1{ + Azure: azureConfig, } case client.OspreyProviderName: config.Providers = &client.Providers{ - Osprey: &client.OspreyConfig{ - //CertificateAuthority: caPath, - Targets: targets, + Osprey: []*client.OspreyConfig{ + { + //CertificateAuthority: caPath, + Targets: targets, + }, }, } - + configV1.Providers = &client.ProvidersV1{Osprey: &client.OspreyConfig{Targets: targets}} } - testConfig := &TestConfig{Config: config, ConfigFile: ospreyconfigFile} - return testConfig, client.SaveConfig(config, ospreyconfigFile) + testConfig := &TestConfig{Config: config, LegacyConfig: configV1, ConfigFile: ospreyconfigFile, LegacyConfigFile: legacyOspreyconfigFile} + err1 := SaveConfig(config, ospreyconfigFile) + err2 := SaveConfig(configV1, legacyOspreyconfigFile) + return testConfig, multierr.Combine(err1, err2) +} + +// SaveConfig writes the osprey config to the specified path. +func SaveConfig(config interface{}, path string) error { + err := os.MkdirAll(filepath.Dir(path), 0755) + if err != nil { + return fmt.Errorf("failed to access config dir %s: %w", path, err) + } + out, err := yaml.Marshal(config) + if err != nil { + return fmt.Errorf("failed to marshal config file %s: %w", path, err) + } + err = os.WriteFile(path, out, 0755) + if err != nil { + return fmt.Errorf("failed to write config file %s: %w", path, err) + } + return nil } // Client returns a TestCommand for the osprey binary with the provided args arguments. diff --git a/go.mod b/go.mod index 8b7429f..eabcc3e 100644 --- a/go.mod +++ b/go.mod @@ -7,12 +7,12 @@ require ( github.com/golang-jwt/jwt v3.2.2+incompatible github.com/golang/protobuf v1.5.3 github.com/jaytaylor/html2text v0.0.0-20211105163654-bc68cce691ba - github.com/mitchellh/go-homedir v1.1.0 github.com/onsi/ginkgo v1.16.5 github.com/onsi/gomega v1.22.1 github.com/prometheus/client_golang v1.13.0 github.com/sirupsen/logrus v1.9.0 github.com/spf13/cobra v1.6.0 + go.uber.org/multierr v1.6.0 golang.org/x/crypto v0.17.0 golang.org/x/net v0.17.0 golang.org/x/oauth2 v0.7.0 @@ -76,6 +76,7 @@ require ( github.com/spf13/pflag v1.0.5 // indirect github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf // indirect go.opencensus.io v0.24.0 // indirect + go.uber.org/atomic v1.7.0 // indirect golang.org/x/sys v0.15.0 // indirect golang.org/x/term v0.15.0 // indirect golang.org/x/text v0.14.0 // indirect diff --git a/go.sum b/go.sum index e9d3cf6..ac0d958 100644 --- a/go.sum +++ b/go.sum @@ -341,8 +341,6 @@ github.com/matttproud/golang_protobuf_extensions v1.0.1 h1:4hp9jkHxhMHkqkrB3Ix0j github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= github.com/mitchellh/copystructure v1.0.0 h1:Laisrj+bAB6b/yJwB5Bt3ITZhGJdqmxquMKeZ+mmkFQ= github.com/mitchellh/copystructure v1.0.0/go.mod h1:SNtv71yrdKgLRyLFxmLdkAbkKEFWgYaq1OVrnRcwhnw= -github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= -github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= github.com/mitchellh/reflectwalk v1.0.0 h1:9D+8oIskB4VJBN5SFlmc27fSlIBZaov1Wpk/IfikLNY= github.com/mitchellh/reflectwalk v1.0.0/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= @@ -474,6 +472,10 @@ go.opencensus.io v0.23.0/go.mod h1:XItmlyltB5F7CS4xOC1DcqMoFqwtC6OG2xF7mCv7P7E= go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0= go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo= go.opentelemetry.io/proto/otlp v0.7.0/go.mod h1:PqfVotwruBrMGOCsRd/89rSnXhoiJIqeYNgFYFoEGnI= +go.uber.org/atomic v1.7.0 h1:ADUqmZGgLDDfbSL9ZmPxKTybcoEYHgpYfELNoN+7hsw= +go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= +go.uber.org/multierr v1.6.0 h1:y6IPFStTAIT5Ytl7/XYmHvzXQ7S3g/IeZW9hyZ5thw4= +go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU= golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=