Skip to content

Commit

Permalink
Merge pull request #35 from OpenCHAMI/34-dev-add-standardised-instanc…
Browse files Browse the repository at this point in the history
…e-data-to-the-cloud-init-payloads

feat: add cluster data retrieval and enhance ID mapping functionality
  • Loading branch information
davidallendj authored Dec 11, 2024
2 parents aacf917 + 00f322c commit 70a27d7
Show file tree
Hide file tree
Showing 12 changed files with 920 additions and 169 deletions.
12 changes: 7 additions & 5 deletions cmd/cloud-init-server/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,14 +17,16 @@ import (
)

type CiHandler struct {
store ciStore
sm smdclient.SMDClientInterface
store ciStore
sm smdclient.SMDClientInterface
clusterName string
}

func NewCiHandler(s ciStore, c smdclient.SMDClientInterface) *CiHandler {
func NewCiHandler(s ciStore, c smdclient.SMDClientInterface, clusterName string) *CiHandler {
return &CiHandler{
store: s,
sm: c,
store: s,
sm: c,
clusterName: clusterName,
}
}

Expand Down
145 changes: 145 additions & 0 deletions cmd/cloud-init-server/instance_data.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
package main

import (
"crypto/rand"
"encoding/json"
"fmt"
"net/http"
"strings"

"github.com/OpenCHAMI/cloud-init/internal/smdclient"
"github.com/OpenCHAMI/cloud-init/pkg/citypes"
"github.com/rs/zerolog/log"
)

// The instance-data endpoint should return information about the instance.
// For information about the standard items, check the docs at https://cloudinit.readthedocs.io/en/latest/explanation/instancedata.html#standardised-instance-data-json-v1-keys
// It should be accessible at /latest/instance-data as yaml and /latest/instance-data/json as json.
// /latest/instance-data should obey Accept headers and return the appropriate format as well.
// The instance-data endpoint should return a 404 if the instance data is not available.

/* The payload we're targeting here is:
#cloud-config
instance-data:
v1:
cloud_name: ""
availability_zone: ""
instance_id: ""
instance_type: ""
local_hostname: ""
region: ""
hostname: ""
local_ipv4: null
cloud_provider: ""
public_keys:
- ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQD...
vendor_data:
version: 1.0
groups:
- group_name
key: value
key2: value2
*/

func InstanceDataHandler(smd smdclient.SMDClientInterface, clusterName string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// ip should be the ip address that the request originated from. Check the headers to see if the request has been forwarded and the remote IP is preserved
// Check for the first ip in the X-Forwarded-For header if it exists
var ip string
if r.Header.Get("X-Forwarded-For") != "" {
// If it exists, use that
ip = strings.Split(r.Header.Get("X-Forwarded-For"), ",")[0]
} else {
portIndex := strings.LastIndex(r.RemoteAddr, ":")
if portIndex > 0 {
ip = r.RemoteAddr[:portIndex]
} else {
ip = r.RemoteAddr
}
}
// Get the component information from the SMD client
id, err := smd.IDfromIP(ip)
if err != nil {
log.Print(err)
w.WriteHeader(http.StatusUnprocessableEntity)
return
} else {
log.Printf("xname %s with ip %s found\n", id, ip)
}
smdComponent, err := smd.ComponentInformation(id)
if err != nil {
// If the component information is not available, return a 404
http.Error(w, "Node not found in SMD. Instance-data not available", http.StatusNotFound)
return
}
groups, err := smd.GroupMembership(id)
if err != nil {
// If the group information is not available, return an empty list
groups = []string{}
}

component := citypes.OpenCHAMIComponent{
Component: smdComponent,
ClusterName: clusterName,
}

cluster_data := generateInstanceData(component, groups)
// Return the instance data as json
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(cluster_data)
}
}

func generateInstanceData(component citypes.OpenCHAMIComponent, groups []string) citypes.ClusterData {
cluster_data := citypes.ClusterData{}
cluster_data.InstanceData.V1.CloudName = "OpenCHAMI"
cluster_data.InstanceData.V1.AvailabilityZone = "lanl-yellow"
cluster_data.InstanceData.V1.InstanceID = generateInstanceId()
cluster_data.InstanceData.V1.InstanceType = "t2.micro"
cluster_data.InstanceData.V1.LocalHostname = genHostname(component.ClusterName, component)
cluster_data.InstanceData.V1.Region = "us-west"
cluster_data.InstanceData.V1.Hostname = genHostname(component.ClusterName, component)
cluster_data.InstanceData.V1.LocalIPv4 = component.IP
cluster_data.InstanceData.V1.CloudProvider = "OpenCHAMI"
cluster_data.InstanceData.V1.PublicKeys = []string{"ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQD..."}
cluster_data.InstanceData.V1.VendorData.Version = "1.0"
for _, group := range groups {
cluster_data.InstanceData.V1.VendorData.Groups = append(cluster_data.InstanceData.V1.VendorData.Groups, struct {
GroupName string "json:\"group_name\""
Metadata map[string]string "json:\"metadata,omitempty\""
}{GroupName: group})
}
return cluster_data
}

func genHostname(clusterName string, comp citypes.OpenCHAMIComponent) string {
// in the future, we might want to map the hostname to an xname or something else.
switch comp.Role {
case "compute":
nid, _ := comp.NID.Int64()
return fmt.Sprintf("%.2s%04d", clusterName, nid)
case "io":
nid, _ := comp.NID.Int64()
return fmt.Sprintf("%.2s-io%02d", clusterName, nid)
case "front_end":
nid, _ := comp.NID.Int64()
return fmt.Sprintf("%.2s-fe%02d", clusterName, nid)
default:
nid, _ := comp.NID.Int64()
return fmt.Sprintf("%.2s%04d", clusterName, nid)
}
}

func generateInstanceId() string {
// in the future, we might want to map the instance-id to an xname or something else.
return generateUniqueID("i")

}

func generateUniqueID(prefix string) string {
b := make([]byte, 4)
rand.Read(b)
return fmt.Sprintf("%s-%x", prefix, b)
}
122 changes: 122 additions & 0 deletions cmd/cloud-init-server/instance_data_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
package main

import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"testing"

base "github.com/Cray-HPE/hms-base"
"github.com/OpenCHAMI/cloud-init/pkg/citypes"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
)

type MockSMDClient struct {
mock.Mock
}

func (m *MockSMDClient) ComponentInformation(id string) (base.Component, error) {
args := m.Called(id)
return args.Get(0).(base.Component), args.Error(1)
}

func (m *MockSMDClient) GroupMembership(id string) ([]string, error) {
args := m.Called(id)
return args.Get(0).([]string), args.Error(1)
}

func (m *MockSMDClient) IDfromMAC(mac string) (string, error) {
args := m.Called(mac)
return args.String(0), args.Error(1)
}

func (m *MockSMDClient) IDfromIP(ipaddr string) (string, error) {
args := m.Called(ipaddr)
return args.String(0), args.Error(1)
}

func (m *MockSMDClient) IPfromID(id string) (string, error) {
args := m.Called(id)
return args.String(0), args.Error(1)
}

func (m *MockSMDClient) PopulateNodes() {
m.Called()
}

func TestInstanceDataHandler(t *testing.T) {
mockSMDClient := new(MockSMDClient)
clusterName := "test-cluster"

component := base.Component{
ID: "test-id",
Role: "compute",
NID: json.Number(fmt.Sprint(1)),
}

mockSMDClient.On("ComponentInformation", "192.168.1.1").Return(component, nil)
mockSMDClient.On("GroupMembership", "192.168.1.1").Return([]string{"group1", "group2"}, nil)

handler := InstanceDataHandler(mockSMDClient, clusterName)

t.Run("returns instance data as json", func(t *testing.T) {
req := httptest.NewRequest("GET", "/latest/instance-data", nil)
req.RemoteAddr = "192.168.1.1"
w := httptest.NewRecorder()

handler(w, req)

resp := w.Result()
defer resp.Body.Close()

assert.Equal(t, http.StatusOK, resp.StatusCode)
assert.Equal(t, "application/json", resp.Header.Get("Content-Type"))

var responseData citypes.ClusterData
err := json.NewDecoder(resp.Body).Decode(&responseData)
assert.NoError(t, err)
assert.Equal(t, "OpenCHAMI", responseData.InstanceData.V1.CloudName)
assert.Equal(t, "lanl-yellow", responseData.InstanceData.V1.AvailabilityZone)
assert.Equal(t, "t2.micro", responseData.InstanceData.V1.InstanceType)
assert.Equal(t, "us-west", responseData.InstanceData.V1.Region)
assert.Equal(t, "OpenCHAMI", responseData.InstanceData.V1.CloudProvider)
assert.Equal(t, []string{"ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQD..."}, responseData.InstanceData.V1.PublicKeys)
assert.Equal(t, "1.0", responseData.InstanceData.V1.VendorData.Version)
assert.Len(t, responseData.InstanceData.V1.VendorData.Groups, 2)
})

t.Run("returns 404 if component information is not available", func(t *testing.T) {
mockSMDClient.On("ComponentInformation", "192.168.1.2").Return(base.Component{}, assert.AnError)

req := httptest.NewRequest("GET", "/latest/instance-data", nil)
req.RemoteAddr = "192.168.1.2"
w := httptest.NewRecorder()

handler(w, req)

resp := w.Result()
defer resp.Body.Close()

assert.Equal(t, http.StatusNotFound, resp.StatusCode)
assert.Equal(t, "Node not found in SMD. Instance-data not available\n", w.Body.String())
})

t.Run("uses X-Forwarded-For header if present", func(t *testing.T) {
req := httptest.NewRequest("GET", "/latest/instance-data", nil)
req.Header.Set("X-Forwarded-For", "192.168.1.3")
w := httptest.NewRecorder()

mockSMDClient.On("ComponentInformation", "192.168.1.3").Return(component, nil)
mockSMDClient.On("GroupMembership", "192.168.1.3").Return([]string{"group1", "group2"}, nil)

handler(w, req)

resp := w.Result()
defer resp.Body.Close()

assert.Equal(t, http.StatusOK, resp.StatusCode)
assert.Equal(t, "application/json", resp.Header.Get("Content-Type"))
})
}
16 changes: 12 additions & 4 deletions cmd/cloud-init-server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,14 +29,16 @@ var (
accessToken = ""
certPath = ""
store ciStore
clusterName string
)

func main() {
flag.StringVar(&ciEndpoint, "listen", ciEndpoint, "Server IP and port for cloud-init-server to listen on")
flag.StringVar(&tokenEndpoint, "token-url", tokenEndpoint, "OIDC server URL (endpoint) to fetch new tokens from (for SMD access)")
flag.StringVar(&smdEndpoint, "smd-url", smdEndpoint, "http IP/url and port for running SMD")
flag.StringVar(&smdEndpoint, "smd-url", smdEndpoint, "Server host and port only for running SMD (do not include /hsm/v2)")
flag.StringVar(&jwksUrl, "jwks-url", jwksUrl, "JWT keyserver URL, required to enable secure route")
flag.StringVar(&accessToken, "access-token", accessToken, "encoded JWT access token")
flag.StringVar(&clusterName, "cluster-name", clusterName, "Name of the cluster")
flag.StringVar(&certPath, "cacert", certPath, "Path to CA cert. (defaults to system CAs)")
flag.BoolVar(&insecure, "insecure", insecure, "Set to bypass TLS verification for requests")
flag.Parse()
Expand Down Expand Up @@ -82,20 +84,25 @@ func main() {
fakeSm.Summary()
sm = fakeSm
} else {
sm = smdclient.NewSMDClient(smdEndpoint, tokenEndpoint, accessToken, certPath, insecure)
var err error
sm, err = smdclient.NewSMDClient(smdEndpoint, tokenEndpoint, accessToken, certPath, insecure)
if err != nil {
// Could not create SMD client, so exit with error saying why
log.Fatal().Err(err)
}
}

// Unsecured datastore and router
store := memstore.NewMemStore()
ciHandler := NewCiHandler(store, sm)
ciHandler := NewCiHandler(store, sm, clusterName)
router_unsec := chi.NewRouter()
initCiRouter(router_unsec, ciHandler)
router.Mount("/cloud-init", router_unsec)

if secureRouteEnable {
// Secured datastore and router
store_sec := memstore.NewMemStore()
ciHandler_sec := NewCiHandler(store_sec, sm)
ciHandler_sec := NewCiHandler(store_sec, sm, clusterName)
router_sec := chi.NewRouter()
router_sec.Use(
jwtauth.Verifier(keyset),
Expand All @@ -116,6 +123,7 @@ func initCiRouter(router chi.Router, handler *CiHandler) {
router.Get("/user-data", handler.GetDataByIP(UserData))
router.Get("/meta-data", handler.GetDataByIP(MetaData))
router.Get("/vendor-data", handler.GetDataByIP(VendorData))
router.Get("/instance-data", InstanceDataHandler(handler.sm, handler.clusterName))
router.Get("/{id}", GetEntry(handler.store, handler.sm))
router.Get("/{id}/user-data", handler.GetDataByMAC(UserData))
router.Put("/{id}/user-data", handler.UpdateUserEntry)
Expand Down
9 changes: 5 additions & 4 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,11 @@ require (
github.com/OpenCHAMI/smd/v2 v2.17.7
github.com/go-chi/chi/v5 v5.1.0
github.com/go-chi/render v1.0.3
github.com/lestrrat-go/jwx/v2 v2.1.2
github.com/lestrrat-go/jwx/v2 v2.1.3
github.com/openchami/chi-middleware/auth v0.0.0-20240812224658-b16b83c70700
github.com/openchami/chi-middleware/log v0.0.0-20240812224658-b16b83c70700
github.com/rs/zerolog v1.33.0
github.com/stretchr/testify v1.9.0
github.com/stretchr/testify v1.10.0
)

require (
Expand All @@ -31,13 +31,14 @@ require (
github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/segmentio/asm v1.2.0 // indirect
github.com/stretchr/objx v0.5.2 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

require (
github.com/Cray-HPE/hms-base v1.15.1
github.com/Cray-HPE/hms-certs v1.4.0 // indirect
github.com/Cray-HPE/hms-securestorage v1.13.0 // indirect
github.com/Cray-HPE/hms-certs v1.5.0 // indirect
github.com/Cray-HPE/hms-securestorage v1.14.0 // indirect
github.com/fsnotify/fsnotify v1.8.0 // indirect
github.com/hashicorp/errwrap v1.1.0 // indirect
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
Expand Down
Loading

0 comments on commit 70a27d7

Please sign in to comment.