diff --git a/.github/workflows/update-terraform-provider.yaml b/.github/workflows/update-terraform-provider.yaml index a78d69bf..1a6460d8 100644 --- a/.github/workflows/update-terraform-provider.yaml +++ b/.github/workflows/update-terraform-provider.yaml @@ -19,7 +19,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v6 with: - go-version: '1.22' + go-version: '1.25' - name: Install golangci-lint uses: golangci/golangci-lint-action@v8 diff --git a/README.md b/README.md index d73dc3bf..abfcaf34 100644 --- a/README.md +++ b/README.md @@ -122,6 +122,7 @@ $ kubectl create namespace crossplane-system --dry-run=client -o yaml | kubectl | `organization_id` | The [organization ID](https://console.scaleway.com/organization/settings) that will be used as default value for organization-scoped resources. | | `region` | The [region](https://developers.scaleway.com/en/quickstart/#region-and-zone) that will be used as default value for all resources. (`fr-par` if none specified) | | `zone` | The [zone](https://developers.scaleway.com/en/quickstart/#region-and-zone) that will be used as default value for all resources. (`fr-par-1` if none specified) | +| `api_url` | The URL of the API | ### Create a ProviderConfig @@ -154,6 +155,23 @@ The `spec.secretRef` describes the parameters of the secret to use. * `name` is the name of the Kubernetes `secret` object. * `key` is the `Data` field from `kubectl describe secret`. +### SCW config support + +This provider can read the standard SCW config file (`~/.config/scw/config.yaml`) and environment variables. Precedence is: + +1. ProviderConfig credentials +2. Environment variables (`SCW_*`) +3. SCW config file + +You can control behavior in `ProviderConfig.spec.scw`: + +```yaml +spec: + scw: + useScwConfig: true + # path: /home/me/.config/scw/config.yaml + # profile: myProfile +``` ### Create a managed resource 1. Create a managed resource to see if the provider is properly functioning. diff --git a/apis/generate.go b/apis/generate.go index f93733f4..78b3e3e2 100644 --- a/apis/generate.go +++ b/apis/generate.go @@ -1,5 +1,4 @@ //go:build generate -// +build generate /* Copyright 2021 Upbound Inc. diff --git a/apis/v1beta1/types.go b/apis/v1beta1/types.go index 1262fe6f..7011c5a5 100644 --- a/apis/v1beta1/types.go +++ b/apis/v1beta1/types.go @@ -13,7 +13,13 @@ import ( // A ProviderConfigSpec defines the desired state of a ProviderConfig. type ProviderConfigSpec struct { // Credentials required to authenticate to this provider. + // You may set source: None to rely on env/file config. Credentials ProviderCredentials `json:"credentials"` + + // Scw controls how the Scaleway shared config is discovered and selected. + // Optional; if omitted, file discovery is enabled by default and the SDK + // will use its active profile (env can still override). + Scw *ScwConfig `json:"scw,omitempty"` } // ProviderCredentials required to authenticate. @@ -25,6 +31,18 @@ type ProviderCredentials struct { xpv1.CommonCredentialSelectors `json:",inline"` } +type ScwConfig struct { + // UseScwConfig toggles loading of the SCW config file. Defaults to true if nil. + UseScwConfig *bool `json:"useScwConfig,omitempty"` + + // Path to a specific config file. If unset, SDK discovery is used. + Path *string `json:"path,omitempty"` + + // Profile name to select from the config file. If unset, SDK active profile is used. + // Note: SCW_* environment variables still override file values. + Profile *string `json:"profile,omitempty"` +} + // A ProviderConfigStatus reflects the observed state of a ProviderConfig. type ProviderConfigStatus struct { xpv1.ProviderConfigStatus `json:",inline"` diff --git a/apis/v1beta1/zz_generated.deepcopy.go b/apis/v1beta1/zz_generated.deepcopy.go index 09df2c17..16db91ad 100644 --- a/apis/v1beta1/zz_generated.deepcopy.go +++ b/apis/v1beta1/zz_generated.deepcopy.go @@ -75,6 +75,11 @@ func (in *ProviderConfigList) DeepCopyObject() runtime.Object { func (in *ProviderConfigSpec) DeepCopyInto(out *ProviderConfigSpec) { *out = *in in.Credentials.DeepCopyInto(&out.Credentials) + if in.Scw != nil { + in, out := &in.Scw, &out.Scw + *out = new(ScwConfig) + (*in).DeepCopyInto(*out) + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ProviderConfigSpec. @@ -176,3 +181,33 @@ func (in *ProviderCredentials) DeepCopy() *ProviderCredentials { in.DeepCopyInto(out) return out } + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ScwConfig) DeepCopyInto(out *ScwConfig) { + *out = *in + if in.UseScwConfig != nil { + in, out := &in.UseScwConfig, &out.UseScwConfig + *out = new(bool) + **out = **in + } + if in.Path != nil { + in, out := &in.Path, &out.Path + *out = new(string) + **out = **in + } + if in.Profile != nil { + in, out := &in.Profile, &out.Profile + *out = new(string) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ScwConfig. +func (in *ScwConfig) DeepCopy() *ScwConfig { + if in == nil { + return nil + } + out := new(ScwConfig) + in.DeepCopyInto(out) + return out +} diff --git a/go.mod b/go.mod index 5815b3c2..8c698a94 100644 --- a/go.mod +++ b/go.mod @@ -9,6 +9,7 @@ require ( github.com/crossplane/crossplane-tools v0.0.0-20230925130601-628280f8bf79 github.com/crossplane/upjet v1.9.0 github.com/pkg/errors v0.9.1 + github.com/scaleway/scaleway-sdk-go v1.0.0-beta.35 golang.org/x/text v0.30.0 gopkg.in/yaml.v3 v3.0.1 k8s.io/apimachinery v0.34.1 diff --git a/go.sum b/go.sum index 03a9683d..c76983e1 100644 --- a/go.sum +++ b/go.sum @@ -235,6 +235,8 @@ github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoG github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/scaleway/scaleway-sdk-go v1.0.0-beta.35 h1:8xfn1RzeI9yoCUuEwDy08F+No6PcKZGEDOQ6hrRyLts= +github.com/scaleway/scaleway-sdk-go v1.0.0-beta.35/go.mod h1:47B1d/YXmSAxlJxUJxClzHR6b3T4M1WyCvwENPQNBWc= github.com/sergi/go-diff v1.0.0 h1:Kpca3qRNrduNnOQeazBd0ysaKrUJiIuISHxogkT9RPQ= github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo= github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8= diff --git a/internal/clients/scaleway.go b/internal/clients/scaleway.go index 66682779..c37ab961 100644 --- a/internal/clients/scaleway.go +++ b/internal/clients/scaleway.go @@ -16,6 +16,7 @@ import ( "github.com/crossplane/crossplane-runtime/pkg/resource" "github.com/pkg/errors" "github.com/scaleway/crossplane-provider-scaleway/internal/version" + "github.com/scaleway/scaleway-sdk-go/scw" "github.com/crossplane/upjet/pkg/terraform" @@ -27,8 +28,9 @@ const ( errNoProviderConfig = "no providerConfigRef provided" errGetProviderConfig = "cannot get referenced ProviderConfig" errTrackUsage = "cannot track ProviderConfig usage" - errExtractCredentials = "cannot extract credentials" errUnmarshalCredentials = "cannot unmarshal scaleway credentials as JSON" + errLoadSCWConfig = "cannot load SCW config file" + errGetSCWProfile = "cannot get SCW profile from config" keyAccessKey = "access_key" keySecretKey = "secret_key" @@ -36,6 +38,8 @@ const ( keyOrganizationID = "organization_id" keyRegion = "region" keyZone = "zone" + keyAPIURL = "api_url" + keyInsecure = "insecure" ) // TerraformSetupBuilder builds Terraform a terraform.SetupFn function which @@ -48,6 +52,7 @@ func TerraformSetupBuilder(tfversion, providerSource, providerVersion string) te Source: providerSource, Version: providerVersion, }, + Configuration: map[string]any{}, } configRef := mg.GetProviderConfigReference() @@ -64,32 +69,21 @@ func TerraformSetupBuilder(tfversion, providerSource, providerVersion string) te return ps, errors.Wrap(err, errTrackUsage) } - data, err := resource.CommonCredentialExtractor(ctx, pc.Spec.Credentials.Source, client, pc.Spec.Credentials.CommonCredentialSelectors) + // Load Scaleway config file + Env (Env > File) + profile, err := resolveScwProfile(pc) if err != nil { - return ps, errors.Wrap(err, errExtractCredentials) - } - creds := map[string]string{} - if err := json.Unmarshal(data, &creds); err != nil { - return ps, errors.Wrap(err, errUnmarshalCredentials) - } - - scalewayCreds := map[string]string{} - if err := json.Unmarshal(data, &scalewayCreds); err != nil { - return ps, errors.Wrap(err, errUnmarshalCredentials) + return ps, err } + fillConfigFromProfile(ps.Configuration, profile) - ps.Configuration = map[string]interface{}{} - for _, key := range []string{ - keyAccessKey, - keySecretKey, - keyProjectID, - keyOrganizationID, - keyRegion, - keyZone, - } { - if scalewayCreds[key] != "" { - ps.Configuration[key] = scalewayCreds[key] + // Overlay Secret (if any). Secret > Env > File + data, err := resource.CommonCredentialExtractor(ctx, pc.Spec.Credentials.Source, client, pc.Spec.Credentials.CommonCredentialSelectors) + if err == nil && len(data) > 0 { + vals := map[string]string{} + if err := json.Unmarshal(data, &vals); err != nil { + return ps, errors.Wrap(err, errUnmarshalCredentials) } + overlayCredentials(ps.Configuration, vals) } // Set the custom user agent @@ -102,3 +96,121 @@ func TerraformSetupBuilder(tfversion, providerSource, providerVersion string) te return ps, nil } } + +// overlayCredentials overlays non-empty Secret values over existing config +func overlayCredentials(cfg map[string]any, vals map[string]string) { + for _, k := range []string{ + keyAccessKey, keySecretKey, keyProjectID, keyOrganizationID, + keyRegion, keyZone, keyAPIURL, keyInsecure, + } { + if v, ok := vals[k]; ok && v != "" { + cfg[k] = v + } + } +} + +func resolveScwProfile(pc *v1beta1.ProviderConfig) (*scw.Profile, error) { + useFile := shouldUseScwConfig(pc) + + fileProf, err := readScwConfigProfile(pc, useFile) + if err != nil { + return nil, err + } + + envProf := scw.LoadEnvProfile() + return scw.MergeProfiles(fileProf, envProf), nil +} + +func readScwConfigProfile(pc *v1beta1.ProviderConfig, useFile bool) (*scw.Profile, error) { + if !useFile { + return nil, nil + } + + cfg, err := loadScwConfigFile(pc) + if err != nil { + return nil, err + } + if cfg == nil { + return nil, nil + } + + return selectScwProfile(pc, cfg) +} + +func loadScwConfigFile(pc *v1beta1.ProviderConfig) (*scw.Config, error) { + var cfg *scw.Config + var err error + + if hasScwPath(pc) { + cfg, err = scw.LoadConfigFromPath(*pc.Spec.Scw.Path) + } else { + cfg, err = scw.LoadConfig() + } + + var notFound *scw.ConfigFileNotFoundError + if err != nil && !errors.As(err, ¬Found) { + return nil, errors.Wrap(err, errLoadSCWConfig) + } + + return cfg, nil +} + +func selectScwProfile(pc *v1beta1.ProviderConfig, cfg *scw.Config) (*scw.Profile, error) { + if cfg == nil { + return nil, nil + } + + if hasScwProfile(pc) { + prof, err := cfg.GetProfile(*pc.Spec.Scw.Profile) + if err != nil { + return nil, errors.Wrap(err, errGetSCWProfile) + } + return prof, nil + } + + prof, err := cfg.GetActiveProfile() + if err != nil { + return nil, errors.Wrap(err, errGetSCWProfile) + } + return prof, nil +} + +func hasScwPath(pc *v1beta1.ProviderConfig) bool { + return pc.Spec.Scw != nil && pc.Spec.Scw.Path != nil && *pc.Spec.Scw.Path != "" +} + +func hasScwProfile(pc *v1beta1.ProviderConfig) bool { + return pc.Spec.Scw != nil && pc.Spec.Scw.Profile != nil && *pc.Spec.Scw.Profile != "" +} + +func shouldUseScwConfig(pc *v1beta1.ProviderConfig) bool { + if pc.Spec.Scw != nil && pc.Spec.Scw.UseScwConfig != nil { + return *pc.Spec.Scw.UseScwConfig + } + return true +} + +// fillConfigFromProfile copies non-empty profile fields into cfg +func fillConfigFromProfile(cfg map[string]any, p *scw.Profile) { + if cfg == nil || p == nil { + return + } + + assign := func(key string, val *string) { + if val != nil && *val != "" { + cfg[key] = *val + } + } + + assign(keyAccessKey, p.AccessKey) + assign(keySecretKey, p.SecretKey) + assign(keyProjectID, p.DefaultProjectID) + assign(keyOrganizationID, p.DefaultOrganizationID) + assign(keyRegion, p.DefaultRegion) + assign(keyZone, p.DefaultZone) + assign(keyAPIURL, p.APIURL) + + if p.Insecure != nil { + cfg[keyInsecure] = *p.Insecure + } +} diff --git a/package/crds/scaleway.upbound.io_providerconfigs.yaml b/package/crds/scaleway.upbound.io_providerconfigs.yaml index 92f1a082..c66e88d8 100644 --- a/package/crds/scaleway.upbound.io_providerconfigs.yaml +++ b/package/crds/scaleway.upbound.io_providerconfigs.yaml @@ -52,7 +52,9 @@ spec: description: A ProviderConfigSpec defines the desired state of a ProviderConfig. properties: credentials: - description: Credentials required to authenticate to this provider. + description: |- + Credentials required to authenticate to this provider. + You may set source: None to rely on env/file config. properties: env: description: |- @@ -107,6 +109,26 @@ spec: required: - source type: object + scw: + description: |- + Scw controls how the Scaleway shared config is discovered and selected. + Optional; if omitted, file discovery is enabled by default and the SDK + will use its active profile (env can still override). + properties: + path: + description: Path to a specific config file. If unset, SDK discovery + is used. + type: string + profile: + description: |- + Profile name to select from the config file. If unset, SDK active profile is used. + Note: SCW_* environment variables still override file values. + type: string + useScwConfig: + description: UseScwConfig toggles loading of the SCW config file. + Defaults to true if nil. + type: boolean + type: object required: - credentials type: object