Skip to content

Commit

Permalink
Import Oracle Cloud tags
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 24, 2025
1 parent da1c7e5 commit e59abb4
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 @@ -42,6 +42,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"
)

// Clients provides interface for obtaining cloud provider clients.
Expand Down Expand Up @@ -547,6 +548,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 e59abb4

Please sign in to comment.