Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow authentication through environment variables #41

Merged
merged 6 commits into from
Dec 11, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading