diff --git a/internal/cloud/openstack/api.go b/internal/cloud/openstack/api.go new file mode 100644 index 000000000..69cf9f215 --- /dev/null +++ b/internal/cloud/openstack/api.go @@ -0,0 +1,23 @@ +/* +Copyright (c) Edgeless Systems GmbH + +SPDX-License-Identifier: AGPL-3.0-only +*/ + +package openstack + +import ( + "context" + + "github.com/edgelesssys/constellation/v2/internal/role" +) + +type imdsAPI interface { + providerID(ctx context.Context) (string, error) + name(ctx context.Context) (string, error) + projectID(ctx context.Context) (string, error) + uid(ctx context.Context) (string, error) + initSecretHash(ctx context.Context) (string, error) + role(ctx context.Context) (role.Role, error) + vpcIP(ctx context.Context) (string, error) +} diff --git a/internal/cloud/openstack/api_test.go b/internal/cloud/openstack/api_test.go new file mode 100644 index 000000000..ce48c8da7 --- /dev/null +++ b/internal/cloud/openstack/api_test.go @@ -0,0 +1,58 @@ +/* +Copyright (c) Edgeless Systems GmbH + +SPDX-License-Identifier: AGPL-3.0-only +*/ + +package openstack + +import ( + "context" + + "github.com/edgelesssys/constellation/v2/internal/role" +) + +type stubIMDSClient struct { + providerIDResult string + providerIDErr error + nameResult string + nameErr error + projectIDResult string + projectIDErr error + uidResult string + uidErr error + initSecretHashResult string + initSecretHashErr error + roleResult role.Role + roleErr error + vpcIPResult string + vpcIPErr error +} + +func (c *stubIMDSClient) providerID(ctx context.Context) (string, error) { + return c.providerIDResult, c.providerIDErr +} + +func (c *stubIMDSClient) name(ctx context.Context) (string, error) { + return c.nameResult, c.nameErr +} + +func (c *stubIMDSClient) projectID(ctx context.Context) (string, error) { + return c.projectIDResult, c.projectIDErr +} + +func (c *stubIMDSClient) uid(ctx context.Context) (string, error) { + return c.uidResult, c.uidErr +} + +func (c *stubIMDSClient) initSecretHash(ctx context.Context) (string, error) { + return c.initSecretHashResult, c.initSecretHashErr +} + +func (c *stubIMDSClient) role(ctx context.Context) (role.Role, error) { + return c.roleResult, c.roleErr +} + +func (c *stubIMDSClient) vpcIP(ctx context.Context) (string, error) { + return c.vpcIPResult, c.vpcIPErr +} diff --git a/internal/cloud/openstack/imds.go b/internal/cloud/openstack/imds.go new file mode 100644 index 000000000..b4cabe5d6 --- /dev/null +++ b/internal/cloud/openstack/imds.go @@ -0,0 +1,259 @@ +/* +Copyright (c) Edgeless Systems GmbH + +SPDX-License-Identifier: AGPL-3.0-only +*/ + +package openstack + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "time" + + "github.com/edgelesssys/constellation/v2/internal/cloud" + "github.com/edgelesssys/constellation/v2/internal/role" +) + +// documentation of OpenStack Metadata Service: https://docs.openstack.org/nova/rocky/user/metadata-service.html + +const ( + imdsMetaDataURL = "http://169.254.169.254/openstack/2018-08-27/meta_data.json" + ec2ImdsBaseURL = "http://169.254.169.254/1.0/meta-data" + maxCacheAge = 12 * time.Hour +) + +type imdsClient struct { + client httpClient + + vpcIPCache string + vpcIPCacheTime time.Time + cache metadataResponse + cacheTime time.Time +} + +// providerID returns the provider ID of the instance the function is called from. +func (c *imdsClient) providerID(ctx context.Context) (string, error) { + if c.timeForUpdate(c.cacheTime) || c.cache.UUID == "" { + if err := c.update(ctx); err != nil { + return "", err + } + } + + if c.cache.UUID == "" { + return "", errors.New("unable to get provider id") + } + + return c.cache.UUID, nil +} + +func (c *imdsClient) name(ctx context.Context) (string, error) { + if c.timeForUpdate(c.cacheTime) || c.cache.Name == "" { + if err := c.update(ctx); err != nil { + return "", err + } + } + + if c.cache.Name == "" { + return "", errors.New("unable to get name") + } + + return c.cache.Name, nil +} + +// projectID returns the project ID of the instance the function +// is called from. +func (c *imdsClient) projectID(ctx context.Context) (string, error) { + if c.timeForUpdate(c.cacheTime) || c.cache.ProjectID == "" { + if err := c.update(ctx); err != nil { + return "", err + } + } + + if c.cache.ProjectID == "" { + return "", errors.New("unable to get project id") + } + + return c.cache.ProjectID, nil +} + +// uid returns the UID of the cluster, based on the tags on the instance +// the function is called from, which are inherited from the scale set. +func (c *imdsClient) uid(ctx context.Context) (string, error) { + if c.timeForUpdate(c.cacheTime) || len(c.cache.Tags.UID) == 0 { + if err := c.update(ctx); err != nil { + return "", err + } + } + + if len(c.cache.Tags.UID) == 0 { + return "", fmt.Errorf("unable to get uid from metadata tags %v", c.cache.Tags) + } + + return c.cache.Tags.UID, nil +} + +// initSecretHash returns the hash of the init secret of the cluster, based on the tags on the instance +// the function is called from, which are inherited from the scale set. +func (c *imdsClient) initSecretHash(ctx context.Context) (string, error) { + if c.timeForUpdate(c.cacheTime) || len(c.cache.Tags.InitSecretHash) == 0 { + if err := c.update(ctx); err != nil { + return "", err + } + } + + if len(c.cache.Tags.InitSecretHash) == 0 { + return "", fmt.Errorf("unable to get tag %s from metadata tags %v", cloud.TagInitSecretHash, c.cache.Tags) + } + + return c.cache.Tags.InitSecretHash, nil +} + +// role returns the role of the instance the function is called from. +func (c *imdsClient) role(ctx context.Context) (role.Role, error) { + if c.timeForUpdate(c.cacheTime) || len(c.cache.Tags.Role) == 0 { + if err := c.update(ctx); err != nil { + return role.Unknown, err + } + } + + if len(c.cache.Tags.Role) == 0 { + return role.Unknown, fmt.Errorf("unable to get role from metadata tags %v", c.cache.Tags) + } + + return role.FromString(c.cache.Tags.Role), nil +} + +func (c *imdsClient) authURL(ctx context.Context) (string, error) { + if c.timeForUpdate(c.cacheTime) || c.cache.Tags.AuthURL == "" { + if err := c.update(ctx); err != nil { + return "", err + } + } + + if c.cache.Tags.AuthURL == "" { + return "", errors.New("unable to get auth url") + } + + return c.cache.Tags.AuthURL, nil +} + +func (c *imdsClient) userDomainName(ctx context.Context) (string, error) { + if c.timeForUpdate(c.cacheTime) || c.cache.Tags.UserDomainName == "" { + if err := c.update(ctx); err != nil { + return "", err + } + } + + if c.cache.Tags.UserDomainName == "" { + return "", errors.New("unable to get user domain name") + } + + return c.cache.Tags.UserDomainName, nil +} + +func (c *imdsClient) username(ctx context.Context) (string, error) { + if c.timeForUpdate(c.cacheTime) || c.cache.Tags.Username == "" { + if err := c.update(ctx); err != nil { + return "", err + } + } + + if c.cache.Tags.Username == "" { + return "", errors.New("unable to get token name") + } + + return c.cache.Tags.Username, nil +} + +func (c *imdsClient) password(ctx context.Context) (string, error) { + if c.timeForUpdate(c.cacheTime) || c.cache.Tags.Password == "" { + if err := c.update(ctx); err != nil { + return "", err + } + } + + if c.cache.Tags.Password == "" { + return "", errors.New("unable to get token password") + } + + return c.cache.Tags.Password, nil +} + +// timeForUpdate checks whether an update is needed due to cache age. +func (c *imdsClient) timeForUpdate(t time.Time) bool { + return time.Since(t) > maxCacheAge +} + +// update updates instance metadata from the azure imds API. +func (c *imdsClient) update(ctx context.Context) error { + resp, err := httpGet(ctx, c.client, imdsMetaDataURL) + if err != nil { + return err + } + var metadataResp metadataResponse + if err := json.Unmarshal(resp, &metadataResp); err != nil { + return err + } + c.cache = metadataResp + c.cacheTime = time.Now() + return nil +} + +func (c *imdsClient) vpcIP(ctx context.Context) (string, error) { + const path = "local-ipv4" + + if c.timeForUpdate(c.vpcIPCacheTime) || c.vpcIPCache == "" { + resp, err := httpGet(ctx, c.client, ec2ImdsBaseURL+"/"+path) + if err != nil { + return "", err + } + c.vpcIPCache = string(resp) + c.vpcIPCacheTime = time.Now() + } + + if c.vpcIPCache == "" { + return "", errors.New("unable to get vpc ip") + } + + return c.vpcIPCache, nil +} + +func httpGet(ctx context.Context, c httpClient, url string) ([]byte, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, http.NoBody) + if err != nil { + return nil, err + } + resp, err := c.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + return io.ReadAll(resp.Body) +} + +// metadataResponse contains metadataResponse with only the required values. +type metadataResponse struct { + UUID string `json:"uuid,omitempty"` + ProjectID string `json:"project_id,omitempty"` + Name string `json:"name,omitempty"` + Tags metadataTags `json:"meta,omitempty"` +} + +type metadataTags struct { + InitSecretHash string `json:"constellation-init-secret-hash,omitempty"` + Role string `json:"constellation-role,omitempty"` + UID string `json:"constellation-uid,omitempty"` + AuthURL string `json:"openstack-auth-url,omitempty"` + UserDomainName string `json:"openstack-user-domain-name,omitempty"` + Username string `json:"openstack-username,omitempty"` + Password string `json:"openstack-password,omitempty"` +} + +type httpClient interface { + Do(req *http.Request) (*http.Response, error) +} diff --git a/internal/cloud/openstack/imds_test.go b/internal/cloud/openstack/imds_test.go new file mode 100644 index 000000000..79170a1f7 --- /dev/null +++ b/internal/cloud/openstack/imds_test.go @@ -0,0 +1,402 @@ +/* +Copyright (c) Edgeless Systems GmbH + +SPDX-License-Identifier: AGPL-3.0-only +*/ + +package openstack + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "io" + "net/http" + "strings" + "testing" + "time" + + "github.com/edgelesssys/constellation/v2/internal/role" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestProviderID(t *testing.T) { + someErr := errors.New("failed") + + type testCase struct { + cache metadataResponse + cacheTime time.Time + newClient httpClientJSONCreateFunc + wantResult string + wantCall bool + wantErr bool + } + + newTestCases := func(mResp1, mResp2 metadataResponse, expect1, expect2 string) map[string]testCase { + return map[string]testCase{ + "cached": { + cache: mResp1, + cacheTime: time.Now(), + wantResult: expect1, + wantCall: false, + }, + "from http": { + newClient: newStubHTTPClientJSONFunc(mResp1, nil), + wantResult: expect1, + wantCall: true, + }, + "cache outdated": { + cache: mResp1, + cacheTime: time.Now().AddDate(0, 0, -1), + newClient: newStubHTTPClientJSONFunc(mResp2, nil), + wantResult: expect2, + wantCall: true, + }, + "cache empty": { + cacheTime: time.Now(), + newClient: newStubHTTPClientJSONFunc(mResp1, nil), + wantResult: expect1, + wantCall: true, + }, + "http error": { + newClient: newStubHTTPClientJSONFunc(metadataResponse{}, someErr), + wantCall: true, + wantErr: true, + }, + "http empty response": { + newClient: newStubHTTPClientJSONFunc(metadataResponse{}, nil), + wantCall: true, + wantErr: true, + }, + } + } + + testUnits := map[string]struct { + method func(c *imdsClient, ctx context.Context) (string, error) + testCases map[string]testCase + }{ + "providerID": { + method: (*imdsClient).providerID, + testCases: newTestCases( + metadataResponse{UUID: "uuid1"}, + metadataResponse{UUID: "uuid2"}, + "uuid1", "uuid2", + ), + }, + "name": { + method: (*imdsClient).name, + testCases: newTestCases( + metadataResponse{Name: "name1"}, + metadataResponse{Name: "name2"}, + "name1", "name2", + ), + }, + "projectID": { + method: (*imdsClient).projectID, + testCases: newTestCases( + metadataResponse{ProjectID: "projectID1"}, + metadataResponse{ProjectID: "projectID2"}, + "projectID1", "projectID2", + ), + }, + "uid": { + method: (*imdsClient).uid, + testCases: newTestCases( + metadataResponse{Tags: metadataTags{UID: "uid1"}}, + metadataResponse{Tags: metadataTags{UID: "uid2"}}, + "uid1", "uid2", + ), + }, + "initSecretHash": { + method: (*imdsClient).initSecretHash, + testCases: newTestCases( + metadataResponse{Tags: metadataTags{InitSecretHash: "hash1"}}, + metadataResponse{Tags: metadataTags{InitSecretHash: "hash2"}}, + "hash1", "hash2", + ), + }, + "authURL": { + method: (*imdsClient).authURL, + testCases: newTestCases( + metadataResponse{Tags: metadataTags{AuthURL: "authURL1"}}, + metadataResponse{Tags: metadataTags{AuthURL: "authURL2"}}, + "authURL1", "authURL2", + ), + }, + "userDomainName": { + method: (*imdsClient).userDomainName, + testCases: newTestCases( + metadataResponse{Tags: metadataTags{UserDomainName: "userDomainName1"}}, + metadataResponse{Tags: metadataTags{UserDomainName: "userDomainName2"}}, + "userDomainName1", "userDomainName2", + ), + }, + "username": { + method: (*imdsClient).username, + testCases: newTestCases( + metadataResponse{Tags: metadataTags{Username: "username1"}}, + metadataResponse{Tags: metadataTags{Username: "username2"}}, + "username1", "username2", + ), + }, + "password": { + method: (*imdsClient).password, + testCases: newTestCases( + metadataResponse{Tags: metadataTags{Password: "password1"}}, + metadataResponse{Tags: metadataTags{Password: "password2"}}, + "password1", "password2", + ), + }, + } + + for name, tu := range testUnits { + t.Run(name, func(t *testing.T) { + for name, tc := range tu.testCases { + t.Run(name, func(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + + var client *stubHTTPClientJSON + if tc.newClient != nil { + client = tc.newClient(require) + } + imds := &imdsClient{ + client: client, + cache: tc.cache, + cacheTime: tc.cacheTime, + } + + result, err := tu.method(imds, context.Background()) + + if tc.wantErr { + assert.Error(err) + } else { + assert.NoError(err) + assert.Equal(tc.wantResult, result) + if client != nil { + assert.Equal(tc.wantCall, client.called) + } + } + }) + } + }) + } +} + +func TestRole(t *testing.T) { + someErr := errors.New("failed") + mResp1 := metadataResponse{Tags: metadataTags{Role: "control-plane"}} + mResp2 := metadataResponse{Tags: metadataTags{Role: "worker"}} + expect1 := role.ControlPlane + expect2 := role.Worker + + testCases := map[string]struct { + cache metadataResponse + cacheTime time.Time + newClient httpClientJSONCreateFunc + wantResult role.Role + wantCall bool + wantErr bool + }{ + "cached": { + cache: mResp1, + cacheTime: time.Now(), + wantResult: expect1, + wantCall: false, + }, + "from http": { + newClient: newStubHTTPClientJSONFunc(mResp1, nil), + wantResult: expect1, + wantCall: true, + }, + "cache outdated": { + cache: mResp1, + cacheTime: time.Now().AddDate(0, 0, -1), + newClient: newStubHTTPClientJSONFunc(mResp2, nil), + wantResult: expect2, + wantCall: true, + }, + "cache empty": { + cacheTime: time.Now(), + newClient: newStubHTTPClientJSONFunc(mResp1, nil), + wantResult: expect1, + wantCall: true, + }, + "http error": { + newClient: newStubHTTPClientJSONFunc(metadataResponse{}, someErr), + wantCall: true, + wantErr: true, + }, + "http empty response": { + newClient: newStubHTTPClientJSONFunc(metadataResponse{}, nil), + wantCall: true, + wantErr: true, + }, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + + var client *stubHTTPClientJSON + if tc.newClient != nil { + client = tc.newClient(require) + } + imds := &imdsClient{ + client: client, + cache: tc.cache, + cacheTime: tc.cacheTime, + } + + result, err := imds.role(context.Background()) + + if tc.wantErr { + assert.Error(err) + } else { + assert.NoError(err) + assert.Equal(tc.wantResult, result) + if client != nil { + assert.Equal(tc.wantCall, client.called) + } + } + }) + } +} + +func TestVPCIP(t *testing.T) { + someErr := errors.New("failed") + + testCases := map[string]struct { + cache string + cacheTime time.Time + client *stubHTTPClient + wantResult string + wantCall bool + wantErr bool + }{ + "cached": { + cache: "192.0.2.1", + cacheTime: time.Now(), + wantResult: "192.0.2.1", + wantCall: false, + }, + "from http": { + client: &stubHTTPClient{response: "192.0.2.1"}, + wantResult: "192.0.2.1", + wantCall: true, + }, + "cache outdated": { + cache: "192.0.2.1", + cacheTime: time.Now().AddDate(0, 0, -1), + client: &stubHTTPClient{response: "192.0.2.2"}, + wantResult: "192.0.2.2", + wantCall: true, + }, + "cache empty": { + cacheTime: time.Now(), + client: &stubHTTPClient{response: "192.0.2.1"}, + wantResult: "192.0.2.1", + wantCall: true, + }, + "http error": { + client: &stubHTTPClient{err: someErr}, + wantCall: true, + wantErr: true, + }, + "http empty response": { + client: &stubHTTPClient{}, + wantCall: true, + wantErr: true, + }, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + assert := assert.New(t) + + imds := &imdsClient{ + client: tc.client, + vpcIPCache: tc.cache, + vpcIPCacheTime: tc.cacheTime, + } + + result, err := imds.vpcIP(context.Background()) + + if tc.wantErr { + assert.Error(err) + } else { + assert.NoError(err) + assert.Equal(tc.wantResult, result) + if tc.client != nil { + assert.Equal(tc.wantCall, tc.client.called) + } + } + }) + } +} + +func TestTimeForUpdate(t *testing.T) { + testCases := map[string]struct { + cacheTime time.Time + want bool + }{ + "cache outdated": { + cacheTime: time.Now().AddDate(-1, 0, -1), + want: true, + }, + "cache not outdated": { + cacheTime: time.Now(), + want: false, + }, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + assert := assert.New(t) + + imds := &imdsClient{cacheTime: tc.cacheTime} + + assert.Equal(tc.want, imds.timeForUpdate(tc.cacheTime)) + }) + } +} + +type httpClientJSONCreateFunc func(r *require.Assertions) *stubHTTPClientJSON + +type stubHTTPClientJSON struct { + require *require.Assertions + response metadataResponse + err error + called bool +} + +func newStubHTTPClientJSONFunc(response metadataResponse, err error) httpClientJSONCreateFunc { + return func(r *require.Assertions) *stubHTTPClientJSON { + return &stubHTTPClientJSON{ + response: response, + err: err, + require: r, + } + } +} + +func (c *stubHTTPClientJSON) Do(req *http.Request) (*http.Response, error) { + c.called = true + body, err := json.Marshal(c.response) + c.require.NoError(err) + return &http.Response{Body: io.NopCloser(bytes.NewReader(body))}, c.err +} + +type stubHTTPClient struct { + response string + err error + called bool +} + +func (c *stubHTTPClient) Do(req *http.Request) (*http.Response, error) { + c.called = true + return &http.Response{Body: io.NopCloser(strings.NewReader(c.response))}, c.err +} diff --git a/internal/cloud/openstack/openstack.go b/internal/cloud/openstack/openstack.go new file mode 100644 index 000000000..1de1f9860 --- /dev/null +++ b/internal/cloud/openstack/openstack.go @@ -0,0 +1,53 @@ +/* +Copyright (c) Edgeless Systems GmbH + +SPDX-License-Identifier: AGPL-3.0-only +*/ + +package openstack + +import ( + "context" + "fmt" + "net/http" + + "github.com/edgelesssys/constellation/v2/internal/cloud/metadata" +) + +// Cloud is the metadata client for OpenStack. +type Cloud struct { + imds imdsAPI +} + +// New creates a new OpenStack metadata client. +func New(ctx context.Context) (*Cloud, error) { + imds := &imdsClient{client: &http.Client{}} + return &Cloud{imds: imds}, nil +} + +// Self returns the metadata of the current instance. +func (c *Cloud) Self(ctx context.Context) (metadata.InstanceMetadata, error) { + name, err := c.imds.name(ctx) + if err != nil { + return metadata.InstanceMetadata{}, fmt.Errorf("getting name: %w", err) + } + providerID, err := c.imds.providerID(ctx) + if err != nil { + return metadata.InstanceMetadata{}, fmt.Errorf("getting provider id: %w", err) + } + role, err := c.imds.role(ctx) + if err != nil { + return metadata.InstanceMetadata{}, fmt.Errorf("getting role: %w", err) + } + vpcIP, err := c.imds.vpcIP(ctx) + if err != nil { + return metadata.InstanceMetadata{}, fmt.Errorf("getting vpc ip: %w", err) + } + + return metadata.InstanceMetadata{ + Name: name, + ProviderID: providerID, + Role: role, + VPCIP: vpcIP, + }, nil +} diff --git a/internal/cloud/openstack/openstack_test.go b/internal/cloud/openstack/openstack_test.go new file mode 100644 index 000000000..94f0501df --- /dev/null +++ b/internal/cloud/openstack/openstack_test.go @@ -0,0 +1,95 @@ +/* +Copyright (c) Edgeless Systems GmbH + +SPDX-License-Identifier: AGPL-3.0-only +*/ + +package openstack + +import ( + "context" + "fmt" + "testing" + + "github.com/edgelesssys/constellation/v2/internal/cloud/metadata" + "github.com/edgelesssys/constellation/v2/internal/role" + "github.com/stretchr/testify/assert" +) + +func TestSelf(t *testing.T) { + someErr := fmt.Errorf("failed") + + testCases := map[string]struct { + imds imdsAPI + want metadata.InstanceMetadata + wantErr bool + }{ + "success": { + imds: &stubIMDSClient{ + nameResult: "name", + providerIDResult: "providerID", + roleResult: role.ControlPlane, + vpcIPResult: "192.0.2.1", + }, + want: metadata.InstanceMetadata{ + Name: "name", + ProviderID: "providerID", + Role: role.ControlPlane, + VPCIP: "192.0.2.1", + }, + }, + "fail to get name": { + imds: &stubIMDSClient{ + nameErr: someErr, + providerIDResult: "providerID", + roleResult: role.ControlPlane, + vpcIPResult: "192.0.2.1", + }, + wantErr: true, + }, + "fail to get provider ID": { + imds: &stubIMDSClient{ + nameResult: "name", + providerIDErr: someErr, + roleResult: role.ControlPlane, + vpcIPResult: "192.0.2.1", + }, + wantErr: true, + }, + "fail to get role": { + imds: &stubIMDSClient{ + nameResult: "name", + providerIDResult: "providerID", + roleErr: someErr, + vpcIPResult: "192.0.2.1", + }, + wantErr: true, + }, + "fail to get VPC IP": { + imds: &stubIMDSClient{ + nameResult: "name", + providerIDResult: "providerID", + roleResult: role.ControlPlane, + vpcIPErr: someErr, + }, + wantErr: true, + }, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + assert := assert.New(t) + + c := &Cloud{imds: tc.imds} + + got, err := c.Self(context.Background()) + + if tc.wantErr { + assert.Error(err) + } else { + assert.NoError(err) + assert.Equal(tc.want, got) + } + }) + } +}