diff --git a/cmd/cloudinitsave/cloudinitsave.go b/cmd/cloudinitsave/cloudinitsave.go index aecb2913..c6ccaf4f 100644 --- a/cmd/cloudinitsave/cloudinitsave.go +++ b/cmd/cloudinitsave/cloudinitsave.go @@ -32,6 +32,7 @@ import ( "github.com/rancher/os/config/cloudinit/datasource/configdrive" "github.com/rancher/os/config/cloudinit/datasource/file" "github.com/rancher/os/config/cloudinit/datasource/metadata/aliyun" + "github.com/rancher/os/config/cloudinit/datasource/metadata/azure" "github.com/rancher/os/config/cloudinit/datasource/metadata/cloudstack" "github.com/rancher/os/config/cloudinit/datasource/metadata/digitalocean" "github.com/rancher/os/config/cloudinit/datasource/metadata/ec2" @@ -268,6 +269,8 @@ func getDatasources(datasources []string) []datasource.Datasource { } case "aliyun": dss = append(dss, aliyun.NewDatasource(root)) + case "azure": + dss = append(dss, azure.NewDatasource(root)) } } diff --git a/config/cloudinit/datasource/metadata/azure/metadata.go b/config/cloudinit/datasource/metadata/azure/metadata.go new file mode 100644 index 00000000..1d14b9aa --- /dev/null +++ b/config/cloudinit/datasource/metadata/azure/metadata.go @@ -0,0 +1,155 @@ +package azure + +import ( + "encoding/json" + "net" + "net/http" + "strconv" + + "github.com/rancher/os/config/cloudinit/config" + "github.com/rancher/os/config/cloudinit/datasource" + "github.com/rancher/os/config/cloudinit/datasource/metadata" +) + +const ( + metadataHeader = "true" + metadataVersion = "2019-02-01" + metadataEndpoint = "http://169.254.169.254/metadata/" +) + +type MetadataService struct { + metadata.Service +} + +func NewDatasource(root string) *MetadataService { + if root == "" { + root = metadataEndpoint + } + return &MetadataService{metadata.NewDatasource(root, "instance?api-version="+metadataVersion+"&format=json", "", "", assembleHeader())} +} + +func (ms MetadataService) ConfigRoot() string { + return ms.Root + "instance" +} + +func (ms MetadataService) AvailabilityChanges() bool { + // TODO: if it can't find the network, maybe we can start it? + return false +} + +func (ms MetadataService) FetchMetadata() (datasource.Metadata, error) { + d, err := ms.FetchData(ms.MetadataURL()) + if err != nil { + return datasource.Metadata{}, err + } + type Plan struct { + Name string `json:"name,omitempty"` + Product string `json:"product,omitempty"` + Publisher string `json:"publisher,omitempty"` + } + type PublicKey struct { + KeyData string `json:"keyData,omitempty"` + Path string `json:"path,omitempty"` + } + type Compute struct { + AZEnvironment string `json:"azEnvironment,omitempty"` + CustomData string `json:"customData,omitempty"` + Location string `json:"location,omitempty"` + Name string `json:"name,omitempty"` + Offer string `json:"offer,omitempty"` + OSType string `json:"osType,omitempty"` + PlacementGroupID string `json:"placementGroupId,omitempty"` + Plan Plan `json:"plan,omitempty"` + PlatformFaultDomain string `json:"platformFaultDomain,omitempty"` + PlatformUpdateDomain string `json:"platformUpdateDomain,omitempty"` + Provider string `json:"provider,omitempty"` + PublicKeys []PublicKey `json:"publicKeys,omitempty"` + Publisher string `json:"publisher,omitempty"` + ResourceGroupName string `json:"resourceGroupName,omitempty"` + SKU string `json:"sku,omitempty"` + SubscriptionID string `json:"subscriptionId,omitempty"` + Tags string `json:"tags,omitempty"` + Version string `json:"version,omitempty"` + VMID string `json:"vmId,omitempty"` + VMScaleSetName string `json:"vmScaleSetName,omitempty"` + VMSize string `json:"vmSize,omitempty"` + Zone string `json:"zone,omitempty"` + } + type IPAddress struct { + PrivateIPAddress string `json:"privateIpAddress,omitempty"` + PublicIPAddress string `json:"publicIpAddress,omitempty"` + } + type Subnet struct { + Address string `json:"address,omitempty"` + Prefix string `json:"prefix,omitempty"` + } + type IPV4 struct { + IPAddress []IPAddress `json:"ipAddress,omitempty"` + Subnet []Subnet `json:"subnet,omitempty"` + } + type IPV6 struct { + IPAddress []IPAddress `json:"ipAddress,omitempty"` + } + type Interface struct { + IPV4 IPV4 `json:"ipv4,omitempty"` + IPV6 IPV6 `json:"ipv6,omitempty"` + MacAddress string `json:"macAddress,omitempty"` + } + type Network struct { + Interface []Interface `json:"interface,omitempty"` + } + type Instance struct { + Compute Compute `json:"compute,omitempty"` + Network Network `json:"network,omitempty"` + } + instance := &Instance{} + if err := json.Unmarshal(d, instance); err != nil { + return datasource.Metadata{}, err + } + m := datasource.Metadata{ + Hostname: instance.Compute.Name, + SSHPublicKeys: make(map[string]string, 0), + } + if len(instance.Network.Interface) > 0 { + if len(instance.Network.Interface[0].IPV4.IPAddress) > 0 { + m.PublicIPv4 = net.ParseIP(instance.Network.Interface[0].IPV4.IPAddress[0].PublicIPAddress) + m.PrivateIPv4 = net.ParseIP(instance.Network.Interface[0].IPV4.IPAddress[0].PrivateIPAddress) + } + if len(instance.Network.Interface[0].IPV6.IPAddress) > 0 { + m.PublicIPv6 = net.ParseIP(instance.Network.Interface[0].IPV6.IPAddress[0].PublicIPAddress) + m.PrivateIPv6 = net.ParseIP(instance.Network.Interface[0].IPV6.IPAddress[0].PrivateIPAddress) + } + } + for i, k := range instance.Compute.PublicKeys { + m.SSHPublicKeys[strconv.Itoa(i)] = k.KeyData + } + return m, nil +} + +func (ms MetadataService) FetchUserdata() ([]byte, error) { + d, err := ms.FetchData(ms.UserdataURL()) + if err != nil { + return []byte{}, err + } + return config.DecodeBase64Content(string(d)) +} + +func (ms MetadataService) Type() string { + return "azure-metadata-service" +} + +func (ms MetadataService) MetadataURL() string { + // metadata: http://169.254.169.254/metadata/instance?api-version=2019-02-01&format=json + return ms.Root + "instance?api-version=" + metadataVersion + "&format=json" +} + +func (ms MetadataService) UserdataURL() string { + // userdata: http://169.254.169.254/metadata/instance/compute/customData?api-version=2019-02-01&format=text + return ms.Root + "instance/compute/customData?api-version=" + metadataVersion + "&format=text" +} + +func assembleHeader() http.Header { + h := http.Header{} + h.Add("Metadata", metadataHeader) + return h +} diff --git a/config/cloudinit/datasource/metadata/azure/metadata_test.go b/config/cloudinit/datasource/metadata/azure/metadata_test.go new file mode 100644 index 00000000..e9c5f143 --- /dev/null +++ b/config/cloudinit/datasource/metadata/azure/metadata_test.go @@ -0,0 +1,166 @@ +package azure + +import ( + "bytes" + "net" + "reflect" + "testing" + + "github.com/rancher/os/config/cloudinit/datasource" + "github.com/rancher/os/config/cloudinit/datasource/metadata" + "github.com/rancher/os/config/cloudinit/datasource/metadata/test" +) + +func TestType(t *testing.T) { + want := "azure-metadata-service" + if kind := (MetadataService{}).Type(); kind != want { + t.Fatalf("bad type: want %q, got %q", want, kind) + } +} + +func TestMetadataURL(t *testing.T) { + want := "http://169.254.169.254/metadata/instance?api-version=2019-02-01&format=json" + ms := NewDatasource("") + if url := ms.MetadataURL(); url != want { + t.Fatalf("bad url: want %q, got %q", want, url) + } +} + +func TestUserdataURL(t *testing.T) { + want := "http://169.254.169.254/metadata/instance/compute/customData?api-version=2019-02-01&format=text" + ms := NewDatasource("") + if url := ms.UserdataURL(); url != want { + t.Fatalf("bad url: want %q, got %q", want, url) + } +} + +func TestFetchMetadata(t *testing.T) { + for _, tt := range []struct { + root string + metadataPath string + resources map[string]string + expect datasource.Metadata + clientErr error + expectErr error + }{ + { + root: "/metadata/", + resources: map[string]string{ + "/metadata/instance?api-version=2019-02-01&format=json": `{ + "compute": { + "azEnvironment": "AZUREPUBLICCLOUD", + "location": "westus", + "name": "rancheros", + "offer": "", + "osType": "Linux", + "placementGroupId": "", + "plan": { + "name": "", + "product": "", + "publisher": "" + }, + "platformFaultDomain": "0", + "platformUpdateDomain": "0", + "provider": "Microsoft.Compute", + "publicKeys": [{ + "keyData":"publickey1", + "path": "/home/rancher/.ssh/authorized_keys" + }], + "publisher": "", + "resourceGroupName": "rancheros", + "sku": "Enterprise", + "subscriptionId": "xxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxx", + "tags": "", + "version": "", + "vmId": "453945c8-3923-4366-b2d3-ea4c80e9b70e", + "vmScaleSetName": "", + "vmSize": "Standard_A1", + "zone": "" + }, + "network": { + "interface": [{ + "ipv4": { + "ipAddress": [{ + "privateIpAddress": "192.168.1.2", + "publicIpAddress": "5.6.7.8" + }], + "subnet": [{ + "address": "192.168.1.0", + "prefix": "24" + }] + }, + "ipv6": { + "ipAddress": [] + }, + "macAddress": "002248020E1E" + }] + } +} +`, + }, + expect: datasource.Metadata{ + PrivateIPv4: net.ParseIP("192.168.1.2"), + PublicIPv4: net.ParseIP("5.6.7.8"), + SSHPublicKeys: map[string]string{ + "0": "publickey1", + }, + Hostname: "rancheros", + }, + }, + } { + service := &MetadataService{ + Service: metadata.Service{ + Root: tt.root, + Client: &test.HTTPClient{Resources: tt.resources, Err: tt.clientErr}, + }, + } + metadata, err := service.FetchMetadata() + if Error(err) != Error(tt.expectErr) { + t.Fatalf("bad error (%q): \nwant %#v,\n got %#v", tt.resources, tt.expectErr, err) + } + if !reflect.DeepEqual(tt.expect, metadata) { + t.Fatalf("bad fetch (%q): \nwant %#v,\n got %#v", tt.resources, tt.expect, metadata) + } + } +} + +func TestFetchUserdata(t *testing.T) { + for _, tt := range []struct { + root string + userdataPath string + resources map[string]string + userdata []byte + clientErr error + expectErr error + }{ + { + root: "/metadata/", + resources: map[string]string{ + "/metadata/instance/compute/customData?api-version=2019-02-01&format=text": "I2Nsb3VkLWNvbmZpZwpob3N0bmFtZTogcmFuY2hlcjE=", + }, + userdata: []byte(`#cloud-config +hostname: rancher1`), + }, + } { + service := &MetadataService{ + Service: metadata.Service{ + Root: tt.root, + Client: &test.HTTPClient{Resources: tt.resources, Err: tt.clientErr}, + }, + } + data, err := service.FetchUserdata() + if Error(err) != Error(tt.expectErr) { + t.Fatalf("bad error (%q): want %q, got %q", tt.resources, tt.expectErr, err) + } + if !bytes.Equal(data, tt.userdata) { + t.Fatalf("bad userdata (%q): want %q, got %q", tt.resources, tt.userdata, data) + } + } +} + +func Error(err error) string { + if err != nil { + return err.Error() + } + return "" +}