Skip to content

Commit

Permalink
Import Oracle Cloud tags (#52283)
Browse files Browse the repository at this point in the history
This change adds the ability to import tags when running on an
Oracle Cloud compute instance.
  • Loading branch information
atburke committed Feb 27, 2025
1 parent 282561b commit a113591
Show file tree
Hide file tree
Showing 9 changed files with 311 additions and 31 deletions.
1 change: 1 addition & 0 deletions api/types/constants.go
Original file line number Diff line number Diff line change
Expand Up @@ -1229,6 +1229,7 @@ const (
InstanceMetadataTypeEC2 InstanceMetadataType = "EC2"
InstanceMetadataTypeAzure InstanceMetadataType = "Azure"
InstanceMetadataTypeGCP InstanceMetadataType = "GCP"
InstanceMetadataTypeOracle InstanceMetadataType = "Oracle"
)

// OriginValues lists all possible origin values.
Expand Down
4 changes: 4 additions & 0 deletions lib/cloud/clients.go
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ import (
awsimds "github.com/gravitational/teleport/lib/cloud/imds/aws"
azureimds "github.com/gravitational/teleport/lib/cloud/imds/azure"
gcpimds "github.com/gravitational/teleport/lib/cloud/imds/gcp"
oracleimds "github.com/gravitational/teleport/lib/cloud/imds/oracle"
"github.com/gravitational/teleport/lib/modules"
"github.com/gravitational/teleport/lib/utils"
"github.com/gravitational/teleport/lib/utils/aws/iamutils"
Expand Down Expand Up @@ -1030,6 +1031,9 @@ func (c *cloudClients) initInstanceMetadata(ctx context.Context) (imds.Client, e
clt, err := gcpimds.NewInstanceMetadataClient(instancesClient)
return clt, trace.Wrap(err)
},
func(ctx context.Context) (imds.Client, error) {
return oracleimds.NewInstanceMetadataClient(), nil
},
}

client, err := DiscoverInstanceMetadata(ctx, providers)
Expand Down
142 changes: 142 additions & 0 deletions lib/cloud/imds/oracle/imds.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
// Teleport
// Copyright (C) 2025 Gravitational, Inc.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.

package oracle

import (
"context"
"io"
"net/http"
"net/url"
"strings"

"github.com/gravitational/trace"

"github.com/gravitational/teleport/api/types"
"github.com/gravitational/teleport/lib/auth/join/oracle"
"github.com/gravitational/teleport/lib/defaults"
"github.com/gravitational/teleport/lib/utils"
)

const defaultIMDSAddr = "http://169.254.169.254/opc/v2"

type instance struct {
ID string `json:"id"`
DefinedTags map[string]map[string]string `json:"definedTags"`
FreeformTags map[string]string `json:"freeformTags"`
}

// InstanceMetadataClient is a client for Oracle Cloud instance metadata.
type InstanceMetadataClient struct {
baseIMDSAddr string
}

// NewInstanceMetadataClient creates a new instance metadata client.
func NewInstanceMetadataClient() *InstanceMetadataClient {
return &InstanceMetadataClient{
baseIMDSAddr: defaultIMDSAddr,
}
}

func (clt *InstanceMetadataClient) getInstance(ctx context.Context) (*instance, error) {
httpClient, err := defaults.HTTPClient()
if err != nil {
return nil, trace.Wrap(err)
}
addr, err := url.JoinPath(clt.baseIMDSAddr, "instance")
if err != nil {
return nil, trace.Wrap(err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, addr, nil)
if err != nil {
return nil, trace.Wrap(err)
}
req.Header.Set("Authorization", "Bearer Oracle")
resp, err := httpClient.Do(req)
if err != nil {
return nil, trace.Wrap(err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, trace.Wrap(err)
}
if resp.StatusCode != http.StatusOK {
return nil, trace.ReadError(resp.StatusCode, body)
}
var inst instance
if err := utils.FastUnmarshal(body, &inst); err != nil {
return nil, trace.Wrap(err)
}
return &inst, nil
}

// IsAvailable checks if instance metadata is available.
func (clt *InstanceMetadataClient) IsAvailable(ctx context.Context) bool {
inst, err := clt.getInstance(ctx)
if err != nil {
return false
}
_, err = oracle.ParseRegionFromOCID(inst.ID)
return err == nil
}

// GetTags gets the instance's defined and freeform tags.
func (clt *InstanceMetadataClient) GetTags(ctx context.Context) (map[string]string, error) {
inst, err := clt.getInstance(ctx)
if err != nil {
return nil, trace.Wrap(err)
}
tags := make(map[string]string, len(inst.FreeformTags))
for k, v := range inst.FreeformTags {
tags[k] = v
}
for namespace, definedTags := range inst.DefinedTags {
for k, v := range definedTags {
tags[namespace+"/"+k] = v
}
}
return tags, nil
}

// GetHostname gets the hostname set by the cloud instance that Teleport
// should use, if any.
func (clt *InstanceMetadataClient) GetHostname(ctx context.Context) (string, error) {
inst, err := clt.getInstance(ctx)
if err != nil {
return "", trace.Wrap(err)
}
for k, v := range inst.FreeformTags {
if strings.EqualFold(k, types.CloudHostnameTag) {
return v, nil
}
}
return "", trace.NotFound("tag %q not found", types.CloudHostnameTag)
}

// GetType gets the cloud instance type.
func (clt *InstanceMetadataClient) GetType() types.InstanceMetadataType {
return types.InstanceMetadataTypeOracle
}

// GetID gets the ID of the cloud instance.
func (clt *InstanceMetadataClient) GetID(ctx context.Context) (string, error) {
inst, err := clt.getInstance(ctx)
if err != nil {
return "", trace.Wrap(err)
}
return inst.ID, nil
}
129 changes: 129 additions & 0 deletions lib/cloud/imds/oracle/imds_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
// Teleport
// Copyright (C) 2025 Gravitational, Inc.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.

package oracle

import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"

"github.com/stretchr/testify/assert"
)

const defaultInstanceID = "ocid1.instance.oc1.phx.12345678"

func mockIMDSServer(t *testing.T, status int, data any) string {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(status)
if data == nil {
return
}
body, err := json.Marshal(data)
if !assert.NoError(t, err) {
return
}
w.Write(body)
}))
t.Cleanup(server.Close)
return server.URL
}

func TestIsAvailable(t *testing.T) {
t.Parallel()
tests := []struct {
name string
imdsStatus int
imdsResponse any
assert assert.BoolAssertionFunc
}{
{
name: "ok",
imdsStatus: http.StatusOK,
imdsResponse: instance{
ID: defaultInstanceID,
},
assert: assert.True,
},
{
name: "not available",
imdsStatus: http.StatusNotFound,
assert: assert.False,
},
{
name: "not on oci",
imdsStatus: http.StatusOK,
imdsResponse: instance{
ID: "notavalidocid",
},
assert: assert.False,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
imdsURL := mockIMDSServer(t, tc.imdsStatus, tc.imdsResponse)
clt := &InstanceMetadataClient{baseIMDSAddr: imdsURL}
tc.assert(t, clt.IsAvailable(context.Background()))
})
}

t.Run("don't hang on connection", func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond)
t.Cleanup(cancel)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
select {
case <-time.After(10 * time.Second):
data, err := json.Marshal(instance{
ID: defaultInstanceID,
})
if !assert.NoError(t, err) {
return
}
w.Write(data)
case <-ctx.Done():
}
}))
t.Cleanup(server.Close)

clt := &InstanceMetadataClient{baseIMDSAddr: server.URL}
assert.False(t, clt.IsAvailable(ctx))
})
}

func TestGetTags(t *testing.T) {
t.Parallel()
serverURL := mockIMDSServer(t, http.StatusOK, instance{
DefinedTags: map[string]map[string]string{
"my-namespace": {
"foo": "bar",
},
},
FreeformTags: map[string]string{
"baz": "quux",
},
})
clt := &InstanceMetadataClient{baseIMDSAddr: serverURL}
tags, err := clt.GetTags(context.Background())
assert.NoError(t, err)
assert.Equal(t, map[string]string{
"my-namespace/foo": "bar",
"baz": "quux",
}, tags)

}
54 changes: 25 additions & 29 deletions lib/labels/cloud.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,14 +45,18 @@ const (
// GCPLabelNamespace is used as the namespace prefix for any labels imported
// from GCP.
GCPLabelNamespace = "gcp"
// OracleLabelNamespace is used as the namespace prefix for any labels
// imported from Oracle Cloud.
OracleLabelNamespace = "oracle"
// labelUpdatePeriod is the period for updating cloud labels.
labelUpdatePeriod = time.Hour
)

const (
awsErrorMessage = "Could not fetch EC2 instance's tags, please ensure 'allow instance tags in metadata' is enabled on the instance."
azureErrorMessage = "Could not fetch Azure instance's tags."
gcpErrorMessage = "Could not fetch GCP instance's labels, please ensure instance's service principal has read access to instances."
awsErrorMessage = "Could not fetch EC2 instance's tags, please ensure 'allow instance tags in metadata' is enabled on the instance."
azureErrorMessage = "Could not fetch Azure instance's tags."
gcpErrorMessage = "Could not fetch GCP instance's labels, please ensure instance's service principal has read access to instances."
oracleErrorMessage = "Could not fetch Oracle Cloud instance's tags."
)

// CloudConfig is the configuration for a cloud label service.
Expand All @@ -68,6 +72,22 @@ func (conf *CloudConfig) checkAndSetDefaults() error {
if conf.Client == nil {
return trace.BadParameter("missing parameter: Client")
}
switch conf.Client.GetType() {
case types.InstanceMetadataTypeEC2:
conf.namespace = AWSLabelNamespace
conf.instanceMetadataHint = awsErrorMessage
case types.InstanceMetadataTypeAzure:
conf.namespace = AzureLabelNamespace
conf.instanceMetadataHint = azureErrorMessage
case types.InstanceMetadataTypeGCP:
conf.namespace = GCPLabelNamespace
conf.instanceMetadataHint = gcpErrorMessage
case types.InstanceMetadataTypeOracle:
conf.namespace = OracleLabelNamespace
conf.instanceMetadataHint = oracleErrorMessage
default:
return trace.BadParameter("invalid client type: %v", conf.Client.GetType())
}

conf.Clock = cmp.Or(conf.Clock, clockwork.NewRealClock())
conf.Log = cmp.Or(conf.Log, slog.With(teleport.ComponentKey, "cloudlabels"))
Expand All @@ -93,35 +113,11 @@ func NewCloudImporter(ctx context.Context, c *CloudConfig) (*CloudImporter, erro
if err := c.checkAndSetDefaults(); err != nil {
return nil, trace.Wrap(err)
}
cloudImporter := &CloudImporter{
return &CloudImporter{
CloudConfig: c,
labels: make(map[string]string),
closeCh: make(chan struct{}),
}
switch c.Client.GetType() {
case types.InstanceMetadataTypeEC2:
cloudImporter.initEC2()
case types.InstanceMetadataTypeAzure:
cloudImporter.initAzure()
case types.InstanceMetadataTypeGCP:
cloudImporter.initGCP()
}
return cloudImporter, nil
}

func (l *CloudImporter) initEC2() {
l.namespace = AWSLabelNamespace
l.instanceMetadataHint = awsErrorMessage
}

func (l *CloudImporter) initAzure() {
l.namespace = AzureLabelNamespace
l.instanceMetadataHint = azureErrorMessage
}

func (l *CloudImporter) initGCP() {
l.namespace = GCPLabelNamespace
l.instanceMetadataHint = gcpErrorMessage
}, nil
}

// Get returns the list of updated cloud labels.
Expand Down
2 changes: 1 addition & 1 deletion lib/labels/cloud_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ func (m *mockIMDSClient) IsAvailable(ctx context.Context) bool {
}

func (m *mockIMDSClient) GetType() types.InstanceMetadataType {
return "mock"
return types.InstanceMetadataTypeEC2
}

func (m *mockIMDSClient) GetTags(ctx context.Context) (map[string]string, error) {
Expand Down
Loading

0 comments on commit a113591

Please sign in to comment.