diff --git a/README.md b/README.md index 353c7bf..edbcd33 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ reusability. Feel free to add to this. * [audittools](./audittools) contains helper functions for establishing a connection to a RabbitMQ server (with sane defaults) and publishing messages to it. * [easypg](./easypg) is a database library for applications that use PostgreSQL. It integrates [golang-migrate/migrate](https://github.com/golang-migrate/migrate) for data definition and imports the libpq-based SQL driver. * [errext](./errext) contains convenience functions for handling and propagating errors. +* [gophercloudext](./gophercloudext) contains convenience functions for use with [Gophercloud](https://github.com/gophercloud/gophercloud). It is specifically intended as a lightweight replacement for `gophercloud/utils` with fewer dependencies. * [gopherpolicy](./gopherpolicy) integrates [Gophercloud](https://github.com/gophercloud/gophercloud) with [goslo.policy](https://github.com/databus23/goslo.policy), for OpenStack services that need to validate client tokens and check permissions. * [httpapi](./httpapi) contains opinionated base machinery for assembling and exposing an API consisting of HTTP endpoints. * [httpext](./httpext) adds some convenience functions to [net/http](https://golang.org/pkg/http/). diff --git a/gophercloudext/auth.go b/gophercloudext/auth.go new file mode 100644 index 0000000..ba0db89 --- /dev/null +++ b/gophercloudext/auth.go @@ -0,0 +1,147 @@ +/******************************************************************************* +* +* Copyright 2024 SAP SE +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You should have received a copy of the License along with this +* program. If not, you may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +* +*******************************************************************************/ + +// Package gophercloudext contains convenience functions for use with [Gophercloud]. +// It is specifically intended as a lightweight replacement for [gophercloud/utils] with fewer dependencies. +// +// [Gophercloud]: https://github.com/gophercloud/gophercloud +// [gophercloud/utils]: https://github.com/gophercloud/utils +package gophercloudext + +import ( + "context" + "fmt" + "net/http" + "os" + "strings" + + "github.com/gophercloud/gophercloud/v2" + "github.com/gophercloud/gophercloud/v2/openstack" + + "github.com/sapcc/go-bits/osext" +) + +// ClientOpts contains configuration for NewProviderClient(). +type ClientOpts struct { + // EnvPrefix allows a custom environment variable prefix to be used. + // If not set, "OS_" is used. + EnvPrefix string + + // HTTPClient is the ProviderClient's internal HTTP client. + // If not set, a fresh http.Client using http.DefaultTransport will be used. + // + // This is a weird behavior, but we cannot do better because + // gophercloud.ProviderClient insists on taking ownership of whatever is + // given here, so we cannot just give http.DefaultClient here. + HTTPClient *http.Client +} + +// NewProviderClient authenticates with OpenStack using the credentials found +// in the usual OS_* environment variables. +// +// Ref: https://docs.openstack.org/python-openstackclient/latest/cli/man/openstack.html +// +// This function has the same purpose as AuthenticatedClient from package +// github.com/gophercloud/utils/openstack/clientconfig, except for some +// differences that make it specifically suited for long-running server +// applications and remove functionality only needed for interactive use: +// +// - It always sets AllowReauth on the ProviderClient. +// - It does not support authenticating with a pre-existing Keystone token. +// - It does not support reading clouds.yaml files. +// - It does not support the old Keystone v2 authentication (only v3). +// +// Also, to simplify things, some legacy or fallback environment variables are +// not supported: +// +// - OS_TENANT_ID (give OS_PROJECT_ID instead) +// - OS_TENANT_NAME (give OS_PROJECT_NAME instead) +// - OS_DEFAULT_DOMAIN_ID (give OS_PROJECT_DOMAIN_ID and OS_USER_DOMAIN_ID instead) +// - OS_DEFAULT_DOMAIN_NAME (give OS_PROJECT_DOMAIN_NAME and OS_USER_DOMAIN_NAME instead) +// - OS_APPLICATION_CREDENTIAL_NAME (give OS_APPLICATION_CREDENTIAL_ID instead) +func NewProviderClient(ctx context.Context, optsPtr *ClientOpts) (*gophercloud.ProviderClient, gophercloud.EndpointOpts, error) { + // apply defaults to `opts` + var opts ClientOpts + if optsPtr != nil { + opts = *optsPtr + } + if opts.EnvPrefix == "" { + opts.EnvPrefix = "OS_" + } + if opts.HTTPClient == nil { + opts.HTTPClient = &http.Client{} + } + + // expect an auth URL for v3 + authURL, err := osext.NeedGetenv(opts.EnvPrefix + "AUTH_URL") + if err != nil { + return nil, gophercloud.EndpointOpts{}, err + } + if !strings.Contains(authURL, "/v3") { + return nil, gophercloud.EndpointOpts{}, fmt.Errorf( + "expected %sAUTH_URL to refer to Keystone v3, but got %s", opts.EnvPrefix, authURL, + ) + } + + // most other consistency checks are delegated to gophercloud.AuthOptions, + // so we just build an AuthOptions without checking a lot of stuff + scope := gophercloud.AuthScope{ + ProjectID: os.Getenv(opts.EnvPrefix + "PROJECT_ID"), + ProjectName: os.Getenv(opts.EnvPrefix + "PROJECT_NAME"), + } + if scope.ProjectID == "" && scope.ProjectName == "" { + // not project scope, so might be domain scope + scope.DomainID = os.Getenv(opts.EnvPrefix + "DOMAIN_ID") + scope.DomainName = os.Getenv(opts.EnvPrefix + "DOMAIN_NAME") + if scope.DomainID == "" && scope.DomainName == "" { + // not domain scope either, so might be system scope + scope.System = os.Getenv(opts.EnvPrefix+"SYSTEM_SCOPE") != "" + } + } else { + // definitely project scope + scope.DomainID = os.Getenv(opts.EnvPrefix + "PROJECT_DOMAIN_ID") + scope.DomainName = os.Getenv(opts.EnvPrefix + "PROJECT_DOMAIN_NAME") + } + ao := gophercloud.AuthOptions{ + IdentityEndpoint: authURL, + Username: os.Getenv(opts.EnvPrefix + "USERNAME"), + UserID: os.Getenv(opts.EnvPrefix + "USER_ID"), + Password: os.Getenv(opts.EnvPrefix + "PASSWORD"), + AllowReauth: true, + Scope: &scope, + ApplicationCredentialID: os.Getenv(opts.EnvPrefix + "APPLICATION_CREDENTIAL_ID"), + ApplicationCredentialSecret: os.Getenv(opts.EnvPrefix + "APPLICATION_CREDENTIAL_SECRET"), + } + + provider, err := openstack.NewClient(ao.IdentityEndpoint) + if err == nil { + provider.HTTPClient = *opts.HTTPClient + err = openstack.Authenticate(ctx, provider, ao) + } + if err != nil { + return nil, gophercloud.EndpointOpts{}, fmt.Errorf( + "cannot initialize OpenStack client from %s* variables: %w", opts.EnvPrefix, err) + } + + eo := gophercloud.EndpointOpts{ + Availability: gophercloud.Availability(os.Getenv(opts.EnvPrefix + "INTERFACE")), + Region: os.Getenv(opts.EnvPrefix + "REGION_NAME"), + } + return provider, eo, nil +}