-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #35 from OpenCHAMI/34-dev-add-standardised-instanc…
…e-data-to-the-cloud-init-payloads feat: add cluster data retrieval and enhance ID mapping functionality
- Loading branch information
Showing
12 changed files
with
920 additions
and
169 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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")) | ||
}) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.