From dab9ad6722ad7dff7795b334c65e52bf6978f51d Mon Sep 17 00:00:00 2001 From: cw-kojima1003 Date: Mon, 26 Jan 2026 07:21:16 +0000 Subject: [PATCH] provider: add openstack cloud support This commit allows cloud-api-adaptor(CAA) to support OpenStack-based clouds. There is currently no provider in CAA that supports clouds built with standard OpenStack. By implementing an OpenStack provider, we can offer secure pod execution environments in a wider range of fields. Adding an inbox Provider for OpenStack, referring to the following documentation. --- https://github.com/confidential-containers/cloud-api-adaptor/blob/main/src/cloud-api-adaptor/docs/addnewprovider.md - Add and initialize the OpenStack provider manager - Add a definition for the configuration struct - Add cloud interfaces - Add provider interfaces - Add additional files to modularize the code - Add relevant unit tests - Update entrypoint.sh and Makefile Signed-off-by: cw-kojima1003 --- src/cloud-api-adaptor/Makefile | 4 +- .../cmd/cloud-api-adaptor/openstack.go | 10 + src/cloud-api-adaptor/entrypoint.sh | 15 +- src/cloud-api-adaptor/go.mod | 1 + src/cloud-api-adaptor/go.sum | 2 + .../overlays/openstack/kustomization.yaml | 73 + .../openstack/openstack-cred.env.sample | 6 + .../openstack/tls_certs_volume_mount.yaml | 21 + src/cloud-providers/go.mod | 1 + src/cloud-providers/go.sum | 2 + src/cloud-providers/openstack/manager.go | 48 + src/cloud-providers/openstack/manager_test.go | 321 ++++ src/cloud-providers/openstack/openstack.go | 180 ++ .../openstack/openstack_test.go | 1472 +++++++++++++++++ src/cloud-providers/openstack/provider.go | 173 ++ .../openstack/provider_test.go | 1014 ++++++++++++ src/cloud-providers/openstack/types.go | 56 + src/cloud-providers/openstack/types_test.go | 42 + src/peerpod-ctrl/controllers/openstack.go | 10 + src/peerpod-ctrl/go.mod | 1 + src/peerpod-ctrl/go.sum | 2 + 21 files changed, 3450 insertions(+), 4 deletions(-) create mode 100644 src/cloud-api-adaptor/cmd/cloud-api-adaptor/openstack.go create mode 100644 src/cloud-api-adaptor/install/overlays/openstack/kustomization.yaml create mode 100644 src/cloud-api-adaptor/install/overlays/openstack/openstack-cred.env.sample create mode 100644 src/cloud-api-adaptor/install/overlays/openstack/tls_certs_volume_mount.yaml create mode 100644 src/cloud-providers/openstack/manager.go create mode 100644 src/cloud-providers/openstack/manager_test.go create mode 100644 src/cloud-providers/openstack/openstack.go create mode 100644 src/cloud-providers/openstack/openstack_test.go create mode 100644 src/cloud-providers/openstack/provider.go create mode 100644 src/cloud-providers/openstack/provider_test.go create mode 100644 src/cloud-providers/openstack/types.go create mode 100644 src/cloud-providers/openstack/types_test.go create mode 100644 src/peerpod-ctrl/controllers/openstack.go diff --git a/src/cloud-api-adaptor/Makefile b/src/cloud-api-adaptor/Makefile index f35bd03781..b061c7ff58 100644 --- a/src/cloud-api-adaptor/Makefile +++ b/src/cloud-api-adaptor/Makefile @@ -25,9 +25,9 @@ RUN_TESTS ?= '' RESOURCE_CTRL ?= true # BUILTIN_CLOUD_PROVIDERS is used for binary build -- what providers are built in the binaries. ifeq ($(RELEASE_BUILD),true) - BUILTIN_CLOUD_PROVIDERS ?= alibabacloud aws azure gcp ibmcloud ibmcloud_powervs + BUILTIN_CLOUD_PROVIDERS ?= alibabacloud aws azure gcp ibmcloud ibmcloud_powervs openstack else - BUILTIN_CLOUD_PROVIDERS ?= alibabacloud aws azure byom gcp ibmcloud ibmcloud_powervs libvirt docker + BUILTIN_CLOUD_PROVIDERS ?= alibabacloud aws azure byom gcp ibmcloud ibmcloud_powervs libvirt docker openstack endif all: build diff --git a/src/cloud-api-adaptor/cmd/cloud-api-adaptor/openstack.go b/src/cloud-api-adaptor/cmd/cloud-api-adaptor/openstack.go new file mode 100644 index 0000000000..d6f94f31ca --- /dev/null +++ b/src/cloud-api-adaptor/cmd/cloud-api-adaptor/openstack.go @@ -0,0 +1,10 @@ +//go:build openstack + +// (C) Copyright Confidential Containers Contributors +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + _ "github.com/confidential-containers/cloud-api-adaptor/src/cloud-providers/openstack" +) diff --git a/src/cloud-api-adaptor/entrypoint.sh b/src/cloud-api-adaptor/entrypoint.sh index 816becc9ce..6403c41f0a 100755 --- a/src/cloud-api-adaptor/entrypoint.sh +++ b/src/cloud-api-adaptor/entrypoint.sh @@ -122,12 +122,21 @@ byom() { } +openstack() { + test_vars OPENSTACK_IMAGE_ID OPENSTACK_FLAVOR_ID OPENSTACK_SECURITY_GROUP + test_vars OPENSTACK_USERNAME OPENSTACK_PASSWORD OPENSTACK_REGION OPENSTACK_TENANT_NAME OPENSTACK_DOMAIN_NAME OPENSTACK_IDENTITY_ENDPOINT + + set -x + exec cloud-api-adaptor openstack ${optionals} + +} + help_msg() { cat < # set - relative path to ca.crt, located either in the same folder as the kustomization.yaml file or within a subfolder +# - # set - relative path to client.crt, located either in the same folder as the kustomization.yaml file or within a subfolder +# - # set - relative path to client.key, located either in the same folder as the kustomization.yaml file or within a subfolder +##TLS_SETTINGS + +patchesStrategicMerge: +##TLS_SETTINGS + #- tls_certs_volume_mount.yaml # set (for tls) +##TLS_SETTINGS diff --git a/src/cloud-api-adaptor/install/overlays/openstack/openstack-cred.env.sample b/src/cloud-api-adaptor/install/overlays/openstack/openstack-cred.env.sample new file mode 100644 index 0000000000..fe16a8697b --- /dev/null +++ b/src/cloud-api-adaptor/install/overlays/openstack/openstack-cred.env.sample @@ -0,0 +1,6 @@ +OPENSTACK_IDENTITY_ENDPOINT= +OPENSTACK_USERNAME= +OPENSTACK_PASSWORD= +OPENSTACK_TENANT_NAME= +OPENSTACK_DOMAIN_NAME= +OPENSTACK_REGION= diff --git a/src/cloud-api-adaptor/install/overlays/openstack/tls_certs_volume_mount.yaml b/src/cloud-api-adaptor/install/overlays/openstack/tls_certs_volume_mount.yaml new file mode 100644 index 0000000000..c82c10f399 --- /dev/null +++ b/src/cloud-api-adaptor/install/overlays/openstack/tls_certs_volume_mount.yaml @@ -0,0 +1,21 @@ +apiVersion: apps/v1 +kind: DaemonSet +metadata: + name: cloud-api-adaptor-daemonset + namespace: confidential-containers-system + labels: + app: cloud-api-adaptor +spec: + template: + spec: + containers: + - name: cloud-api-adaptor-con + volumeMounts: + - mountPath: /etc/certificates + name: certs + volumes: + - name: certs + secret: + secretName: certs-for-tls + +# to apply this uncomment the patchesStrategicMerge of this file in kustomization.yaml diff --git a/src/cloud-providers/go.mod b/src/cloud-providers/go.mod index 1db62dab00..36c99b542a 100644 --- a/src/cloud-providers/go.mod +++ b/src/cloud-providers/go.mod @@ -25,6 +25,7 @@ require ( github.com/aws/aws-sdk-go-v2/service/ec2 v1.279.2 github.com/docker/docker v28.3.3+incompatible github.com/docker/go-connections v0.5.0 + github.com/gophercloud/gophercloud/v2 v2.8.0 github.com/kdomanski/iso9660 v0.4.0 github.com/stretchr/testify v1.11.1 golang.org/x/crypto v0.45.0 diff --git a/src/cloud-providers/go.sum b/src/cloud-providers/go.sum index f396855da2..56059dd7cd 100644 --- a/src/cloud-providers/go.sum +++ b/src/cloud-providers/go.sum @@ -264,6 +264,8 @@ github.com/googleapis/enterprise-certificate-proxy v0.3.7 h1:zrn2Ee/nWmHulBx5sAV github.com/googleapis/enterprise-certificate-proxy v0.3.7/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA= github.com/googleapis/gax-go/v2 v2.15.0 h1:SyjDc1mGgZU5LncH8gimWo9lW1DtIfPibOG81vgd/bo= github.com/googleapis/gax-go/v2 v2.15.0/go.mod h1:zVVkkxAQHa1RQpg9z2AUCMnKhi0Qld9rcmyfL1OZhoc= +github.com/gophercloud/gophercloud/v2 v2.8.0 h1:of2+8tT6+FbEYHfYC8GBu8TXJNsXYSNm9KuvpX7Neqo= +github.com/gophercloud/gophercloud/v2 v2.8.0/go.mod h1:Ki/ILhYZr/5EPebrPL9Ej+tUg4lqx71/YH2JWVeU+Qk= github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= github.com/gopherjs/gopherjs v0.0.0-20200217142428-fce0ec30dd00/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= github.com/grpc-ecosystem/grpc-gateway/v2 v2.16.0 h1:YBftPWNWd4WwGqtY2yeZL2ef8rHAxPBD8KFhJpmcqms= diff --git a/src/cloud-providers/openstack/manager.go b/src/cloud-providers/openstack/manager.go new file mode 100644 index 0000000000..36f6e9bf2e --- /dev/null +++ b/src/cloud-providers/openstack/manager.go @@ -0,0 +1,48 @@ +// (C) Copyright Confidential Containers Contributors +// SPDX-License-Identifier: Apache-2.0 + +package openstack + +import ( + "flag" + + provider "github.com/confidential-containers/cloud-api-adaptor/src/cloud-providers" +) + +var openstackcfg Config + +type Manager struct{} + +func init() { + provider.AddCloudProvider("openstack", &Manager{}) +} + +func (_ *Manager) ParseCmd(flags *flag.FlagSet) { + reg := provider.NewFlagRegistrar(flags) + + reg.StringWithEnv(&openstackcfg.ServerPrefix, "server-prefix", "", "OPENSTACK_SERVER_PREFIX", "server-prefix") + reg.StringWithEnv(&openstackcfg.ImageID, "imageID", "", "OPENSTACK_IMAGE_ID", "openstack-image-id") + reg.StringWithEnv(&openstackcfg.FlavorID, "flavorID", "", "OPENSTACK_FLAVOR_ID", "openstack-flavor-id") + reg.CustomTypeWithEnv(&openstackcfg.NetworkIDs, "networkID", "", "OPENSTACK_NETWORK_ID", "openstack-network-id") + reg.CustomTypeWithEnv(&openstackcfg.SecurityGroups, "security-group", "", "OPENSTACK_SECURITY_GROUP", "openstack-security-group") + reg.StringWithEnv(&openstackcfg.FloatingIpNetworkID, "floating-ip-networkID", "", "OPENSTACK_FLOATING_IP_NETWORK_ID", "openstack-floating-ip-network-id") + + reg.StringWithEnv(&openstackcfg.Username, "openstack-username", "", "OPENSTACK_USERNAME", "openstack-username") + reg.StringWithEnv(&openstackcfg.Password, "openstack-password", "", "OPENSTACK_PASSWORD", "openstack-password") + reg.StringWithEnv(&openstackcfg.Region, "openstack-region", "", "OPENSTACK_REGION", "openstack-region") + reg.StringWithEnv(&openstackcfg.TenantName, "openstack-tenant-name", "", "OPENSTACK_TENANT_NAME", "openstack-tenant-name") + reg.StringWithEnv(&openstackcfg.DomainName, "openstack-domain-name", "", "OPENSTACK_DOMAIN_NAME", "openstack-domain-name") + reg.StringWithEnv(&openstackcfg.IdentityEndpoint, "openstack-identity-endpoint", "", "OPENSTACK_IDENTITY_ENDPOINT", "openstack-identity-endpoint") +} + +func (_ *Manager) LoadEnv() { + // No longer needed - environment variables are handled in ParseCmd +} + +func (_ *Manager) NewProvider() (provider.Provider, error) { + return NewProvider(&openstackcfg) +} + +func (_ *Manager) GetConfig() (config *Config) { + return &openstackcfg +} diff --git a/src/cloud-providers/openstack/manager_test.go b/src/cloud-providers/openstack/manager_test.go new file mode 100644 index 0000000000..f488b7f933 --- /dev/null +++ b/src/cloud-providers/openstack/manager_test.go @@ -0,0 +1,321 @@ +// (C) Copyright Confidential Containers Contributors +// SPDX-License-Identifier: Apache-2.0 + +package openstack + +import ( + "flag" + "fmt" + "os" + "testing" +) + +type TestManager struct { + *Manager +} + +func TestParseCmd(t *testing.T) { + tests := []struct { + name string + args []string + expected Config + }{ + { + name: "AllFlagsSet", + args: []string{ + "-server-prefix=test-vm-name", + "-imageID=test-image-id", + "-flavorID=test-flavor-id", + "-networkID=net-1,net-2,net-3", + "-security-group=sg-1,sg-2,sg-3", + "-floating-ip-networkID=floating-net", + "-openstack-username=test-user", + "-openstack-password=test-password", + "-openstack-region=test-region", + "-openstack-tenant-name=test-tenant", + "-openstack-domain-name=test-domain", + "-openstack-identity-endpoint=https://identity.testopenstack/v3", + }, + expected: Config{ + ServerPrefix: "test-vm-name", + ImageID: "test-image-id", + FlavorID: "test-flavor-id", + NetworkIDs: []string{"net-1", "net-2", "net-3"}, + SecurityGroups: []string{"sg-1", "sg-2", "sg-3"}, + FloatingIpNetworkID: "floating-net", + Username: "test-user", + Password: "test-password", + Region: "test-region", + TenantName: "test-tenant", + DomainName: "test-domain", + IdentityEndpoint: "https://identity.testopenstack/v3", + }, + }, + { + name: "DefaultValues", + args: []string{}, + expected: Config{ + ServerPrefix: "", + ImageID: "", + FlavorID: "", + NetworkIDs: []string{}, + SecurityGroups: []string{}, + FloatingIpNetworkID: "", + Username: "", + Password: "", + Region: "", + TenantName: "", + DomainName: "", + IdentityEndpoint: "", + }, + }, + { + name: "SingleNetworkAndSecurityGroup", + args: []string{ + "-networkID=net-1", + "-security-group=sg-1", + }, + expected: Config{ + NetworkIDs: []string{"net-1"}, + SecurityGroups: []string{"sg-1"}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + + flags := flag.NewFlagSet("test", flag.ContinueOnError) + testManager := &Manager{} + testManager.ParseCmd(flags) + err := flags.Parse(tt.args) + + if err != nil { + t.Errorf("Failed to parse flags: %v", err) + } + + if !comparestructs(openstackcfg, tt.expected) { + t.Errorf("Expected config: %+v, but got: %+v\n", tt.expected, openstackcfg) + } else { + t.Logf("Expected config: %+v, got config: %+v\n", tt.expected, openstackcfg) + } + + flags = nil + openstackcfg = Config{} + }) + } +} + +func comparestructs(result, expected Config) bool { + isEqual := true + + if result.ServerPrefix != expected.ServerPrefix { + fmt.Printf("Expected ServerPrefix: %v, but got: %v\n", expected.ServerPrefix, result.ServerPrefix) + isEqual = false + } + + if result.ImageID != expected.ImageID { + fmt.Printf("Expected ImageID: %v, but got: %v\n", expected.ImageID, result.ImageID) + isEqual = false + } + + if result.FlavorID != expected.FlavorID { + fmt.Printf("Expected FlavorID: %v, but got: %v\n", expected.FlavorID, result.FlavorID) + isEqual = false + } + + if len(result.NetworkIDs) != len(expected.NetworkIDs) { + fmt.Printf("Expected NetworkIDs length: %v, but got: %v\n", len(expected.NetworkIDs), len(result.NetworkIDs)) + isEqual = false + } else { + for i := range expected.NetworkIDs { + if result.NetworkIDs[i] != expected.NetworkIDs[i] { + fmt.Printf("Expected NetworkIDs[%d]: %v, but got: %v\n", i, expected.NetworkIDs[i], result.NetworkIDs[i]) + isEqual = false + } + } + } + + if len(result.SecurityGroups) != len(expected.SecurityGroups) { + fmt.Printf("Expected SecurityGroups length: %v, but got: %v\n", len(expected.SecurityGroups), len(result.SecurityGroups)) + isEqual = false + } else { + for i := range expected.SecurityGroups { + if result.SecurityGroups[i] != expected.SecurityGroups[i] { + fmt.Printf("Expected SecurityGroups[%d]: %v, but got: %v\n", i, expected.SecurityGroups[i], result.SecurityGroups[i]) + isEqual = false + } + } + } + if result.FloatingIpNetworkID != expected.FloatingIpNetworkID { + fmt.Printf("Expected FloatingIpNetworkID: %v, but got: %v\n", expected.FloatingIpNetworkID, result.FloatingIpNetworkID) + isEqual = false + } + if result.Username != expected.Username { + fmt.Printf("Expected Username: %v, but got: %v\n", expected.Username, result.Username) + isEqual = false + } + if result.Password != expected.Password { + fmt.Printf("Expected Password: %v, but got: %v\n", expected.Password, result.Password) + isEqual = false + } + if result.Region != expected.Region { + fmt.Printf("Expected Region: %v, but got: %v\n", expected.Region, result.Region) + isEqual = false + } + if result.TenantName != expected.TenantName { + fmt.Printf("Expected TenantName: %v, but got: %v\n", expected.TenantName, result.TenantName) + isEqual = false + } + if result.DomainName != expected.DomainName { + fmt.Printf("Expected DomainName: %v, but got: %v\n", expected.DomainName, result.DomainName) + isEqual = false + } + if result.IdentityEndpoint != expected.IdentityEndpoint { + fmt.Printf("Expected IdentityEndpoint: %v, but got: %v\n", expected.IdentityEndpoint, result.IdentityEndpoint) + isEqual = false + } + + return isEqual +} + +func TestLoadEnv(t *testing.T) { + testManager := &Manager{} + testManager.LoadEnv() +} + +func TestManagerNewProvider(t *testing.T) { + server := CreateServer() + + tests := []struct { + name string + input Config + wantErr bool + }{ + { + name: "ValidConfig", + input: Config{ + IdentityEndpoint: server.URL + "/v3", + Username: "test-user", + TenantName: "test-tenant", + Password: "test-password", + DomainName: "test-domain", + Region: "test-region", + ServerPrefix: "test-vm-name", + ImageID: "test-image-id", + FlavorID: "test-flavor-id", + NetworkIDs: []string{"net-1"}, + SecurityGroups: []string{"sg-1"}, + FloatingIpNetworkID: "floating-net", + }, + wantErr: false, + }, + { + name: "InvalidEndpointConfig", + input: Config{ + IdentityEndpoint: "http://bad-address.example.com/v3", + Username: "test-user", + TenantName: "test-tenant", + Password: "test-password", + DomainName: "test-domain", + Region: "test-region", + ServerPrefix: "test-vm-name", + ImageID: "test-image-id", + FlavorID: "test-flavor-id", + NetworkIDs: []string{"net-1"}, + SecurityGroups: []string{"sg-1"}, + FloatingIpNetworkID: "floating-net", + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + openstackcfg = tt.input + + testManager := &Manager{} + provider, err := testManager.NewProvider() + if tt.wantErr { + if err == nil { + t.Errorf("Expected error for test case %v, but got none", tt.name) + } else { + t.Logf("Expected error occurred for test case %v: %v", tt.name, err) + } + if provider != nil { + t.Errorf("Expected provider to be nil for test case %v, but got: %v", tt.name, provider) + } + } else { + if err != nil { + t.Errorf("Expected no error for test case %v, but got: %v", tt.name, err) + } + if provider != nil { + t.Logf("Provider successfully created: %v", tt.name) + } else { + t.Errorf("Expected provider to be created for test case %v, but got nil", tt.name) + } + } + openstackcfg = Config{} + }) + } +} + +func TestGetConfig(t *testing.T) { + + flags := flag.NewFlagSet("test", flag.ContinueOnError) + testManager := &Manager{} + + args := []string{ + "-server-prefix=test-vm-name", + "-imageID=test-image-id", + "-flavorID=test-flavor-id", + "-networkID=net-1", + "-security-group=sg-1", + "-floating-ip-networkID=floating-net", + } + + os.Setenv("OPENSTACK_USERNAME", "test-user") + os.Setenv("OPENSTACK_PASSWORD", "test-password") + os.Setenv("OPENSTACK_REGION", "test-region") + os.Setenv("OPENSTACK_TENANT_NAME", "test-tenant") + os.Setenv("OPENSTACK_DOMAIN_NAME", "test-domain") + os.Setenv("OPENSTACK_IDENTITY_ENDPOINT", "https://identity.testopenstack/v3") + + testManager.ParseCmd(flags) + err := flags.Parse(args) + if err != nil { + t.Fatalf("Failed to parse flags: %v", err) + } + + expectedConfig := Config{ + ServerPrefix: "test-vm-name", + ImageID: "test-image-id", + FlavorID: "test-flavor-id", + NetworkIDs: []string{"net-1"}, + SecurityGroups: []string{"sg-1"}, + FloatingIpNetworkID: "floating-net", + Username: "test-user", + Password: "test-password", + Region: "test-region", + TenantName: "test-tenant", + DomainName: "test-domain", + IdentityEndpoint: "https://identity.testopenstack/v3", + } + + testManager.LoadEnv() + testManager.GetConfig() + + if !comparestructs(openstackcfg, expectedConfig) { + t.Errorf("After LoadEnv: Expected config: %+v, but got: %+v", expectedConfig, openstackcfg) + } else { + t.Logf("Expected config: %+v, got config: %+v\n", expectedConfig, openstackcfg) + } + + os.Unsetenv("OPENSTACK_USERNAME") + os.Unsetenv("OPENSTACK_PASSWORD") + os.Unsetenv("OPENSTACK_REGION") + os.Unsetenv("OPENSTACK_TENANT_NAME") + os.Unsetenv("OPENSTACK_DOMAIN_NAME") + os.Unsetenv("OPENSTACK_IDENTITY_ENDPOINT") + openstackcfg = Config{} +} diff --git a/src/cloud-providers/openstack/openstack.go b/src/cloud-providers/openstack/openstack.go new file mode 100644 index 0000000000..c7231fcaa8 --- /dev/null +++ b/src/cloud-providers/openstack/openstack.go @@ -0,0 +1,180 @@ +// (C) Copyright Confidential Containers Contributors +// SPDX-License-Identifier: Apache-2.0 + +package openstack + +import ( + "context" + "fmt" + "log" + "net/netip" + "time" + + "github.com/gophercloud/gophercloud/v2" + "github.com/gophercloud/gophercloud/v2/openstack" + "github.com/gophercloud/gophercloud/v2/openstack/compute/v2/attachinterfaces" + "github.com/gophercloud/gophercloud/v2/openstack/compute/v2/servers" + "github.com/gophercloud/gophercloud/v2/openstack/networking/v2/extensions/layer3/floatingips" +) + +const ( + maxRetriesGetStatus = 60 // Maximum retries to get server status + intervalGetStatus = 3 // Interval (in seconds) between each retry to get server status +) + +// NewProviderClient creates a new OpenStack provider client with authentication +func NewProviderClient(openstackcfg Config) (*gophercloud.ProviderClient, error) { + authOpts := gophercloud.AuthOptions{ + IdentityEndpoint: openstackcfg.IdentityEndpoint, + Username: openstackcfg.Username, + Password: openstackcfg.Password, + TenantName: openstackcfg.TenantName, + DomainName: openstackcfg.DomainName, + AllowReauth: true, // Allow re-authentication + } + + client, err := openstack.AuthenticatedClient(context.Background(), authOpts) + if err != nil { + return nil, fmt.Errorf("failed to authenticate with OpenStack: %w", err) + } + + return client, nil +} + +// NewComputeClient creates a new OpenStack Compute service client +func NewComputeClient(providerClient *gophercloud.ProviderClient, endpointOpts gophercloud.EndpointOpts) (*gophercloud.ServiceClient, error) { + client, err := openstack.NewComputeV2(providerClient, endpointOpts) + if err != nil { + return nil, fmt.Errorf("failed to create OpenStack Compute client: %w", err) + } + return client, nil +} + +// NewNetworkClient creates a new OpenStack Network service client +func NewNetworkClient(providerClient *gophercloud.ProviderClient, endpointOpts gophercloud.EndpointOpts) (*gophercloud.ServiceClient, error) { + client, err := openstack.NewNetworkV2(providerClient, endpointOpts) + if err != nil { + return nil, fmt.Errorf("failed to create OpenStack network client: %w", err) + } + return client, nil +} + +// MakeNetworkList converts a list of network IDs into a list of OpenStack Network structs +func MakeNetworkList(networkIDs []string) []servers.Network { + networks := make([]servers.Network, 0, len(networkIDs)) + for _, id := range networkIDs { + networks = append(networks, servers.Network{UUID: id}) + } + return networks +} + +// extractIPsFromAddresses parses a map of OpenStack server addresses and returns a slice of netip.Addr IPs. +func extractFixedIPsFromAddresses(addresses map[string]any) ([]netip.Addr, error) { + var ips []netip.Addr + var ip6s []netip.Addr + + for _, addrList := range addresses { + list, ok := addrList.([]any) + if !ok { + return nil, fmt.Errorf("unexpected type for addrList: got %T, want []any", addrList) + } + + for _, addrItem := range list { + addrMap, ok := addrItem.(map[string]any) + if !ok { + return nil, fmt.Errorf("unexpected type for addrItem: got %T, want map[string]any", addrItem) + } + addrVal, ok := addrMap["addr"] + if !ok { + return nil, fmt.Errorf("missing 'addr' key in address item: %v", addrMap) + } + if addrVal == nil { + return nil, fmt.Errorf("'addr' value is nil in address item: %v", addrMap) + } + addrStr, ok := addrVal.(string) + if !ok { + return nil, fmt.Errorf("unexpected type for 'addr' value: got %T, want string in %v", addrVal, addrMap) + } + ip, err := netip.ParseAddr(addrStr) + if err != nil { + return nil, fmt.Errorf("failed to parse IP address %s: %w", addrStr, err) + } + if ip.Is4() { + ips = append(ips, ip) + } else { + ip6s = append(ip6s, ip) + } + } + } + return append(ips, ip6s...), nil +} + +// Request the server status and get fixed IPs in the response. +func GetFixedIPs(ctx context.Context, computeClient *gophercloud.ServiceClient, serverID string) ([]netip.Addr, error) { + var ips []netip.Addr + for retries := 0; retries < maxRetriesGetStatus; retries++ { + time.Sleep(intervalGetStatus * time.Second) + server, err := servers.Get(ctx, computeClient, serverID).Extract() + if err != nil { + return nil, fmt.Errorf("failed to get server status: %w", err) + } + + if server.Status != "ACTIVE" { + continue + } + ips, err = extractFixedIPsFromAddresses(server.Addresses) + if err != nil { + return nil, err + } + if len(ips) != 0 { + return ips, nil + } + } + return nil, fmt.Errorf("failed to get fixed IPs after %d retries", maxRetriesGetStatus) +} + +// AssignFloatingIP assigns a floating IP from the specified floating network to the given fixed IP. +func AssignFloatingIP(ctx context.Context, networkClient *gophercloud.ServiceClient, portID string, floatingNetworkID string) (netip.Addr, string, error) { + res, err := floatingips.Create(ctx, networkClient, floatingips.CreateOpts{ + FloatingNetworkID: floatingNetworkID, + PortID: portID, + }).Extract() + if err != nil { + return netip.Addr{}, "", fmt.Errorf("failed to create floating IP: %w", err) + } + fip, err := netip.ParseAddr(res.FloatingIP) + if err != nil { + return netip.Addr{}, "", fmt.Errorf("invalid floating IP address received: %+v", res) + } + + return fip, res.ID, nil +} + +// GetPortID retrieves the port ID associated with the given server ID and fixed IP. +func GetPortID(computeClient *gophercloud.ServiceClient, serverID string, fixedIP string) string { + allPages, err := attachinterfaces.List(computeClient, serverID).AllPages(context.TODO()) + if err != nil { + log.Printf("failed to list attached interfaces for server %s: %v", serverID, err) + return "" + } + allInterfaces, err := attachinterfaces.ExtractInterfaces(allPages) + if err != nil { + log.Printf("failed to extract interfaces for server %s: %v", serverID, err) + return "" + } + + for _, eachInterface := range allInterfaces { + for _, fixedIPs := range eachInterface.FixedIPs { + if fixedIPs.IPAddress == fixedIP { + return eachInterface.PortID + } + } + } + log.Printf("failed to locate a network interface associated with the fixedIP: %v", fixedIP) + return "" +} + +// DeleteFloatingIP deletes the floating IP with the specified ID. +func DeleteFloatingIP(ctx context.Context, networkClient *gophercloud.ServiceClient, floatingIPID string) error { + return floatingips.Delete(ctx, networkClient, floatingIPID).ExtractErr() +} diff --git a/src/cloud-providers/openstack/openstack_test.go b/src/cloud-providers/openstack/openstack_test.go new file mode 100644 index 0000000000..69e80fecaa --- /dev/null +++ b/src/cloud-providers/openstack/openstack_test.go @@ -0,0 +1,1472 @@ +// (C) Copyright Confidential Containers Contributors +// SPDX-License-Identifier: Apache-2.0 + +package openstack + +import ( + "context" + "log" + "net/http" + "net/http/httptest" + "reflect" + "sync/atomic" + "testing" + + "github.com/gophercloud/gophercloud/v2" + "github.com/gophercloud/gophercloud/v2/openstack/compute/v2/servers" +) + +func TestNewProviderClient(t *testing.T) { + server := CreateServer() + + tests := []struct { + name string + cfg Config + wantError bool + }{ + { + name: "ValidConfig", + cfg: Config{ + IdentityEndpoint: server.URL + "/v3", + Username: "test-user", + Password: "test-password", + TenantName: "test-tenant", + DomainName: "test-domain", + }, + wantError: false, + }, + { + name: "InvalidEndpointConfig", + cfg: Config{ + IdentityEndpoint: "http://bad-address.example.com/v3", + Username: "test-user", + Password: "test-password", + TenantName: "test-tenant", + DomainName: "test-domain", + }, + wantError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + providerClient, err := NewProviderClient(tt.cfg) + if tt.wantError { + if err == nil { + t.Errorf("Expected error for test case %v, but got none\n", tt.name) + } else { + t.Logf("Expected error occurred for test case %v: %v\n", tt.name, err) + } + if providerClient != nil { + t.Errorf("Expected providerClient to be nil for test case %v, but got: %+v\n", tt.name, providerClient) + } + } else { + if err != nil { + t.Errorf("Expected no error for test case %v, but got: %v\n", tt.name, err) + } + if providerClient != nil { + t.Logf("ProviderClient successfully created for test case %v: %+v\n", tt.name, providerClient) + } else { + t.Errorf("Expected providerClient to be created for test case %v, but got nil\n", tt.name) + } + } + }) + } +} + +func TestNewComputeClient(t *testing.T) { + server := CreateServer() + serverNoCompute := CreateServerNoCompute() + + tests := []struct { + name string + cfg Config + endpointOpts gophercloud.EndpointOpts + server *httptest.Server + wantError bool + }{ + { + name: "ValidEndpointOptsAndProviderClient", + cfg: Config{ + IdentityEndpoint: server.URL + "/v3", + Username: "test-user", + Password: "test-password", + TenantName: "test-tenant", + DomainName: "test-domain", + }, + endpointOpts: gophercloud.EndpointOpts{ + Region: "test-region", + }, + server: server, + wantError: false, + }, + { + name: "InvalidProviderClient", + cfg: Config{ + IdentityEndpoint: serverNoCompute.URL + "/v3", + Username: "test-user", + Password: "test-password", + TenantName: "test-tenant", + DomainName: "test-domain", + }, + endpointOpts: gophercloud.EndpointOpts{ + Region: "test-region", + }, + server: serverNoCompute, + wantError: true, + }, + { + name: "InvalidEndpointOpts", + cfg: Config{ + IdentityEndpoint: server.URL + "/v3", + Username: "test-user", + Password: "test-password", + TenantName: "test-tenant", + DomainName: "test-domain", + }, + endpointOpts: gophercloud.EndpointOpts{ + Region: "bad-region", + }, + server: server, + wantError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + providerClient, err := NewProviderClient(tt.cfg) + if err != nil { + if tt.wantError { + t.Logf("Expected error occurred in provider client creation for %v: %v", tt.name, err) + return + } + t.Fatalf("Unexpected error in provider client creation for %v: %v", tt.name, err) + } else { + t.Logf("Using provider client's pointer: %p", providerClient) + } + + computeClient, err := NewComputeClient(providerClient, tt.endpointOpts) + if tt.wantError { + if err == nil { + t.Errorf("Expected error for test case %v, but got none", tt.name) + } else { + t.Logf("Expected error occurred for test case %v: %v", tt.name, err) + } + if computeClient != nil { + t.Errorf("Expected computeClient to be nil for test case %v, but got: %+v", tt.name, computeClient) + } + } else { + if err != nil { + t.Errorf("Expected no error for test case %v, but got: %v", tt.name, err) + } + if computeClient != nil { + t.Logf("ComputeClient successfully created for test case %v: %+v", tt.name, computeClient) + } else { + t.Errorf("Expected computeClient to be created for test case %v, but got nil", tt.name) + } + } + }) + } +} + +func TestNewNetworkClient(t *testing.T) { + server := CreateServer() + serverNoNetwork := CreateServerNoNetwork() + + tests := []struct { + name string + cfg Config + endpointOpts gophercloud.EndpointOpts + server *httptest.Server + wantError bool + }{ + { + name: "ValidEndpointOptsAndProviderClient", + cfg: Config{ + IdentityEndpoint: server.URL + "/v3", + Username: "test-user", + Password: "test-password", + TenantName: "test-tenant", + DomainName: "test-domain", + }, + endpointOpts: gophercloud.EndpointOpts{ + Region: "test-region", + }, + server: server, + wantError: false, + }, + { + name: "InvalidProviderClient", + cfg: Config{ + IdentityEndpoint: serverNoNetwork.URL + "/v3", + Username: "test-user", + Password: "test-password", + TenantName: "test-tenant", + DomainName: "test-domain", + }, + endpointOpts: gophercloud.EndpointOpts{ + Region: "test-region", + }, + server: serverNoNetwork, + wantError: true, + }, + { + name: "InvalidEndpointOpts", + cfg: Config{ + IdentityEndpoint: server.URL + "/v3", + Username: "test-user", + Password: "test-password", + TenantName: "test-tenant", + DomainName: "test-domain", + }, + endpointOpts: gophercloud.EndpointOpts{ + Region: "bad-region", + }, + server: server, + wantError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + providerClient, err := NewProviderClient(tt.cfg) + if err != nil { + if tt.wantError { + t.Logf("Expected error occurred in provider client creation for %v: %v", tt.name, err) + return + } + t.Fatalf("Unexpected error in provider client creation for %v: %v", tt.name, err) + } else { + t.Logf("Using provider client's pointer: %p", providerClient) + } + + networkClient, err := NewNetworkClient(providerClient, tt.endpointOpts) + if tt.wantError { + if err == nil { + t.Errorf("Expected error for test case %v, but got none", tt.name) + } else { + t.Logf("Expected error occurred for test case %v: %v", tt.name, err) + } + if networkClient != nil { + t.Errorf("Expected networkClient to be nil for test case %v, but got: %+v", tt.name, networkClient) + } + } else { + if err != nil { + t.Errorf("Expected no error for test case %v, but got: %v", tt.name, err) + } + if networkClient != nil { + t.Logf("NetworkClient successfully created for test case %v: %+v", tt.name, networkClient) + } else { + t.Errorf("Expected networkClient to be created for test case %v, but got nil", tt.name) + } + } + }) + } +} + +func TestMakeNetworkList(t *testing.T) { + tests := []struct { + name string + input []string + expect []servers.Network + }{ + { + name: "SingleNetwork", + input: []string{"net-1"}, + expect: []servers.Network{{UUID: "net-1"}}, + }, + { + name: "NoNetwork", + input: []string{}, + expect: []servers.Network{}, + }, + { + name: "MultipleNetworks", + input: []string{"net-1", "net-2", "net-3"}, + expect: []servers.Network{{UUID: "net-1"}, {UUID: "net-2"}, {UUID: "net-3"}}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + + networks := MakeNetworkList(tt.input) + if !reflect.DeepEqual(tt.expect, networks) { + t.Errorf("Expected networks: %v, but got: %v", tt.expect, networks) + } else { + t.Logf("Expected networks: %+v, got networks: %+v", tt.expect, networks) + } + }) + } +} + +func TestExtractFixedIPsFromAddresses(t *testing.T) { + tests := []struct { + name string + input map[string]any + expected []string + wantErr bool + }{ + { + name: "EmptyAddresses", + input: map[string]any{}, + expected: []string{}, + wantErr: false, + }, + { + name: "SingleAddress", + input: map[string]any{ + "address1": []any{ + map[string]any{"addr": "11.22.33.44"}, + }, + }, + expected: []string{"11.22.33.44"}, + wantErr: false, + }, + { + name: "MultipleAddresses", + input: map[string]any{ + "address1": []any{ + map[string]any{"addr": "11.22.33.44"}, + }, + "address2": []any{ + map[string]any{"addr": "55.66.77.88"}, + }, + "address3": []any{ + map[string]any{"addr": "12.34.56.78"}, + }, + }, + expected: []string{"11.22.33.44", "55.66.77.88", "12.34.56.78"}, + wantErr: false, + }, + { + name: "InvalidAddressFormat", + input: map[string]any{ + "address1": []any{ + map[string]any{"addr": "xxxxxxxxxxx"}, + }, + }, + expected: nil, + wantErr: true, + }, + { + name: "MissingAddrKey", + input: map[string]any{ + "address1": []any{ + map[string]any{"ng": "11.22.33.44"}, + }, + }, + expected: nil, + wantErr: true, + }, + { + name: "OnlyIPv6", + input: map[string]any{ + "address1": []any{ + map[string]any{"addr": "2001:db8::1"}, + map[string]any{"addr": "2001:db8::2"}, + map[string]any{"addr": "2001:db8::3"}, + }, + }, + expected: []string{"2001:db8::1", "2001:db8::2", "2001:db8::3"}, + wantErr: false, + }, + { + name: "MixedIPv4AndIPv6", + input: map[string]any{ + "address1": []any{ + map[string]any{"addr": "12.34.56.78"}, + map[string]any{"addr": "2001:db8::1"}, + }, + }, + expected: []string{"12.34.56.78", "2001:db8::1"}, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ips, err := extractFixedIPsFromAddresses(tt.input) + t.Log("Extracted IPs:", ips) + if tt.wantErr { + if err == nil { + t.Errorf("Expected error for test case %v, but got none", tt.name) + } else { + t.Logf("Expected error occurred for test case %v: %v", tt.name, err) + } + } else { + if err != nil { + t.Errorf("Expected no error for test case %v, but got: %v", tt.name, err) + } else if tt.name == "MixedIPv4AndIPv6" { + actualIPs := make([]string, len(ips)) + for i, ip := range ips { + actualIPs[i] = ip.String() + } + + if !reflect.DeepEqual(tt.expected, actualIPs) { + t.Errorf("Expected IPs: %v, but got: %v for test case %v", tt.expected, actualIPs, tt.name) + } else { + t.Logf("Extracted IPs match expected for test case %v: %+v", tt.name, actualIPs) + } + } + } + }) + } +} + +func TestGetFixedIPs(t *testing.T) { + tests := []struct { + name string + cfg Config + handler http.HandlerFunc + wantError bool + }{ + { + name: "InvalidComputeClient", + cfg: Config{ + Username: "test-user", + Password: "test-password", + TenantName: "test-tenant", + DomainName: "test-domain", + }, + handler: HandlerFuncNoCompute, + wantError: true, + }, + { + name: "InvalidServerID", + cfg: Config{ + Username: "test-user", + Password: "test-password", + TenantName: "test-tenant", + DomainName: "test-domain", + }, + handler: HandlerFuncInvalidServerID, + wantError: true, + }, + { + name: "ExceedsMaxRetries", + cfg: Config{ + Username: "test-user", + Password: "test-password", + TenantName: "test-tenant", + DomainName: "test-domain", + }, + handler: HandlerFuncExceedsMaxRetries, + wantError: true, + }, + { + name: "InvalidServerAddressReceived", + cfg: Config{ + Username: "test-user", + Password: "test-password", + TenantName: "test-tenant", + DomainName: "test-domain", + }, + handler: HandlerFuncInvalidServerAddress, + wantError: true, + }, + { + name: "RetriesAndSucceeds", + cfg: Config{ + Username: "test-user", + Password: "test-password", + TenantName: "test-tenant", + DomainName: "test-domain", + }, + handler: HandlerFuncRetriesAndSucceeds, + wantError: false, + }, + { + name: "SucceedsWithoutRetries", + cfg: Config{ + Username: "test-user", + Password: "test-password", + TenantName: "test-tenant", + DomainName: "test-domain", + }, + handler: HandlerFuncSucceedsWithoutRetries, + wantError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.name == "RetriesAndSucceeds" { + atomic.StoreInt32(&requestCount, 0) + } + + var computeClient *gophercloud.ServiceClient + + if tt.name == "InvalidComputeClient" { + computeClient = &gophercloud.ServiceClient{ + ProviderClient: &gophercloud.ProviderClient{}, + Endpoint: "http://bad-endpoint/", + } + } else { + mux := http.NewServeMux() + + mux.HandleFunc("/v3/auth/tokens", HandlerFuncV3) + mux.HandleFunc("/v2.0/tokens", HandlerFuncV2) + + mux.HandleFunc("/v2.0/test-tenant/servers/test-server-id", tt.handler) + + server := httptest.NewServer(mux) + defer server.Close() + + cfg := tt.cfg + cfg.IdentityEndpoint = server.URL + "/v3" + + providerClient, err := NewProviderClient(cfg) + if err != nil { + t.Fatalf("Failed to create provider client: %v", err) + } + + computeClient, err = NewComputeClient(providerClient, gophercloud.EndpointOpts{ + Region: "test-region", + }) + if err != nil { + t.Fatalf("Failed to create compute client: %v", err) + } + } + + ips, err := GetFixedIPs(context.Background(), computeClient, "test-server-id") + + if tt.wantError { + if err == nil { + t.Errorf("Expected error for test case %v, but got none", tt.name) + } else { + t.Logf("Expected error occurred for test case %v: %v", tt.name, err) + } + if len(ips) > 0 { + t.Errorf("Expected IPs to be nil or empty when error occurs for test case %v, but got: %v", tt.name, ips) + } + } else { + if err != nil { + t.Errorf("Expected no error for test case %v, but got: %v", tt.name, err) + } else { + t.Logf("Successfully retrieved IPs for test case %v: %v", tt.name, ips) + } + if len(ips) == 0 { + t.Errorf("Expected IPs to have values when no error occurs for test case %v, but got nil or empty: %v", tt.name, ips) + } + } + }) + } +} + +func TestAssignFloatingIP(t *testing.T) { + tests := []struct { + name string + cfg Config + handler http.HandlerFunc + wantError bool + }{ + { + name: "InvalidNetworkClient", + cfg: Config{ + Username: "test-user", + Password: "test-password", + TenantName: "test-tenant", + DomainName: "test-domain", + }, + handler: HandlerFuncInvalidNetworkClient, + wantError: true, + }, + { + name: "InvalidFloatingNetworkID", + cfg: Config{ + Username: "test-user", + Password: "test-password", + TenantName: "test-tenant", + DomainName: "test-domain", + }, + handler: HandlerFuncInvalidFloatingNetworkID, + wantError: true, + }, + { + name: "InvalidPortID", + cfg: Config{ + Username: "test-user", + Password: "test-password", + TenantName: "test-tenant", + DomainName: "test-domain", + }, + handler: HandlerFuncInvalidPortID, + wantError: true, + }, + { + name: "InvalidResponse", + cfg: Config{ + Username: "test-user", + Password: "test-password", + TenantName: "test-tenant", + DomainName: "test-domain", + }, + handler: HandlerFuncInvalidResponse, + wantError: true, + }, + { + name: "Success", + cfg: Config{ + Username: "test-user", + Password: "test-password", + TenantName: "test-tenant", + DomainName: "test-domain", + }, + handler: HandlerFuncAssignFloatingIPSuccess, + wantError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var networkClient *gophercloud.ServiceClient + + if tt.name == "InvalidNetworkClient" { + + networkClient = &gophercloud.ServiceClient{ + ProviderClient: &gophercloud.ProviderClient{}, + Endpoint: "http://bad-endpoint/", + } + } else { + mux := http.NewServeMux() + + mux.HandleFunc("/v3/auth/tokens", HandlerFuncV3) + mux.HandleFunc("/v2.0/tokens", HandlerFuncV2) + + mux.HandleFunc("/v2.0/v2.0/floatingips", tt.handler) + + server := httptest.NewServer(mux) + defer server.Close() + + cfg := tt.cfg + cfg.IdentityEndpoint = server.URL + "/v3" + + providerClient, err := NewProviderClient(cfg) + if err != nil { + t.Fatalf("Failed to create provider client: %v", err) + } + + networkClient, err = NewNetworkClient(providerClient, gophercloud.EndpointOpts{ + Region: "test-region", + }) + if err != nil { + t.Fatalf("Failed to create network client: %v", err) + } + } + + portID := "test-port-id" + floatingNetworkID := "test-floating-network-id" + + floatingIP, floatingIPID, err := AssignFloatingIP(context.Background(), networkClient, portID, floatingNetworkID) + + if tt.wantError { + if err == nil { + t.Errorf("Expected error for test case %v, but got none", tt.name) + } else { + t.Logf("Expected error occurred for test case %v: %v", tt.name, err) + } + if floatingIP.IsValid() { + t.Errorf("Expected floatingIP to be empty when error occurs for test case %v, but got: %v", tt.name, floatingIP) + } + if floatingIPID != "" { + t.Errorf("Expected floatingIPID to be empty when error occurs for test case %v, but got: %v", tt.name, floatingIPID) + } + } else { + if err != nil { + t.Errorf("Expected no error for test case %v, but got: %v", tt.name, err) + } else { + t.Logf("Successfully assigned floating IP for test case %v: IP=%v, ID=%v", tt.name, floatingIP, floatingIPID) + } + if !floatingIP.IsValid() { + t.Errorf("Expected floatingIP to have valid value when no error occurs for test case %v, but got invalid: %v", tt.name, floatingIP) + } + if floatingIPID == "" { + t.Errorf("Expected floatingIPID to have value when no error occurs for test case %v, but got empty: %v", tt.name, floatingIPID) + } + } + }) + } +} + +func TestGetPortID(t *testing.T) { + tests := []struct { + name string + cfg Config + handler http.HandlerFunc + wantError bool + }{ + { + name: "InvalidComputeClient", + cfg: Config{ + Username: "test-user", + Password: "test-password", + TenantName: "test-tenant", + DomainName: "test-domain", + }, + handler: HandlerFuncGetPortIDInvalidComputeClient, + wantError: true, + }, + { + name: "InvalidServerID", + cfg: Config{ + Username: "test-user", + Password: "test-password", + TenantName: "test-tenant", + DomainName: "test-domain", + }, + handler: HandlerFuncGetPortIDInvalidServerID, + wantError: true, + }, + { + name: "InvalidFixedIP", + cfg: Config{ + Username: "test-user", + Password: "test-password", + TenantName: "test-tenant", + DomainName: "test-domain", + }, + handler: HandlerFuncGetPortIDInvalidFixedIP, + wantError: true, + }, + { + name: "FixedIPPortIDNotLinked", + cfg: Config{ + Username: "test-user", + Password: "test-password", + TenantName: "test-tenant", + DomainName: "test-domain", + }, + handler: HandlerFuncGetPortIDNotLinked, + wantError: true, + }, + { + name: "Success", + cfg: Config{ + Username: "test-user", + Password: "test-password", + TenantName: "test-tenant", + DomainName: "test-domain", + }, + handler: HandlerFuncGetPortIDSuccess, + wantError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var computeClient *gophercloud.ServiceClient + + if tt.name == "InvalidComputeClient" { + computeClient = &gophercloud.ServiceClient{ + ProviderClient: &gophercloud.ProviderClient{}, + Endpoint: "http://bad-endpoint/", + } + } else { + mux := http.NewServeMux() + + mux.HandleFunc("/v3/auth/tokens", HandlerFuncV3) + mux.HandleFunc("/v2.0/tokens", HandlerFuncV2) + + mux.HandleFunc("/v2.0/test-tenant/servers/test-server-id/os-interface", tt.handler) + + server := httptest.NewServer(mux) + defer server.Close() + + cfg := tt.cfg + cfg.IdentityEndpoint = server.URL + "/v3" + + providerClient, err := NewProviderClient(cfg) + if err != nil { + t.Fatalf("Failed to create provider client: %v", err) + } + + computeClient, err = NewComputeClient(providerClient, gophercloud.EndpointOpts{ + Region: "test-region", + }) + if err != nil { + t.Fatalf("Failed to create compute client: %v", err) + } + } + + serverID := "test-server-id" + fixedIP := "192.168.1.100" + + portID := GetPortID(computeClient, serverID, fixedIP) + + if tt.wantError { + if portID != "" { + t.Errorf("Expected empty port ID for test case %v, but got: %v", tt.name, portID) + } else { + t.Logf("Expected empty port ID returned for test case %v", tt.name) + } + } else { + if portID == "" { + t.Errorf("Expected non-empty port ID for test case %v, but got empty string", tt.name) + } else { + t.Logf("Successfully retrieved port ID for test case %v: %v", tt.name, portID) + } + } + }) + } +} + +func TestDeleteFloatingIP(t *testing.T) { + tests := []struct { + name string + cfg Config + handler http.HandlerFunc + wantError bool + }{ + { + name: "InvalidNetworkClient", + cfg: Config{ + Username: "test-user", + Password: "test-password", + TenantName: "test-tenant", + DomainName: "test-domain", + }, + handler: HandlerFuncDeleteFloatingIPInvalidNetworkClient, + wantError: true, + }, + { + name: "InvalidFloatingIPID", + cfg: Config{ + Username: "test-user", + Password: "test-password", + TenantName: "test-tenant", + DomainName: "test-domain", + }, + handler: HandlerFuncDeleteFloatingIPInvalidID, + wantError: true, + }, + { + name: "Success", + cfg: Config{ + Username: "test-user", + Password: "test-password", + TenantName: "test-tenant", + DomainName: "test-domain", + }, + handler: HandlerFuncDeleteFloatingIPSuccess, + wantError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var networkClient *gophercloud.ServiceClient + + if tt.name == "InvalidNetworkClient" { + networkClient = &gophercloud.ServiceClient{ + ProviderClient: &gophercloud.ProviderClient{}, + Endpoint: "http://bad-endpoint/", + } + } else { + mux := http.NewServeMux() + + mux.HandleFunc("/v3/auth/tokens", HandlerFuncV3) + mux.HandleFunc("/v2.0/tokens", HandlerFuncV2) + + mux.HandleFunc("/v2.0/v2.0/floatingips/test-floating-ip-id", tt.handler) + + server := httptest.NewServer(mux) + defer server.Close() + + cfg := tt.cfg + cfg.IdentityEndpoint = server.URL + "/v3" + + providerClient, err := NewProviderClient(cfg) + if err != nil { + t.Fatalf("Failed to create provider client: %v", err) + } + + networkClient, err = NewNetworkClient(providerClient, gophercloud.EndpointOpts{ + Region: "test-region", + }) + if err != nil { + t.Fatalf("Failed to create network client: %v", err) + } + } + + floatingIPID := "test-floating-ip-id" + + err := DeleteFloatingIP(context.Background(), networkClient, floatingIPID) + + if tt.wantError { + if err == nil { + t.Errorf("Expected error for test case %v, but got none", tt.name) + } else { + t.Logf("Expected error occurred for test case %v: %v", tt.name, err) + } + } else { + if err != nil { + t.Errorf("Expected no error for test case %v, but got: %v", tt.name, err) + } else { + t.Logf("Successfully deleted floating IP for test case %v (no error as expected)", tt.name) + } + } + }) + } +} + +func CreateServer() *httptest.Server { + mux := http.NewServeMux() + + mux.HandleFunc("/v3/auth/tokens", HandlerFuncV3) + mux.HandleFunc("/v2.0/tokens", HandlerFuncV2) + + return httptest.NewServer(mux) +} + +func CreateServerNoCompute() *httptest.Server { + mux := http.NewServeMux() + + mux.HandleFunc("/v3/auth/tokens", HandlerFuncV3NoCompute) + mux.HandleFunc("/v2.0/tokens", HandlerFuncV2NoCompute) + + return httptest.NewServer(mux) +} + +func CreateServerNoNetwork() *httptest.Server { + mux := http.NewServeMux() + + mux.HandleFunc("/v3/auth/tokens", HandlerFuncV3NoNetwork) + mux.HandleFunc("/v2.0/tokens", HandlerFuncV2NoNetwork) + + return httptest.NewServer(mux) +} + +func HandlerFuncV3(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusCreated) + _, err := w.Write([]byte(`{ + "token": { + "id": "01234567890", + "expires": "2014-10-01T10:00:00.000000Z", + "catalog": [ + { + "id": "a1b2c3d4-e5f6-7890-1234-567890abcdef", + "name": "nova", + "type": "compute", + "endpoints": [ + { + "id": "1a2b3c4d-5e6f-7890-1234-567890abcdef", + "interface": "public", + "region": "test-region", + "url": "http://` + r.Host + `/v2.0/test-tenant/" + } + ] + }, + { + "id": "b2c3d4e5-f6a7-8901-2345-67890abcdef0", + "name": "neutron", + "type": "network", + "endpoints": [ + { + "id": "2b3c4d5e-6f7a-8901-2345-67890abcdef0", + "interface": "public", + "region": "test-region", + "url": "http://` + r.Host + `/v2.0/" + } + ] + } + ] + } + }`)) + if err != nil { + log.Printf("Warning: Failed to write response: %v", err) + } +} + +func HandlerFuncV2(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, err := w.Write([]byte(`{ + "access": { + "token": { + "id": "01234567890", + "expires": "2014-10-01T10:00:00.000000Z" + }, + "serviceCatalog": [ + { + "name": "Cloud Servers", + "type": "compute", + "endpoints": [ + { + "tenantId": "test-tenant", + "publicURL": "http://` + r.Host + `/v2.0/test-tenant/", + "region": "test-region" + } + ], + "endpoints_links": [] + }, + { + "name": "Neutron", + "type": "network", + "endpoints": [ + { + "tenantId": "test-tenant", + "publicURL": "http://` + r.Host + `/v2.0/", + "region": "test-region" + } + ], + "endpoints_links": [] + } + ] + } + }`)) + if err != nil { + log.Printf("Warning: Failed to write response: %v", err) + } +} + +func HandlerFuncV2NoCompute(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, err := w.Write([]byte(`{ + "access": { + "token": { + "id": "01234567890", + "expires": "2014-10-01T10:00:00.000000Z" + }, + "serviceCatalog": [ + { + "name": "Neutron", + "type": "network", + "endpoints": [ + { + "tenantId": "test-tenant", + "publicURL": "http://` + r.Host + `/v2.0/", + "region": "test-region" + } + ], + "endpoints_links": [] + } + ] + } + }`)) + if err != nil { + log.Printf("Warning: Failed to write response: %v", err) + } +} + +func HandlerFuncV3NoCompute(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusCreated) + _, err := w.Write([]byte(`{ + "token": { + "id": "01234567890", + "expires": "2014-10-01T10:00:00.000000Z", + "catalog": [ + { + "id": "b2c3d4e5-f6a7-8901-2345-67890abcdef0", + "name": "neutron", + "type": "network", + "endpoints": [ + { + "id": "2b3c4d5e-6f7a-8901-2345-67890abcdef0", + "interface": "public", + "region": "test-region", + "url": "http://` + r.Host + `/v2.0/" + } + ] + } + ] + } + }`)) + if err != nil { + log.Printf("Warning: Failed to write response: %v", err) + } +} + +func HandlerFuncV2NoNetwork(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, err := w.Write([]byte(`{ + "access": { + "token": { + "id": "01234567890", + "expires": "2014-10-01T10:00:00.000000Z" + }, + "serviceCatalog": [ + { + "name": "Cloud Servers", + "type": "compute", + "endpoints": [ + { + "tenantId": "test-tenant", + "publicURL": "http://` + r.Host + `/v2.0/test-tenant/", + "region": "test-region" + } + ], + "endpoints_links": [] + } + ] + } + }`)) + if err != nil { + log.Printf("Warning: Failed to write response: %v", err) + } +} + +func HandlerFuncV3NoNetwork(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusCreated) + _, err := w.Write([]byte(`{ + "token": { + "id": "01234567890", + "expires": "2014-10-01T10:00:00.000000Z", + "catalog": [ + { + "id": "a1b2c3d4-e5f6-7890-1234-567890abcdef", + "name": "nova", + "type": "compute", + "endpoints": [ + { + "id": "1a2b3c4d-5e6f-7890-1234-567890abcdef", + "interface": "public", + "region": "test-region", + "url": "http://` + r.Host + `/v2.0/test-tenant/" + } + ] + } + ] + } + }`)) + if err != nil { + log.Printf("Warning: Failed to write response: %v", err) + } +} + +func HandlerFuncNoCompute(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusServiceUnavailable) + _, err := w.Write([]byte(`{ + "error": { + "message": "Compute service is temporarily unavailable", + "code": 503, + "type": "ServiceUnavailable" + } + }`)) + if err != nil { + log.Printf("Warning: Failed to write response: %v", err) + } +} + +func HandlerFuncInvalidServerID(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusNotFound) + _, err := w.Write([]byte(`{ + "itemNotFound": { + "message": "Instance test-server-id could not be found.", + "code": 404 + } + }`)) + if err != nil { + log.Printf("Warning: Failed to write response: %v", err) + } +} + +func HandlerFuncExceedsMaxRetries(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, err := w.Write([]byte(`{ + "server": { + "id": "test-server-id", + "name": "test-server", + "status": "BUILD", + "addresses": {} + } + }`)) + if err != nil { + log.Printf("Warning: Failed to write response: %v", err) + } +} + +func HandlerFuncInvalidServerAddress(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, err := w.Write([]byte(`{ + "server": { + "id": "test-server-id", + "name": "test-server", + "status": "ACTIVE", + "addresses": { + "private": [ + { + "addr": "xxxxxxxxxxx", + "version": 4, + "type": "fixed" + } + ] + } + } + }`)) + if err != nil { + log.Printf("Warning: Failed to write response: %v", err) + } +} + +var requestCount int32 + +func HandlerFuncRetriesAndSucceeds(w http.ResponseWriter, r *http.Request) { + retriesCount := atomic.AddInt32(&requestCount, 1) + w.Header().Set("Content-Type", "application/json") + + if retriesCount <= 3 { + log.Printf("Retry attempt %d: Server status BUILD (not ready yet)", retriesCount) + _, err := w.Write([]byte(`{ + "server": { + "id": "test-server-id", + "name": "test-server", + "status": "BUILD", + "addresses": {} + } + }`)) + if err != nil { + log.Printf("Warning: Failed to write response: %v", err) + } + } else { + log.Printf("Retry attempt %d: Server status ACTIVE (ready with IP addresses)", retriesCount) + _, err := w.Write([]byte(`{ + "server": { + "id": "test-server-id", + "name": "test-server", + "status": "ACTIVE", + "addresses": { + "private": [ + { + "addr": "172.24.4.9", + "version": 4, + "type": "fixed" + } + ] + } + } + }`)) + if err != nil { + log.Printf("Warning: Failed to write response: %v", err) + } + } +} + +func HandlerFuncSucceedsWithoutRetries(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, err := w.Write([]byte(`{ + "server": { + "id": "test-server-id", + "name": "test-server", + "status": "ACTIVE", + "addresses": { + "private": [ + { + "addr": "10.0.0.10", + "version": 4, + "type": "fixed" + } + ], + "public": [ + { + "addr": "172.24.4.10", + "version": 4, + "type": "fixed" + } + ] + } + } + }`)) + if err != nil { + log.Printf("Warning: Failed to write response: %v", err) + } +} + +func HandlerFuncInvalidNetworkClient(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnauthorized) + _, err := w.Write([]byte(`{ + "error": { + "message": "Invalid network client credentials", + "code": 401 + } + }`)) + if err != nil { + log.Printf("Warning: Failed to write response: %v", err) + } +} + +func HandlerFuncInvalidFloatingNetworkID(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusNotFound) + _, err := w.Write([]byte(`{ + "NeutronError": { + "message": "Network invalid-floating-network-id could not be found", + "type": "NetworkNotFound", + "detail": "" + } + }`)) + if err != nil { + log.Printf("Warning: Failed to write response: %v", err) + } +} + +func HandlerFuncInvalidPortID(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusNotFound) + _, err := w.Write([]byte(`{ + "NeutronError": { + "message": "Port invalid-port-id could not be found", + "type": "PortNotFound", + "detail": "" + } + }`)) + if err != nil { + log.Printf("Warning: Failed to write response: %v", err) + } +} + +func HandlerFuncInvalidResponse(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + _, err := w.Write([]byte(`{ + "badRequest": { + "message": "Invalid request format", + "code": 400 + } + }`)) + if err != nil { + log.Printf("Warning: Failed to write response: %v", err) + } +} + +func HandlerFuncAssignFloatingIPSuccess(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.Method == "POST" { + w.WriteHeader(http.StatusCreated) + _, err := w.Write([]byte(`{ + "floatingip": { + "id": "test-floating-ip-id", + "floating_ip_address": "203.0.113.100", + "port_id": "test-port-id", + "status": "ACTIVE" + } + }`)) + if err != nil { + log.Printf("Warning: Failed to write response: %v", err) + } + } else { + w.WriteHeader(http.StatusMethodNotAllowed) + } +} + +func HandlerFuncGetPortIDInvalidComputeClient(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnauthorized) + _, err := w.Write([]byte(`{ + "error": { + "message": "Invalid compute client credentials", + "code": 401 + } + }`)) + if err != nil { + log.Printf("Warning: Failed to write response: %v", err) + } +} + +func HandlerFuncGetPortIDInvalidServerID(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusNotFound) + _, err := w.Write([]byte(`{ + "itemNotFound": { + "message": "Instance invalid-server-id could not be found.", + "code": 404 + } + }`)) + if err != nil { + log.Printf("Warning: Failed to write response: %v", err) + } +} + +func HandlerFuncGetPortIDInvalidFixedIP(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + _, err := w.Write([]byte(`{ + "badRequest": { + "message": "Invalid IP address format", + "code": 400 + } + }`)) + if err != nil { + log.Printf("Warning: Failed to write response: %v", err) + } +} + +func HandlerFuncGetPortIDNotLinked(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusNotFound) + _, err := w.Write([]byte(`{ + "itemNotFound": { + "message": "No port found for the specified fixed IP address", + "code": 404 + } + }`)) + if err != nil { + log.Printf("Warning: Failed to write response: %v", err) + } +} + +func HandlerFuncGetPortIDSuccess(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, err := w.Write([]byte(`{ + "interfaceAttachments": [ + { + "port_id": "test-port-id-12345", + "fixed_ips": [ + { + "ip_address": "192.168.1.100", + "subnet_id": "test-subnet-id" + } + ], + "net_id": "test-network-id", + "port_state": "ACTIVE" + } + ] + }`)) + if err != nil { + log.Printf("Warning: Failed to write response: %v", err) + } +} + +func HandlerFuncDeleteFloatingIPInvalidNetworkClient(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnauthorized) + _, err := w.Write([]byte(`{ + "error": { + "message": "Invalid network client credentials", + "code": 401 + } + }`)) + if err != nil { + log.Printf("Warning: Failed to write response: %v", err) + } +} + +func HandlerFuncDeleteFloatingIPInvalidID(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusNotFound) + _, err := w.Write([]byte(`{ + "itemNotFound": { + "message": "Floating IP invalid-floating-ip-id could not be found", + "code": 404 + } + }`)) + if err != nil { + log.Printf("Warning: Failed to write response: %v", err) + } +} + +func HandlerFuncDeleteFloatingIPSuccess(w http.ResponseWriter, r *http.Request) { + if r.Method == "DELETE" { + w.WriteHeader(http.StatusNoContent) + } else { + w.WriteHeader(http.StatusMethodNotAllowed) + } +} diff --git a/src/cloud-providers/openstack/provider.go b/src/cloud-providers/openstack/provider.go new file mode 100644 index 0000000000..2ae9336ae5 --- /dev/null +++ b/src/cloud-providers/openstack/provider.go @@ -0,0 +1,173 @@ +// (C) Copyright Confidential Containers Contributors +// SPDX-License-Identifier: Apache-2.0 + +package openstack + +import ( + "context" + "fmt" + "log" + "net/netip" + + provider "github.com/confidential-containers/cloud-api-adaptor/src/cloud-providers" + "github.com/confidential-containers/cloud-api-adaptor/src/cloud-providers/util" + "github.com/confidential-containers/cloud-api-adaptor/src/cloud-providers/util/cloudinit" + "github.com/gophercloud/gophercloud/v2" + "github.com/gophercloud/gophercloud/v2/openstack/compute/v2/servers" +) + +// Initialize logger for OpenStack provider +var logger = log.New(log.Writer(), "[adaptor/cloud/openstack] ", log.LstdFlags|log.Lmsgprefix) + +// Maximum length for instance names +const maxInstanceNameLen = 63 + +// openstackProvider implements the Provider interface for OpenStack +type openstackProvider struct { + providerClient *gophercloud.ProviderClient + computeClient *gophercloud.ServiceClient + networkClient *gophercloud.ServiceClient + serviceConfig *Config + floatingIPPool map[string]string +} + +// NewProvider creates a new OpenStack provider. +func NewProvider(config *Config) (provider.Provider, error) { + + // Use config.Redact() to customize log output and hide sensitive information + logger.Printf("openstack config: %+v", config.Redact()) + + providerClient, err := NewProviderClient(*config) + if err != nil { + logger.Printf("unable to create openstack provider client: %v", err) + return nil, err + } + + computeClient, err := NewComputeClient(providerClient, gophercloud.EndpointOpts{ + Region: config.Region, + }) + if err != nil { + logger.Printf("unable to create openstack compute client: %v", err) + return nil, err + } + + networkClient, err := NewNetworkClient(providerClient, gophercloud.EndpointOpts{ + Region: config.Region, + }) + if err != nil { + err = fmt.Errorf("unable to create openstack network client: %v", err) + return nil, err + } + + return &openstackProvider{ + providerClient: providerClient, + computeClient: computeClient, + networkClient: networkClient, + serviceConfig: config, + floatingIPPool: make(map[string]string), + }, nil +} + +func (p *openstackProvider) CreateInstance(ctx context.Context, podname, sandboxID string, cloudConfig cloudinit.CloudConfigGenerator, spec provider.InstanceTypeSpec) (*provider.Instance, error) { + instanceName := util.GenerateInstanceName(podname, sandboxID, maxInstanceNameLen) + + cloudConfigData, err := cloudConfig.Generate() + if err != nil { + return nil, err + } + + // gophercloud-provided struct + // networks must be specified by their unique IDs + createOpts := servers.CreateOpts{ + Name: instanceName, + ImageRef: p.serviceConfig.ImageID, + FlavorRef: p.serviceConfig.FlavorID, + SecurityGroups: p.serviceConfig.SecurityGroups, + UserData: []byte(cloudConfigData), + } + // Make network UUID list. + networkList := MakeNetworkList(p.serviceConfig.NetworkIDs) + + if len(networkList) != 0 { + createOpts.Networks = networkList + } else { + createOpts.Networks = "auto" + } + + // Specify any scheduler hints if needed + schedulerHintOpts := servers.SchedulerHintOpts{} + + server, err := servers.Create(ctx, p.computeClient, createOpts, schedulerHintOpts).Extract() + if err != nil { + return nil, err + } + + ips, err := GetFixedIPs(ctx, p.computeClient, server.ID) + if err != nil { + return nil, fmt.Errorf("failed to extract IPs from server addresses: %w", err) + } + + if len(ips) != 0 { + portID := GetPortID(p.computeClient, server.ID, ips[0].String()) + + // Assign a floating IP if configured + if p.serviceConfig.FloatingIpNetworkID != "" && portID != "" { + fip, fid, err := AssignFloatingIP(ctx, p.networkClient, portID, p.serviceConfig.FloatingIpNetworkID) + if err != nil { + return nil, fmt.Errorf("failed to assign floating IP: %w", err) + } + p.floatingIPPool[server.ID] = fid + + // prepend floating IP to the IP list + ips = append([]netip.Addr{fip}, ips...) + } + } + + instance := &provider.Instance{ + ID: server.ID, + Name: instanceName, + IPs: ips, + } + + return instance, nil +} + +func (p *openstackProvider) DeleteInstance(ctx context.Context, instanceID string) error { + logger.Printf("Deleting instance: %s", instanceID) + + // if a floating IP was assigned, release it + floatingIPID, existsFloatingIP := p.floatingIPPool[instanceID] + if existsFloatingIP { + err := DeleteFloatingIP(ctx, p.networkClient, floatingIPID) + if err != nil { + logger.Printf("failed to delete floating IP %s: %v", floatingIPID, err) + } + delete(p.floatingIPPool, instanceID) + } else { + logger.Printf("No floating IP assigned to instance: %s", instanceID) + } + + // delete the instance + err := servers.Delete(ctx, p.computeClient, instanceID).ExtractErr() + if err != nil { + // if the instance is already deleted + if gophercloud.ResponseCodeIs(err, 404) { + logger.Printf("Instance %s already deleted", instanceID) + return nil + } + return err + } + logger.Printf("Successfully sent delete request for instance: %s", instanceID) + return nil +} + +func (p *openstackProvider) Teardown() error { + return nil +} + +func (p *openstackProvider) ConfigVerifier() error { + if len(p.serviceConfig.ImageID) == 0 { + return fmt.Errorf("imageID is empty") + } + return nil +} diff --git a/src/cloud-providers/openstack/provider_test.go b/src/cloud-providers/openstack/provider_test.go new file mode 100644 index 0000000000..983533d774 --- /dev/null +++ b/src/cloud-providers/openstack/provider_test.go @@ -0,0 +1,1014 @@ +// (C) Copyright Confidential Containers Contributors +// SPDX-License-Identifier: Apache-2.0 + +package openstack + +import ( + "context" + "encoding/json" + "fmt" + "log" + "net/http" + "net/http/httptest" + "reflect" + "testing" + + provider "github.com/confidential-containers/cloud-api-adaptor/src/cloud-providers" + "github.com/confidential-containers/cloud-api-adaptor/src/cloud-providers/util/cloudinit" + "github.com/gophercloud/gophercloud/v2" +) + +func TestNewProvider(t *testing.T) { + server := CreateServer() + serverNoNetwork := CreateServerNoNetwork() + + tests := []struct { + name string + cfg Config + wantError bool + }{ + { + name: "ValidConfig", + cfg: Config{ + IdentityEndpoint: server.URL + "/v3", + Username: "test-user", + Password: "test-password", + TenantName: "test-tenant", + DomainName: "test-domain", + Region: "test-region", + }, + wantError: false, + }, + { + name: "InvalidEndpoint", + cfg: Config{ + IdentityEndpoint: "http://bad-address.example.com/v3", + Username: "test-user", + Password: "test-password", + TenantName: "test-tenant", + DomainName: "test-domain", + Region: "test-region", + }, + wantError: true, + }, + { + name: "InvalidRegion", + cfg: Config{ + IdentityEndpoint: server.URL + "/v3", + Username: "test-user", + Password: "test-password", + TenantName: "test-tenant", + DomainName: "test-domain", + Region: "bad-region", + }, + wantError: true, + }, + { + name: "InvalidNetworkClient", + cfg: Config{ + IdentityEndpoint: serverNoNetwork.URL + "/v3", + Username: "test-user", + Password: "test-password", + TenantName: "test-tenant", + DomainName: "test-domain", + Region: "test-region", + }, + wantError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + provider, err := NewProvider(&tt.cfg) + if tt.wantError { + if err == nil { + t.Errorf("Expected error for test case %v, but got none", tt.name) + } else { + t.Logf("Expected error occurred for test case %v: %v", tt.name, err) + } + + if provider != nil { + t.Errorf("Expected provider to be nil for test case %v, but got: %+v", tt.name, provider) + } + + switch tt.name { + case "InvalidEndpoint": + providerClient, err := NewProviderClient(tt.cfg) + if err == nil { + t.Errorf("Expected ProviderClient creation to fail for %v, but got no error", tt.name) + } else { + t.Logf("ProviderClient creation failed as expected for %v: %v", tt.name, err) + } + if providerClient != nil { + t.Errorf("Expected ProviderClient to be nil for %v, but got: %+v", tt.name, providerClient) + } + + case "InvalidRegion": + providerClient, err := NewProviderClient(tt.cfg) + if err != nil { + t.Errorf("Expected ProviderClient creation to succeed for %v, but got error: %v", tt.name, err) + } else { + t.Logf("ProviderClient creation succeeded for %v", tt.name) + } + if providerClient == nil { + t.Errorf("Expected ProviderClient to be non-nil for %v, but got nil", tt.name) + return + } + + computeClient, err := NewComputeClient(providerClient, gophercloud.EndpointOpts{Region: tt.cfg.Region}) + if err == nil { + t.Errorf("Expected ComputeClient creation to fail for %v, but got no error", tt.name) + } else { + t.Logf("ComputeClient creation failed as expected for %v: %v", tt.name, err) + } + if computeClient != nil { + t.Errorf("Expected ComputeClient to be nil for %v, but got: %+v", tt.name, computeClient) + } + + case "InvalidNetworkClient": + providerClient, err := NewProviderClient(tt.cfg) + if err != nil { + t.Errorf("Expected ProviderClient creation to succeed for %v, but got error: %v", tt.name, err) + } else { + t.Logf("ProviderClient creation succeeded for %v", tt.name) + } + if providerClient == nil { + t.Errorf("Expected ProviderClient to be non-nil for %v, but got nil", tt.name) + return + } + + computeClient, err := NewComputeClient(providerClient, gophercloud.EndpointOpts{Region: tt.cfg.Region}) + if err != nil { + t.Errorf("Expected ComputeClient creation to succeed for %v, but got error: %v", tt.name, err) + } else { + t.Logf("ComputeClient creation succeeded for %v", tt.name) + } + if computeClient == nil { + t.Errorf("Expected ComputeClient to be non-nil for %v, but got nil", tt.name) + return + } + + networkClient, err := NewNetworkClient(providerClient, gophercloud.EndpointOpts{Region: tt.cfg.Region}) + if err == nil { + t.Errorf("Expected NetworkClient creation to fail for %v, but got no error", tt.name) + } else { + t.Logf("NetworkClient creation failed as expected for %v: %v", tt.name, err) + } + if networkClient != nil { + t.Errorf("Expected NetworkClient to be nil for %v, but got: %+v", tt.name, networkClient) + } + } + + } else { + if err != nil { + t.Errorf("Expected no error for test case %v, but got: %v", tt.name, err) + } + + if provider == nil { + t.Errorf("Expected provider to be non-nil for test case %v, but got nil", tt.name) + return + } + + openstackProvider, ok := provider.(*openstackProvider) + if !ok { + t.Errorf("Expected provider to be of type *openstackProvider for test case %v, but got: %T", tt.name, provider) + return + } + + if openstackProvider.providerClient == nil { + t.Errorf("Expected providerClient to be non-nil for test case %v", tt.name) + } + if openstackProvider.computeClient == nil { + t.Errorf("Expected computeClient to be non-nil for test case %v", tt.name) + } + if openstackProvider.networkClient == nil { + t.Errorf("Expected networkClient to be non-nil for test case %v", tt.name) + } + if openstackProvider.serviceConfig == nil { + t.Errorf("Expected serviceConfig to be non-nil for test case %v", tt.name) + } + + if openstackProvider.serviceConfig != nil { + if openstackProvider.serviceConfig.IdentityEndpoint != tt.cfg.IdentityEndpoint { + t.Errorf("Expected IdentityEndpoint %v, but got %v for test case %v", tt.cfg.IdentityEndpoint, openstackProvider.serviceConfig.IdentityEndpoint, tt.name) + } + if openstackProvider.serviceConfig.Username != tt.cfg.Username { + t.Errorf("Expected Username %v, but got %v for test case %v", tt.cfg.Username, openstackProvider.serviceConfig.Username, tt.name) + } + if openstackProvider.serviceConfig.Password != tt.cfg.Password { + t.Errorf("Expected Password %v, but got %v for test case %v", tt.cfg.Password, openstackProvider.serviceConfig.Password, tt.name) + } + if openstackProvider.serviceConfig.TenantName != tt.cfg.TenantName { + t.Errorf("Expected TenantName %v, but got %v for test case %v", tt.cfg.TenantName, openstackProvider.serviceConfig.TenantName, tt.name) + } + if openstackProvider.serviceConfig.DomainName != tt.cfg.DomainName { + t.Errorf("Expected DomainName %v, but got %v for test case %v", tt.cfg.DomainName, openstackProvider.serviceConfig.DomainName, tt.name) + } + if openstackProvider.serviceConfig.Region != tt.cfg.Region { + t.Errorf("Expected Region %v, but got %v for test case %v", tt.cfg.Region, openstackProvider.serviceConfig.Region, tt.name) + } + } + } + }) + } +} + +type mockCloudConfig struct{} + +func (c *mockCloudConfig) Generate() (string, error) { + return "cloud config", nil +} + +type errorCloudConfig struct{} + +func (c *errorCloudConfig) Generate() (string, error) { + return "", fmt.Errorf("invalid cloud config") +} + +func TestCreateInstance(t *testing.T) { + tests := []struct { + name string + config Config + networks []string + cloudConfig cloudinit.CloudConfigGenerator + handler http.HandlerFunc + getHandler http.HandlerFunc + wantError bool + }{ + { + name: "ValidConfig", + config: Config{ + IdentityEndpoint: "", + Username: "test-user", + Password: "test-password", + TenantName: "test-tenant", + DomainName: "test-domain", + }, + networks: nil, + cloudConfig: &mockCloudConfig{}, + handler: HandlerFuncServers, + getHandler: HandlerFuncServersGetSuccess, + wantError: false, + }, + { + name: "NetworksEmpty", + config: Config{ + IdentityEndpoint: "", + Username: "test-user", + Password: "test-password", + TenantName: "test-tenant", + DomainName: "test-domain", + }, + networks: []string{}, + cloudConfig: &mockCloudConfig{}, + handler: HandlerFuncServersNetwork, + getHandler: HandlerFuncServersGetNetwork, + wantError: false, + }, + { + name: "NetworksSet", + config: Config{ + IdentityEndpoint: "", + Username: "test-user", + Password: "test-password", + TenantName: "test-tenant", + DomainName: "test-domain", + }, + networks: []string{"test_net"}, + cloudConfig: &mockCloudConfig{}, + handler: HandlerFuncServersNetwork, + getHandler: HandlerFuncServersGetNetworkSet, + wantError: false, + }, + { + name: "InvalidCloudConfig", + config: Config{ + IdentityEndpoint: "", + Username: "test-user", + Password: "test-password", + TenantName: "test-tenant", + DomainName: "test-domain", + }, + networks: nil, + cloudConfig: &errorCloudConfig{}, + handler: HandlerFuncServers, + getHandler: nil, + wantError: true, + }, + { + name: "ServersCreateError", + config: Config{ + IdentityEndpoint: "", + Username: "test-user", + Password: "test-password", + TenantName: "test-tenant", + DomainName: "test-domain", + }, + networks: nil, + cloudConfig: &mockCloudConfig{}, + handler: HandlerFuncServersError, + getHandler: nil, + wantError: true, + }, + { + name: "InvalidServerAddress", + config: Config{ + IdentityEndpoint: "", + Username: "test-user", + Password: "test-password", + TenantName: "test-tenant", + DomainName: "test-domain", + }, + networks: nil, + cloudConfig: &mockCloudConfig{}, + handler: HandlerFuncServersInvalidAddr, + getHandler: HandlerFuncServersGetInvalidAddr, + wantError: true, + }, + { + name: "GetFixedIPError", + config: Config{ + IdentityEndpoint: "", + Username: "test-user", + Password: "test-password", + TenantName: "test-tenant", + DomainName: "test-domain", + }, + networks: nil, + cloudConfig: &mockCloudConfig{}, + handler: HandlerFuncServersCreateFixedIPError, + getHandler: HandlerFuncServersGetFixedIPError, + wantError: true, + }, + { + name: "AssignFloatingIPError", + config: Config{ + IdentityEndpoint: "", + Username: "test-user", + Password: "test-password", + TenantName: "test-tenant", + DomainName: "test-domain", + }, + networks: nil, + cloudConfig: &mockCloudConfig{}, + handler: HandlerFuncServersAssignFloatingIPError, + getHandler: nil, + wantError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/v2.0/test-tenant/servers", tt.handler) + mux.HandleFunc("/v2.0/tokens", HandlerFuncV2) + mux.HandleFunc("/v3/auth/tokens", HandlerFuncV3) + + if tt.getHandler != nil { + mux.HandleFunc("/v2.0/test-tenant/servers/test-server-id-12345", tt.getHandler) + } + + if tt.name == "NetworksEmpty" || tt.name == "NetworksSet" { + if tt.name == "NetworksSet" { + mux.HandleFunc("/v2.0/test-tenant/servers/test-server-id-12345/os-interface", HandlerFuncOSInterfaceNetworkSet) + } else { + mux.HandleFunc("/v2.0/test-tenant/servers/test-server-id-12345/os-interface", HandlerFuncOSInterfaceNetwork) + } + } else { + mux.HandleFunc("/v2.0/test-tenant/servers/test-server-id-12345/os-interface", HandlerFuncOSInterface) + } + + server := httptest.NewServer(mux) + defer server.Close() + + cfg := tt.config + cfg.IdentityEndpoint = server.URL + "/v3" + cfg.NetworkIDs = tt.networks + + testProvider, err := NewProvider(&cfg) + if err != nil { + t.Fatalf("Expected provider to be created, but got error: %v", err) + } + + podname := "test-vm" + sandboxID := "12345" + spec := provider.InstanceTypeSpec{} + instance, err := testProvider.CreateInstance(context.Background(), podname, sandboxID, tt.cloudConfig, spec) + + if tt.wantError { + if err == nil { + t.Errorf("Expected error for test case %v, but got none", tt.name) + } else if instance != nil { + t.Errorf("Expected instance to be nil for test case %v, but got: %+v", tt.name, instance) + } else { + t.Logf("Expected error occurred for test case %v: %v", tt.name, err) + } + } else { + if err != nil { + t.Errorf("Expected no error for test case %v, but got: %v", tt.name, err) + } else if instance == nil { + t.Errorf("Expected instance to be created for test case %v, but got nil", tt.name) + } else { + t.Logf("Instance successfully created for test case %v: %+v", tt.name, instance) + } + } + }) + } +} + +func TestDeleteInstance(t *testing.T) { + tests := []struct { + name string + handler http.HandlerFunc + wantError bool + }{ + { + name: "DeleteOK", + handler: HandlerFuncDeleteInstanceOK, + wantError: false, + }, + { + name: "AlreadyDeleted", + handler: HandlerFuncDeleteInstanceAlreadyDeleted, + wantError: false, + }, + { + name: "FloatingIPNotAssigned", + handler: HandlerFuncDeleteInstanceFloatingIPNotAssigned, + wantError: false, + }, + { + name: "DeleteError", + handler: HandlerFuncDeleteInstanceError, + wantError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/v2.0/test-tenant/servers/12345", tt.handler) + mux.HandleFunc("/v2.0/tokens", HandlerFuncV2) + mux.HandleFunc("/v3/auth/tokens", HandlerFuncV3) + server := httptest.NewServer(mux) + defer server.Close() + + openstackcfg := Config{ + IdentityEndpoint: server.URL + "/v3", + Username: "test-user", + Password: "test-password", + TenantName: "test-tenant", + DomainName: "test-domain", + } + + deleteProvider, err := NewProvider(&openstackcfg) + if err != nil { + t.Fatalf("Expected provider to be created, but got error: %v", err) + } else if deleteProvider == nil { + t.Fatalf("Expected provider to be created, but got nil") + } else { + t.Logf("Provider successfully created: %+v", deleteProvider) + } + + instanceID := "12345" + + if tt.name == "DeleteOK" || tt.name == "DeleteError" { + openstackProvider, ok := deleteProvider.(*openstackProvider) + if !ok { + t.Fatalf("Expected provider to be of type *openstackProvider, but got: %T", deleteProvider) + } + openstackProvider.floatingIPPool[instanceID] = "test-floating-ip-id-12345" + + mux.HandleFunc("/v2.0/v2.0/floatingips/test-floating-ip-id-12345", HandlerFuncDeleteFloatingIPSuccess) + } + + err = deleteProvider.DeleteInstance(context.Background(), instanceID) + + if tt.wantError { + if err == nil { + t.Errorf("Expected error for test case %v, but got none", tt.name) + } else { + t.Logf("Expected error occurred for test case %v: %v", tt.name, err) + } + } else { + if err != nil { + t.Errorf("Expected no error for test case %v, but got: %v", tt.name, err) + } else { + t.Logf("DeleteInstance succeeded for test case: %v", tt.name) + } + } + }) + } +} + +func TestTeardown(t *testing.T) { + server := CreateServer() + + openstackcfg := Config{ + IdentityEndpoint: server.URL + "/v3", + Username: "test-user", + Password: "test-password", + TenantName: "test-tenant", + DomainName: "test-domain", + } + + provider, err := NewProvider(&openstackcfg) + + if err != nil { + t.Fatalf("Expected provider to be created, but got error: %v", err) + } else if provider == nil { + t.Fatalf("Expected provider to be created, but got nil") + } else { + t.Logf("Provider successfully created: %+v", provider) + } + + err = provider.Teardown() + + if err != nil { + t.Errorf("Expected no error in Teardown, but got: %v", err) + } else { + t.Logf("Teardown succeeded for test case: %v", t.Name()) + } +} + +func TestConfigVerifier(t *testing.T) { + server := CreateServer() + + tests := []struct { + name string + cfg Config + wantError bool + }{ + { + name: "ValidConfig", + cfg: Config{ + IdentityEndpoint: server.URL + "/v3", + Username: "test-user", + Password: "test-password", + TenantName: "test-tenant", + DomainName: "test-domain", + ImageID: "test-image-id", + }, + wantError: false, + }, + { + name: "EmptyImageID", + cfg: Config{ + IdentityEndpoint: server.URL + "/v3", + Username: "test-user", + Password: "test-password", + TenantName: "test-tenant", + DomainName: "test-domain", + ImageID: "", + }, + wantError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + provider, err := NewProvider(&tt.cfg) + if err != nil { + t.Fatalf("Expected provider to be created, but got error: %v", err) + } else if provider == nil { + t.Fatalf("Expected provider to be created, but got nil") + } else { + t.Logf("Provider successfully created for test case %v", tt.name) + } + + err = provider.ConfigVerifier() + + if tt.wantError { + if err == nil { + t.Errorf("Expected error for test case %v, but got none", tt.name) + } else { + t.Logf("Expected error occurred for test case %v: %v", tt.name, err) + } + } else { + if err != nil { + t.Errorf("Expected no error for test case %v, but got: %v", tt.name, err) + } else { + t.Logf("ConfigVerifier succeeded for test case: %v", tt.name) + } + } + }) + } +} + +func HandlerFuncServers(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.Method == "POST" { + w.WriteHeader(http.StatusAccepted) + _, err := w.Write([]byte(`{ + "server": { + "id": "test-server-id-12345", + "name": "test-vm", + "status": "BUILD", + "created": "2024-11-20T12:00:00Z", + "hostId": "", + "progress": 0, + "accessIPv4": "", + "accessIPv6": "", + "image": { + "id": "test-image" + }, + "flavor": { + "id": "test-flavor" + }, + "addresses": {}, + "metadata": {}, + "links": [] + } + }`)) + if err != nil { + log.Printf("Warning: Failed to write response: %v", err) + } + } else { + w.WriteHeader(http.StatusMethodNotAllowed) + } +} + +func HandlerFuncServersError(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.Method == "POST" { + w.WriteHeader(http.StatusInternalServerError) + _, err := w.Write([]byte(`{"error": "internal server error"}`)) + if err != nil { + log.Printf("Warning: Failed to write response: %v", err) + } + } else { + w.WriteHeader(http.StatusMethodNotAllowed) + } +} + +func HandlerFuncServersInvalidAddr(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.Method == "POST" { + w.WriteHeader(http.StatusAccepted) + _, err := w.Write([]byte(`{ + "server": { + "id": "test-server-id-12345", + "name": "test-vm", + "status": "BUILD", + "created": "2024-11-20T12:00:00Z", + "hostId": "", + "progress": 0, + "accessIPv4": "", + "accessIPv6": "", + "image": { + "id": "test-image" + }, + "flavor": { + "id": "test-flavor" + }, + "addresses": {"private": [{"addr": "invalid_ip"}]}, + "metadata": {}, + "links": [] + } + }`)) + if err != nil { + log.Printf("Warning: Failed to write response: %v", err) + } + } else { + w.WriteHeader(http.StatusMethodNotAllowed) + } +} + +func HandlerFuncServersNetwork(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.Method == "POST" { + var reqBody map[string]interface{} + err := json.NewDecoder(r.Body).Decode(&reqBody) + if err != nil { + w.WriteHeader(http.StatusBadRequest) + return + } + + server := reqBody["server"].(map[string]interface{}) + networks, hasNetworks := server["networks"] + + var responseJSON string + log.Printf("Request body for server creation: %+v", reqBody) + + if !hasNetworks || networks == nil || networks == "auto" || isEmptyNetworkSlice(networks) { + responseJSON = `{ + "server": { + "id": "test-server-id-12345", + "name": "test-vm", + "status": "BUILD", + "created": "2024-11-20T12:00:00Z", + "addresses": {"auto": [{"addr": "192.168.1.30"}]}, + "metadata": {}, + "links": [] + } + }` + } else { + responseJSON = `{ + "server": { + "id": "test-server-id-12345", + "name": "test-vm", + "status": "BUILD", + "created": "2024-11-20T12:00:00Z", + "addresses": {"test_net": [{"addr": "10.0.0.5"}]}, + "metadata": {}, + "links": [] + } + }` + } + + w.WriteHeader(http.StatusAccepted) + _, err = w.Write([]byte(responseJSON)) + if err != nil { + log.Printf("Warning: Failed to write response: %v", err) + } + } else { + w.WriteHeader(http.StatusMethodNotAllowed) + } +} + +func isEmptyNetworkSlice(networks interface{}) bool { + if networks == nil { + return true + } + if reflect.TypeOf(networks).Kind() == reflect.Slice { + return len(networks.([]interface{})) == 0 + } + return false +} + +func HandlerFuncServersCreateFixedIPError(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.Method == "POST" { + w.WriteHeader(http.StatusAccepted) + _, err := w.Write([]byte(`{ + "server": { + "id": "test-server-id-12345", + "name": "test-vm", + "status": "BUILD", + "created": "2024-11-20T12:00:00Z", + "hostId": "", + "progress": 0, + "accessIPv4": "", + "accessIPv6": "", + "image": { + "id": "test-image" + }, + "flavor": { + "id": "test-flavor" + }, + "addresses": {"private": [{"addr": ""}]}, + "metadata": {}, + "links": [] + } + }`)) + if err != nil { + log.Printf("Warning: Failed to write response: %v", err) + } + } else { + w.WriteHeader(http.StatusMethodNotAllowed) + } +} + +func HandlerFuncServersAssignFloatingIPError(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.Method == "POST" { + w.WriteHeader(http.StatusInternalServerError) + _, err := w.Write([]byte(`{"error": {"message": "Failed to assign floating IP"}}`)) + if err != nil { + log.Printf("Warning: Failed to write response: %v", err) + } + } else { + w.WriteHeader(http.StatusMethodNotAllowed) + } +} + +func HandlerFuncServersGetSuccess(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.Method == "GET" { + w.WriteHeader(http.StatusOK) + _, err := w.Write([]byte(`{ + "server": { + "id": "test-server-id-12345", + "name": "test-vm", + "status": "ACTIVE", + "created": "2024-11-20T12:00:00Z", + "addresses": {"private": [{"addr": "192.168.1.10"}]}, + "metadata": {}, + "links": [] + } + }`)) + if err != nil { + log.Printf("Warning: Failed to write response: %v", err) + } + } else { + w.WriteHeader(http.StatusMethodNotAllowed) + } +} + +func HandlerFuncServersGetNetwork(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.Method == "GET" { + w.WriteHeader(http.StatusOK) + _, err := w.Write([]byte(`{ + "server": { + "id": "test-server-id-12345", + "name": "test-vm", + "status": "ACTIVE", + "created": "2024-11-20T12:00:00Z", + "addresses": {"auto": [{"addr": "192.168.1.30"}]}, + "metadata": {}, + "links": [] + } + }`)) + if err != nil { + log.Printf("Warning: Failed to write response: %v", err) + } + } else { + w.WriteHeader(http.StatusMethodNotAllowed) + } +} + +func HandlerFuncServersGetNetworkSet(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.Method == "GET" { + w.WriteHeader(http.StatusOK) + _, err := w.Write([]byte(`{ + "server": { + "id": "test-server-id-12345", + "name": "test-vm", + "status": "ACTIVE", + "created": "2024-11-20T12:00:00Z", + "addresses": {"test_net": [{"addr": "10.0.0.5"}]}, + "metadata": {}, + "links": [] + } + }`)) + if err != nil { + log.Printf("Warning: Failed to write response: %v", err) + } + } else { + w.WriteHeader(http.StatusMethodNotAllowed) + } +} + +func HandlerFuncServersGetInvalidAddr(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.Method == "GET" { + w.WriteHeader(http.StatusOK) + _, err := w.Write([]byte(`{ + "server": { + "id": "test-server-id-12345", + "name": "test-vm", + "status": "ACTIVE", + "created": "2024-11-20T12:00:00Z", + "addresses": {"private": [{"addr": "invalid_ip"}]}, + "metadata": {}, + "links": [] + } + }`)) + if err != nil { + log.Printf("Warning: Failed to write response: %v", err) + } + } else { + w.WriteHeader(http.StatusMethodNotAllowed) + } +} + +func HandlerFuncServersGetFixedIPError(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.Method == "GET" { + w.WriteHeader(http.StatusOK) + _, err := w.Write([]byte(`{ + "server": { + "id": "test-server-id-12345", + "name": "test-vm", + "status": "ACTIVE", + "created": "2024-11-20T12:00:00Z", + "addresses": {"private": [{"addr": ""}]}, + "metadata": {}, + "links": [] + } + }`)) + if err != nil { + log.Printf("Warning: Failed to write response: %v", err) + } + } else { + w.WriteHeader(http.StatusMethodNotAllowed) + } +} + +func HandlerFuncDeleteInstanceOK(w http.ResponseWriter, r *http.Request) { + if r.Method == "DELETE" { + w.WriteHeader(http.StatusNoContent) + } else { + w.WriteHeader(http.StatusMethodNotAllowed) + } +} + +func HandlerFuncDeleteInstanceAlreadyDeleted(w http.ResponseWriter, r *http.Request) { + if r.Method == "DELETE" { + w.WriteHeader(http.StatusNotFound) + } else { + w.WriteHeader(http.StatusMethodNotAllowed) + } +} + +func HandlerFuncDeleteInstanceFloatingIPNotAssigned(w http.ResponseWriter, r *http.Request) { + if r.Method == "DELETE" { + w.WriteHeader(http.StatusNoContent) + } else { + w.WriteHeader(http.StatusMethodNotAllowed) + } +} + +func HandlerFuncDeleteInstanceError(w http.ResponseWriter, r *http.Request) { + if r.Method == "DELETE" { + w.WriteHeader(http.StatusNotImplemented) + } else { + w.WriteHeader(http.StatusMethodNotAllowed) + } +} + +func HandlerFuncOSInterface(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.Method == "GET" { + w.WriteHeader(http.StatusOK) + _, err := w.Write([]byte(`{ + "interfaceAttachments": [ + { + "port_id": "test-port-id-12345", + "port_state": "ACTIVE", + "net_id": "test-network-id", + "mac_addr": "fa:16:3e:12:34:56", + "fixed_ips": [ + { + "subnet_id": "test-subnet-id", + "ip_address": "192.168.1.10" + } + ] + } + ] + }`)) + if err != nil { + log.Printf("Warning: Failed to write response: %v", err) + } + } else { + w.WriteHeader(http.StatusMethodNotAllowed) + } +} + +func HandlerFuncOSInterfaceNetwork(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.Method == "GET" { + w.WriteHeader(http.StatusOK) + _, err := w.Write([]byte(`{ + "interfaceAttachments": [ + { + "port_id": "test-port-id-network", + "port_state": "ACTIVE", + "net_id": "test-network-id", + "mac_addr": "fa:16:3e:aa:bb:cc", + "fixed_ips": [ + { + "subnet_id": "test-subnet-id", + "ip_address": "192.168.1.30" + } + ] + } + ] + }`)) + if err != nil { + log.Printf("Warning: Failed to write response: %v", err) + } + } else { + w.WriteHeader(http.StatusMethodNotAllowed) + } +} + +func HandlerFuncOSInterfaceNetworkSet(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.Method == "GET" { + w.WriteHeader(http.StatusOK) + _, err := w.Write([]byte(`{ + "interfaceAttachments": [ + { + "port_id": "test-port-id-network-set", + "port_state": "ACTIVE", + "net_id": "test-net-id", + "mac_addr": "fa:16:3e:dd:ee:ff", + "fixed_ips": [ + { + "subnet_id": "test-subnet-id", + "ip_address": "10.0.0.5" + } + ] + } + ] + }`)) + if err != nil { + log.Printf("Warning: Failed to write response: %v", err) + } + } else { + w.WriteHeader(http.StatusMethodNotAllowed) + } +} diff --git a/src/cloud-providers/openstack/types.go b/src/cloud-providers/openstack/types.go new file mode 100644 index 0000000000..41e79d0fb0 --- /dev/null +++ b/src/cloud-providers/openstack/types.go @@ -0,0 +1,56 @@ +// (C) Copyright Confidential Containers Contributors +// SPDX-License-Identifier: Apache-2.0 + +package openstack + +import ( + "strings" + + "github.com/confidential-containers/cloud-api-adaptor/src/cloud-providers/util" +) + +type networkIds []string + +func (i *networkIds) String() string { + return strings.Join(*i, ", ") +} + +func (i *networkIds) Set(value string) error { + if value != "" { + *i = append(*i, strings.Split(value, ",")...) + } + return nil +} + +type securityGroups []string + +func (i *securityGroups) String() string { + return strings.Join(*i, ", ") +} + +func (i *securityGroups) Set(value string) error { + if value != "" { + *i = append(*i, strings.Split(value, ",")...) + } + return nil +} + +type Config struct { + IdentityEndpoint string + Username string + TenantName string + Password string + DomainName string + Region string + ServerPrefix string + ImageID string + FlavorID string + NetworkIDs networkIds + SecurityGroups securityGroups + FloatingIpNetworkID string +} + +// Redact sensitive information from the config +func (c Config) Redact() Config { + return *util.RedactStruct(&c, "Username", "Password", "TenantName").(*Config) +} diff --git a/src/cloud-providers/openstack/types_test.go b/src/cloud-providers/openstack/types_test.go new file mode 100644 index 0000000000..bc3a36e393 --- /dev/null +++ b/src/cloud-providers/openstack/types_test.go @@ -0,0 +1,42 @@ +// (C) Copyright Confidential Containers Contributors +// SPDX-License-Identifier: Apache-2.0 + +package openstack + +import ( + "testing" +) + +func TestRedact(t *testing.T) { + // Prepare Config values for testing + cfg := Config{ + IdentityEndpoint: "https://identity.test-openstack/v3", + Username: "test-user", + TenantName: "test-tenant", + Password: "test-password", + DomainName: "test-domain", + Region: "test-region", + ServerPrefix: "test-vm-name", + ImageID: "test-image-id", + FlavorID: "test-flavor-id", + NetworkIDs: []string{"net1"}, + SecurityGroups: []string{"sg1"}, + FloatingIpNetworkID: "floating-net", + } + + redactedCfg := cfg.Redact() + t.Logf("Config: %v", redactedCfg) + + // Check if values are masked + maskingCfg := "**********" + + if redactedCfg.Username != maskingCfg { + t.Errorf("Username not redacted: %s", redactedCfg.Username) + } + if redactedCfg.Password != maskingCfg { + t.Errorf("Password not redacted: %s", redactedCfg.Password) + } + if redactedCfg.TenantName != maskingCfg { + t.Errorf("TenantName not redacted: %s", redactedCfg.TenantName) + } +} diff --git a/src/peerpod-ctrl/controllers/openstack.go b/src/peerpod-ctrl/controllers/openstack.go new file mode 100644 index 0000000000..8e827e99cb --- /dev/null +++ b/src/peerpod-ctrl/controllers/openstack.go @@ -0,0 +1,10 @@ +//go:build openstack + +// (C) Copyright Confidential Containers Contributors +// SPDX-License-Identifier: Apache-2.0 + +package controllers + +import ( + _ "github.com/confidential-containers/cloud-api-adaptor/src/cloud-providers/openstack" +) diff --git a/src/peerpod-ctrl/go.mod b/src/peerpod-ctrl/go.mod index 0ddce56747..40ac715cc4 100644 --- a/src/peerpod-ctrl/go.mod +++ b/src/peerpod-ctrl/go.mod @@ -40,6 +40,7 @@ require ( github.com/go-task/slim-sprig/v3 v3.0.0 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/google/pprof v0.0.0-20241210010833-40e02aabc2ad // indirect + github.com/gophercloud/gophercloud/v2 v2.8.0 // indirect github.com/kr/fs v0.1.0 // indirect github.com/opentracing/opentracing-go v1.2.0 // indirect github.com/pkg/sftp v1.13.9 // indirect diff --git a/src/peerpod-ctrl/go.sum b/src/peerpod-ctrl/go.sum index d46610e031..3be481a15d 100644 --- a/src/peerpod-ctrl/go.sum +++ b/src/peerpod-ctrl/go.sum @@ -319,6 +319,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= +github.com/gophercloud/gophercloud/v2 v2.8.0 h1:of2+8tT6+FbEYHfYC8GBu8TXJNsXYSNm9KuvpX7Neqo= +github.com/gophercloud/gophercloud/v2 v2.8.0/go.mod h1:Ki/ILhYZr/5EPebrPL9Ej+tUg4lqx71/YH2JWVeU+Qk= github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= github.com/gopherjs/gopherjs v0.0.0-20200217142428-fce0ec30dd00/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ=