Skip to content

Commit

Permalink
Merge pull request #41 from syseleven/allow-env-auth
Browse files Browse the repository at this point in the history
Allow authentication through environment variables
  • Loading branch information
vooon authored Dec 11, 2024
2 parents 5f6eeda + 97ed917 commit 2a2678a
Show file tree
Hide file tree
Showing 8 changed files with 357 additions and 123 deletions.
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ The following parameters are supported:
|-----------------------|--------|-------------|
| `cloud` | string | Name of the cloud config from clouds.yaml to use |
| `clouds_config` | string | Optional. Path to clouds.yaml |
| `auth_from_env` | bool | Optional. Use environment variables for authentication |
| `name` | string | Name of the Auto Scaling Group (unique string that used to find instances) |
| `nova_microversion` | string | Optional. Microversion for the Openstack Nova client. Default 2.79 (which should be ok for Train+) |
| `boot_time` | string | Optional. Maximum wait time for instance to boot up. During that time plugin check Cloud-Init signatures. |
Expand All @@ -38,6 +39,8 @@ OpenStack setup
1. You should create a special user (recommended) and project (optional),
then export clouds.yaml with credentials for that cloud.

1. Optional: You can also use OS\_\* environment variables to authenticate.

2. You may create a tenant network for workers, in that case don't forget to add a router.
In that case manager VM should have two ports: external and that tenant network,
so it will be able to connect to the worker instances.
Expand Down
2 changes: 2 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ require (
github.com/aws/aws-sdk-go v1.55.5 // indirect
github.com/bodgit/ntlmssp v0.0.0-20240506230425-31973bb52d9b // indirect
github.com/bodgit/windows v1.0.1 // indirect
github.com/caarlos0/env/v11 v11.2.2 // indirect
github.com/coreos/go-json v0.0.0-20231102161613-e49c8866685a // indirect
github.com/coreos/go-semver v0.3.1 // indirect
github.com/coreos/go-systemd/v22 v22.5.0 // indirect
Expand All @@ -28,6 +29,7 @@ require (
github.com/go-logr/logr v1.4.2 // indirect
github.com/gofrs/uuid v4.4.0+incompatible // indirect
github.com/golang/protobuf v1.5.4 // indirect
github.com/gophercloud/utils/v2 v2.0.0-20241209100706-e3a3b7c07d26 // indirect
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
github.com/hashicorp/go-plugin v1.6.2 // indirect
github.com/hashicorp/go-uuid v1.0.3 // indirect
Expand Down
4 changes: 4 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ github.com/bodgit/windows v1.0.1 h1:tF7K6KOluPYygXa3Z2594zxlkbKPAOvqr97etrGNIz4=
github.com/bodgit/windows v1.0.1/go.mod h1:a6JLwrB4KrTR5hBpp8FI9/9W9jJfeQ2h4XDXU74ZCdM=
github.com/bufbuild/protocompile v0.4.0 h1:LbFKd2XowZvQ/kajzguUp2DC9UEIQhIq77fZZlaQsNA=
github.com/bufbuild/protocompile v0.4.0/go.mod h1:3v93+mbWn/v3xzN+31nwkJfrEpAUwp+BagBSZWx+TP8=
github.com/caarlos0/env/v11 v11.2.2 h1:95fApNrUyueipoZN/EhA8mMxiNxrBwDa+oAZrMWl3Kg=
github.com/caarlos0/env/v11 v11.2.2/go.mod h1:JBfcdeQiBoI3Zh1QRAWfe+tpiNTmDtcCj/hHHHMx0vc=
github.com/coreos/go-json v0.0.0-20231102161613-e49c8866685a h1:QimUZQ6Au5wFKKkPMmdoXen+CNR66lXt/76AQLBltS0=
github.com/coreos/go-json v0.0.0-20231102161613-e49c8866685a/go.mod h1:rcFZM3uxVvdyNmsAV2jopgPD1cs5SPWJWU5dOz2LUnw=
github.com/coreos/go-semver v0.3.1 h1:yi21YpKnrx1gt5R+la8n5WgS0kCrsPp33dmEyHReZr4=
Expand Down Expand Up @@ -37,6 +39,8 @@ github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/gophercloud/gophercloud/v2 v2.3.0 h1:5ipI2Mgxee0TwQxqnOIUdTbzL4ZBB8GORyZko+yGXI0=
github.com/gophercloud/gophercloud/v2 v2.3.0/go.mod h1:uJWNpTgJPSl2gyzJqcU/pIAhFUWvIkp8eE8M15n9rs4=
github.com/gophercloud/utils/v2 v2.0.0-20241209100706-e3a3b7c07d26 h1:N65GYmx5LrMeYdeXcxMESDU+2pDyAOXlFNlHl7siUwM=
github.com/gophercloud/utils/v2 v2.0.0-20241209100706-e3a3b7c07d26/go.mod h1:7SHUbtoiSYINNKgAVxse+PMhIio05IK7shHy8DVRaN0=
github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyCS8BvQ=
github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=
github.com/gorilla/sessions v1.2.1 h1:DHd3rPN5lE3Ts3D8rKkQ8x/0kqfeNmBAaiSi+o7FsgI=
Expand Down
288 changes: 288 additions & 0 deletions internal/openstackclient/openstackclient.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,288 @@
package openstackclient

import (
"context"
"crypto/tls"
"fmt"
"net/http"

"github.com/caarlos0/env/v11"
"github.com/gophercloud/gophercloud/v2"
"github.com/gophercloud/gophercloud/v2/openstack"
"github.com/gophercloud/gophercloud/v2/openstack/compute/v2/servers"
"github.com/gophercloud/gophercloud/v2/openstack/config"
"github.com/gophercloud/gophercloud/v2/openstack/config/clouds"
"github.com/gophercloud/gophercloud/v2/openstack/image/v2/images"
"github.com/gophercloud/gophercloud/v2/openstack/utils"
osClient "github.com/gophercloud/utils/v2/client"
"github.com/mitchellh/mapstructure"
)

type AuthConfig interface {
Parse() (gophercloud.AuthOptions, gophercloud.EndpointOpts, *tls.Config, error)
HTTPOpts() (debug bool, computeApiVersion string)
}

type CloudOpts struct {
AllowReauth bool `envDefault:"true"`
}

type CloudConfig struct {
ClientConfigFile string `json:"client-config-file" env:"OS_CLIENT_CONFIG_FILE"`
Cloud string `json:"cloud" env:"OS_CLOUD"`
RegionName string `json:"region-name" env:"OS_REGION_NAME"`
EndpointType string `json:"endpoint-type" env:"OS_ENDPOINT_TYPE"`
Debug bool `json:"debug" env:"OS_DEBUG"`
ComputeApiVersion string `json:"compute-api-version" env:"OS_COMPUTE_API_VERSION" envDefault:"2.79"`
}

type EnvCloudConfig struct {
CloudConfig `embed:"" yaml:",inline"`

AuthURL string `json:"auth-url" env:"OS_AUTH_URL"`
Username string `json:"username" env:"OS_USERNAME"`
UserID string `json:"user-id" env:"OS_USER_ID"`
Password string `json:"password" env:"OS_PASSWORD"`
Passcode string `json:"passcode" env:"OS_PASSCODE"`
ProjectName string `json:"project-name" env:"OS_PROJECT_NAME"`
ProjectID string `json:"project-id" env:"OS_PROJECT_ID"`
UserDomainName string `json:"user-domain-name" env:"OS_USER_DOMAIN_NAME"`
UserDomainID string `json:"user-domain-id" env:"OS_USER_DOMAIN_ID"`
ApplicationCredentialID string `json:"application-credential-id" env:"OS_APPLICATION_CREDENTIAL_ID"`
ApplicationCredentialName string `json:"application-credential-name" env:"OS_APPLICATION_CREDENTIAL_NAME"`
ApplicationCredentialSecret string `json:"application-credential-secret" env:"OS_APPLICATION_CREDENTIAL_SECRET"`
}

// Some good known properties useful for setting up ConnectInfo
//
// See also: https://docs.openstack.org/glance/latest/admin/useful-image-properties.html
type ImageProperties struct {
// Architecture that must be supported by the hypervisor.
Architecture string `json:"architecture,omitempty" mapstructure:"architecture,omitempty"`

// OSType is the operating system installed on the image.
OSType string `json:"os_type,omitempty" mapstructure:"os_type,omitempty"`

// OSDistro is the common name of the operating system distribution in lowercase
OSDistro string `json:"os_distro,omitempty" mapstructure:"os_distro,omitempty"`

// OSVersion is the operating system version as specified by the distributor.
OSVersion string `json:"os_version,omitempty" mapstructure:"os_version,omitempty"`

// OSAdminUser is the default admin user name for the operating system
OSAdminUser string `json:"os_admin_user,omitempty" mapstructure:"os_admin_user,omitempty"`
}

type Client interface {
GetImageProperties(ctx context.Context, imageRef string) (*ImageProperties, error)
ShowServerConsoleOutput(ctx context.Context, serverId string) (string, error)
GetServer(ctx context.Context, serverId string) (*servers.Server, error)
ListServers(ctx context.Context) ([]servers.Server, error)
CreateServer(ctx context.Context, spec servers.CreateOptsBuilder, hintOpts servers.SchedulerHintOptsBuilder) (*servers.Server, error)
DeleteServer(ctx context.Context, serverId string) error
}

type client struct {
compute *gophercloud.ServiceClient
image *gophercloud.ServiceClient
}

func New(ctx context.Context, authConfig AuthConfig, cloudOpts *CloudOpts) (Client, error) {
if cloudOpts == nil {
cloudOpts = &CloudOpts{}
}

var err error
err = env.Parse(cloudOpts)
if err != nil {
return nil, fmt.Errorf("failed to parse cloudOpts: %w", err)
}

err = env.Parse(authConfig)
if err != nil {
return nil, fmt.Errorf("failed to parse authConfig: %w", err)
}

providerClient, endpointOps, err := NewProviderClient(ctx, authConfig, cloudOpts)
if err != nil {
return nil, err
}

computeClient, err := NewComputeClient(ctx, providerClient, endpointOps, authConfig)
if err != nil {
return nil, err
}

imageClient, err := openstack.NewImageV2(providerClient, endpointOps)
if err != nil {
return nil, err
}

return &client{
compute: computeClient,
image: imageClient,
}, nil
}

func (cloudConfig *CloudConfig) HTTPOpts() (debug bool, computeApiVersion string) {
return cloudConfig.Debug, cloudConfig.ComputeApiVersion
}

func (cloudConfig *CloudConfig) Parse() (gophercloud.AuthOptions, gophercloud.EndpointOpts, *tls.Config, error) {
parseOpts := []clouds.ParseOption{clouds.WithCloudName(cloudConfig.Cloud)}
if cloudConfig.ClientConfigFile != "" {
parseOpts = append(parseOpts, clouds.WithLocations(cloudConfig.ClientConfigFile))
}

authOptions, endpointOpts, tlsCfg, err := clouds.Parse(parseOpts...)
if err != nil {
return gophercloud.AuthOptions{}, gophercloud.EndpointOpts{}, nil, fmt.Errorf("failed to parse clouds.yaml: %w", err)
}

if cloudConfig.RegionName != "" {
endpointOpts.Region = cloudConfig.RegionName
}
if cloudConfig.EndpointType != "" {
endpointOpts.Availability = gophercloud.Availability(cloudConfig.EndpointType)
}

return authOptions, endpointOpts, tlsCfg, nil
}

func (envCloudConfig *EnvCloudConfig) Parse() (gophercloud.AuthOptions, gophercloud.EndpointOpts, *tls.Config, error) {
if envCloudConfig.Cloud != "" {
authOptions, endpointOpts, tlsCfg, err := envCloudConfig.CloudConfig.Parse()
if err != nil {
return gophercloud.AuthOptions{}, gophercloud.EndpointOpts{}, nil, err
}

if envCloudConfig.ProjectName != "" {
authOptions.TenantName = envCloudConfig.ProjectName
authOptions.TenantID = ""
}
if envCloudConfig.ProjectID != "" {
authOptions.TenantID = envCloudConfig.ProjectID
authOptions.TenantName = ""
}

return authOptions, endpointOpts, tlsCfg, nil
}

authOptions := gophercloud.AuthOptions{
IdentityEndpoint: envCloudConfig.AuthURL,
UserID: envCloudConfig.UserID,
Username: envCloudConfig.Username,
Password: envCloudConfig.Password,
Passcode: envCloudConfig.Passcode,
TenantID: envCloudConfig.ProjectID,
TenantName: envCloudConfig.ProjectName,
DomainID: envCloudConfig.UserDomainID,
DomainName: envCloudConfig.UserDomainName,
ApplicationCredentialID: envCloudConfig.ApplicationCredentialID,
ApplicationCredentialName: envCloudConfig.ApplicationCredentialName,
ApplicationCredentialSecret: envCloudConfig.ApplicationCredentialSecret,
}

endpointOpts := gophercloud.EndpointOpts{
Region: envCloudConfig.RegionName,
Availability: gophercloud.Availability(envCloudConfig.EndpointType),
}

return authOptions, endpointOpts, nil, nil
}

func NewHTTPClient(tlsCfg *tls.Config) http.Client {
httpClient := http.Client{
Transport: http.DefaultTransport.(*http.Transport).Clone(),
}

if tlsCfg != nil {
tr := httpClient.Transport.(*http.Transport)
tr.TLSClientConfig = tlsCfg
}

httpClient.Transport = &osClient.RoundTripper{
Rt: httpClient.Transport,
}
return httpClient
}

func NewProviderClient(ctx context.Context, authConfig AuthConfig, cloudOpts *CloudOpts) (*gophercloud.ProviderClient, gophercloud.EndpointOpts, error) {
authOptions, endpointOpts, tlsCfg, err := authConfig.Parse()
if err != nil {
return nil, gophercloud.EndpointOpts{}, err
}

httpClient := NewHTTPClient(tlsCfg)
authOptions.AllowReauth = cloudOpts.AllowReauth

providerClient, err := config.NewProviderClient(ctx, authOptions, config.WithHTTPClient(httpClient))
if err != nil {
return nil, gophercloud.EndpointOpts{}, err
}

return providerClient, endpointOpts, nil
}

func NewComputeClient(ctx context.Context, providerClient *gophercloud.ProviderClient, endpointOps gophercloud.EndpointOpts, authConfig AuthConfig) (*gophercloud.ServiceClient, error) {
_, computeApiVersion := authConfig.HTTPOpts()

computeClient, err := openstack.NewComputeV2(providerClient, endpointOps)
if err != nil {
return &gophercloud.ServiceClient{}, err
}

_computeClient, err := utils.RequireMicroversion(ctx, *computeClient, computeApiVersion)
if err != nil {
return &gophercloud.ServiceClient{}, err
}

return &_computeClient, err
}

func (c *client) GetImageProperties(ctx context.Context, imageRef string) (*ImageProperties, error) {
image, err := images.Get(ctx, c.image, imageRef).Extract()
if err != nil {
return nil, fmt.Errorf("failed to get image %s: %w", imageRef, err)
}

out := new(ImageProperties)
err = mapstructure.Decode(image.Properties, out)
if err != nil {
return nil, fmt.Errorf("failed to parse properties: %w", err)
}

return out, nil
}

func (c *client) ShowServerConsoleOutput(ctx context.Context, serverId string) (string, error) {
return servers.ShowConsoleOutput(ctx, c.compute, serverId, servers.ShowConsoleOutputOpts{
Length: 100,
}).Extract()
}

func (c *client) GetServer(ctx context.Context, serverId string) (*servers.Server, error) {
return servers.Get(ctx, c.compute, serverId).Extract()
}

func (c *client) ListServers(ctx context.Context) ([]servers.Server, error) {
page, err := servers.List(c.compute, nil).AllPages(ctx)
if err != nil {
return nil, fmt.Errorf("server listing error: %w", err)
}

allServers, err := servers.ExtractServers(page)
if err != nil {
return nil, fmt.Errorf("server listing extract error: %w", err)
}

return allServers, nil
}

func (c *client) CreateServer(ctx context.Context, spec servers.CreateOptsBuilder, hintOpts servers.SchedulerHintOptsBuilder) (*servers.Server, error) {
return servers.Create(ctx, c.compute, spec, hintOpts).Extract()
}

func (c *client) DeleteServer(ctx context.Context, serverId string) error {
return servers.Delete(ctx, c.compute, serverId).ExtractErr()
}
38 changes: 38 additions & 0 deletions internal/openstackclient/openstackclient_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package openstackclient

import (
"context"
"os"
"testing"

"github.com/gophercloud/gophercloud/v2/testhelper"
thclient "github.com/gophercloud/gophercloud/v2/testhelper/client"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestGetImageProperties(t *testing.T) {
assert := assert.New(t)

img, err := os.ReadFile("../../testdata/image_get.json")
require.NoError(t, err)

testhelper.SetupHTTP()
defer testhelper.TeardownHTTP()

testhelper.ServeFile(t, "", "", "application/json", string(img))

client := &client{
compute: thclient.ServiceClient(),
image: thclient.ServiceClient(),
}

ctx := context.TODO()
props, err := client.GetImageProperties(ctx, "1da9661c-953e-424d-a1e5-834a8174b198")
assert.NoError(err)
if assert.NotNil(props) {
assert.Equal("core", props.OSAdminUser)
}

t.Log(props)
}
Loading

0 comments on commit 2a2678a

Please sign in to comment.