// Copyright (C) The Arvados Authors. All rights reserved.
//
// SPDX-License-Identifier: AGPL-3.0

package azure

import (
	"context"
	"encoding/base64"
	"encoding/json"
	"errors"
	"fmt"
	"net/http"
	"regexp"
	"strconv"
	"strings"
	"sync"
	"time"

	"git.arvados.org/arvados.git/lib/cloud"
	"git.arvados.org/arvados.git/sdk/go/arvados"
	"github.com/Azure/azure-sdk-for-go/services/compute/mgmt/2019-07-01/compute"
	"github.com/Azure/azure-sdk-for-go/services/network/mgmt/2018-06-01/network"
	storageacct "github.com/Azure/azure-sdk-for-go/services/storage/mgmt/2018-02-01/storage"
	"github.com/Azure/azure-sdk-for-go/storage"
	"github.com/Azure/go-autorest/autorest"
	"github.com/Azure/go-autorest/autorest/azure"
	"github.com/Azure/go-autorest/autorest/azure/auth"
	"github.com/Azure/go-autorest/autorest/to"
	"github.com/jmcvetta/randutil"
	"github.com/prometheus/client_golang/prometheus"
	"github.com/sirupsen/logrus"
	"golang.org/x/crypto/ssh"
)

// Driver is the azure implementation of the cloud.Driver interface.
var Driver = cloud.DriverFunc(newAzureInstanceSet)

type azureInstanceSetConfig struct {
	SubscriptionID                 string
	ClientID                       string
	ClientSecret                   string
	TenantID                       string
	CloudEnvironment               string
	ResourceGroup                  string
	ImageResourceGroup             string
	Location                       string
	Network                        string
	NetworkResourceGroup           string
	Subnet                         string
	StorageAccount                 string
	BlobContainer                  string
	SharedImageGalleryName         string
	SharedImageGalleryImageVersion string
	DeleteDanglingResourcesAfter   arvados.Duration
	AdminUsername                  string
}

type containerWrapper interface {
	GetBlobReference(name string) *storage.Blob
	ListBlobs(params storage.ListBlobsParameters) (storage.BlobListResponse, error)
}

type virtualMachinesClientWrapper interface {
	createOrUpdate(ctx context.Context,
		resourceGroupName string,
		VMName string,
		parameters compute.VirtualMachine) (result compute.VirtualMachine, err error)
	delete(ctx context.Context, resourceGroupName string, VMName string) (result *http.Response, err error)
	listComplete(ctx context.Context, resourceGroupName string) (result compute.VirtualMachineListResultIterator, err error)
}

type virtualMachinesClientImpl struct {
	inner compute.VirtualMachinesClient
}

func (cl *virtualMachinesClientImpl) createOrUpdate(ctx context.Context,
	resourceGroupName string,
	VMName string,
	parameters compute.VirtualMachine) (result compute.VirtualMachine, err error) {

	future, err := cl.inner.CreateOrUpdate(ctx, resourceGroupName, VMName, parameters)
	if err != nil {
		return compute.VirtualMachine{}, wrapAzureError(err)
	}
	future.WaitForCompletionRef(ctx, cl.inner.Client)
	r, err := future.Result(cl.inner)
	return r, wrapAzureError(err)
}

func (cl *virtualMachinesClientImpl) delete(ctx context.Context, resourceGroupName string, VMName string) (result *http.Response, err error) {
	future, err := cl.inner.Delete(ctx, resourceGroupName, VMName)
	if err != nil {
		return nil, wrapAzureError(err)
	}
	err = future.WaitForCompletionRef(ctx, cl.inner.Client)
	return future.Response(), wrapAzureError(err)
}

func (cl *virtualMachinesClientImpl) listComplete(ctx context.Context, resourceGroupName string) (result compute.VirtualMachineListResultIterator, err error) {
	r, err := cl.inner.ListComplete(ctx, resourceGroupName)
	return r, wrapAzureError(err)
}

type interfacesClientWrapper interface {
	createOrUpdate(ctx context.Context,
		resourceGroupName string,
		networkInterfaceName string,
		parameters network.Interface) (result network.Interface, err error)
	delete(ctx context.Context, resourceGroupName string, networkInterfaceName string) (result *http.Response, err error)
	listComplete(ctx context.Context, resourceGroupName string) (result network.InterfaceListResultIterator, err error)
}

type interfacesClientImpl struct {
	inner network.InterfacesClient
}

func (cl *interfacesClientImpl) delete(ctx context.Context, resourceGroupName string, VMName string) (result *http.Response, err error) {
	future, err := cl.inner.Delete(ctx, resourceGroupName, VMName)
	if err != nil {
		return nil, wrapAzureError(err)
	}
	err = future.WaitForCompletionRef(ctx, cl.inner.Client)
	return future.Response(), wrapAzureError(err)
}

func (cl *interfacesClientImpl) createOrUpdate(ctx context.Context,
	resourceGroupName string,
	networkInterfaceName string,
	parameters network.Interface) (result network.Interface, err error) {

	future, err := cl.inner.CreateOrUpdate(ctx, resourceGroupName, networkInterfaceName, parameters)
	if err != nil {
		return network.Interface{}, wrapAzureError(err)
	}
	future.WaitForCompletionRef(ctx, cl.inner.Client)
	r, err := future.Result(cl.inner)
	return r, wrapAzureError(err)
}

func (cl *interfacesClientImpl) listComplete(ctx context.Context, resourceGroupName string) (result network.InterfaceListResultIterator, err error) {
	r, err := cl.inner.ListComplete(ctx, resourceGroupName)
	return r, wrapAzureError(err)
}

type disksClientWrapper interface {
	listByResourceGroup(ctx context.Context, resourceGroupName string) (result compute.DiskListPage, err error)
	delete(ctx context.Context, resourceGroupName string, diskName string) (result compute.DisksDeleteFuture, err error)
}

type disksClientImpl struct {
	inner compute.DisksClient
}

func (cl *disksClientImpl) listByResourceGroup(ctx context.Context, resourceGroupName string) (result compute.DiskListPage, err error) {
	r, err := cl.inner.ListByResourceGroup(ctx, resourceGroupName)
	return r, wrapAzureError(err)
}

func (cl *disksClientImpl) delete(ctx context.Context, resourceGroupName string, diskName string) (result compute.DisksDeleteFuture, err error) {
	r, err := cl.inner.Delete(ctx, resourceGroupName, diskName)
	return r, wrapAzureError(err)
}

var quotaRe = regexp.MustCompile(`(?i:exceed|quota|limit)`)

type azureRateLimitError struct {
	azure.RequestError
	firstRetry time.Time
}

func (ar *azureRateLimitError) EarliestRetry() time.Time {
	return ar.firstRetry
}

type azureQuotaError struct {
	azure.RequestError
}

func (ar *azureQuotaError) IsQuotaError() bool {
	return true
}

func wrapAzureError(err error) error {
	de, ok := err.(autorest.DetailedError)
	if !ok {
		return err
	}
	rq, ok := de.Original.(*azure.RequestError)
	if !ok {
		return err
	}
	if rq.Response == nil {
		return err
	}
	if rq.Response.StatusCode == 429 || len(rq.Response.Header["Retry-After"]) >= 1 {
		// API throttling
		ra := rq.Response.Header["Retry-After"][0]
		earliestRetry, parseErr := http.ParseTime(ra)
		if parseErr != nil {
			// Could not parse as a timestamp, must be number of seconds
			dur, parseErr := strconv.ParseInt(ra, 10, 64)
			if parseErr == nil {
				earliestRetry = time.Now().Add(time.Duration(dur) * time.Second)
			} else {
				// Couldn't make sense of retry-after,
				// so set retry to 20 seconds
				earliestRetry = time.Now().Add(20 * time.Second)
			}
		}
		return &azureRateLimitError{*rq, earliestRetry}
	}
	if rq.ServiceError == nil {
		return err
	}
	if quotaRe.FindString(rq.ServiceError.Code) != "" || quotaRe.FindString(rq.ServiceError.Message) != "" {
		return &azureQuotaError{*rq}
	}
	return err
}

type azureInstanceSet struct {
	azconfig           azureInstanceSetConfig
	vmClient           virtualMachinesClientWrapper
	netClient          interfacesClientWrapper
	disksClient        disksClientWrapper
	imageResourceGroup string
	blobcont           containerWrapper
	azureEnv           azure.Environment
	interfaces         map[string]network.Interface
	dispatcherID       string
	namePrefix         string
	ctx                context.Context
	stopFunc           context.CancelFunc
	stopWg             sync.WaitGroup
	deleteNIC          chan string
	deleteBlob         chan storage.Blob
	deleteDisk         chan compute.Disk
	logger             logrus.FieldLogger
}

func newAzureInstanceSet(config json.RawMessage, dispatcherID cloud.InstanceSetID, _ cloud.SharedResourceTags, logger logrus.FieldLogger, reg *prometheus.Registry) (prv cloud.InstanceSet, err error) {
	azcfg := azureInstanceSetConfig{}
	err = json.Unmarshal(config, &azcfg)
	if err != nil {
		return nil, err
	}

	az := azureInstanceSet{logger: logger}
	az.ctx, az.stopFunc = context.WithCancel(context.Background())
	err = az.setup(azcfg, string(dispatcherID))
	if err != nil {
		az.stopFunc()
		return nil, err
	}
	return &az, nil
}

func (az *azureInstanceSet) setup(azcfg azureInstanceSetConfig, dispatcherID string) (err error) {
	az.azconfig = azcfg
	vmClient := compute.NewVirtualMachinesClient(az.azconfig.SubscriptionID)
	netClient := network.NewInterfacesClient(az.azconfig.SubscriptionID)
	disksClient := compute.NewDisksClient(az.azconfig.SubscriptionID)
	storageAcctClient := storageacct.NewAccountsClient(az.azconfig.SubscriptionID)

	az.azureEnv, err = azure.EnvironmentFromName(az.azconfig.CloudEnvironment)
	if err != nil {
		return err
	}

	authorizer, err := auth.ClientCredentialsConfig{
		ClientID:     az.azconfig.ClientID,
		ClientSecret: az.azconfig.ClientSecret,
		TenantID:     az.azconfig.TenantID,
		Resource:     az.azureEnv.ResourceManagerEndpoint,
		AADEndpoint:  az.azureEnv.ActiveDirectoryEndpoint,
	}.Authorizer()
	if err != nil {
		return err
	}

	vmClient.Authorizer = authorizer
	netClient.Authorizer = authorizer
	disksClient.Authorizer = authorizer
	storageAcctClient.Authorizer = authorizer

	az.vmClient = &virtualMachinesClientImpl{vmClient}
	az.netClient = &interfacesClientImpl{netClient}
	az.disksClient = &disksClientImpl{disksClient}

	az.imageResourceGroup = az.azconfig.ImageResourceGroup
	if az.imageResourceGroup == "" {
		az.imageResourceGroup = az.azconfig.ResourceGroup
	}

	var client storage.Client
	if az.azconfig.StorageAccount != "" && az.azconfig.BlobContainer != "" {
		result, err := storageAcctClient.ListKeys(az.ctx, az.azconfig.ResourceGroup, az.azconfig.StorageAccount)
		if err != nil {
			az.logger.WithError(err).Warn("Couldn't get account keys")
			return err
		}

		key1 := *(*result.Keys)[0].Value
		client, err = storage.NewBasicClientOnSovereignCloud(az.azconfig.StorageAccount, key1, az.azureEnv)
		if err != nil {
			az.logger.WithError(err).Warn("Couldn't make client")
			return err
		}

		blobsvc := client.GetBlobService()
		az.blobcont = blobsvc.GetContainerReference(az.azconfig.BlobContainer)
	} else if az.azconfig.StorageAccount != "" || az.azconfig.BlobContainer != "" {
		az.logger.Error("Invalid configuration: StorageAccount and BlobContainer must both be empty or both be set")
	}

	az.dispatcherID = dispatcherID
	az.namePrefix = fmt.Sprintf("compute-%s-", az.dispatcherID)

	go func() {
		az.stopWg.Add(1)
		defer az.stopWg.Done()

		tk := time.NewTicker(5 * time.Minute)
		for {
			select {
			case <-az.ctx.Done():
				tk.Stop()
				return
			case <-tk.C:
				if az.blobcont != nil {
					az.manageBlobs()
				}
				az.manageDisks()
			}
		}
	}()

	az.deleteNIC = make(chan string)
	az.deleteBlob = make(chan storage.Blob)
	az.deleteDisk = make(chan compute.Disk)

	for i := 0; i < 4; i++ {
		go func() {
			for nicname := range az.deleteNIC {
				_, delerr := az.netClient.delete(context.Background(), az.azconfig.ResourceGroup, nicname)
				if delerr != nil {
					az.logger.WithError(delerr).Warnf("Error deleting %v", nicname)
				} else {
					az.logger.Printf("Deleted NIC %v", nicname)
				}
			}
		}()
		go func() {
			for blob := range az.deleteBlob {
				err := blob.Delete(nil)
				if err != nil {
					az.logger.WithError(err).Warnf("Error deleting %v", blob.Name)
				} else {
					az.logger.Printf("Deleted blob %v", blob.Name)
				}
			}
		}()
		go func() {
			for disk := range az.deleteDisk {
				_, err := az.disksClient.delete(az.ctx, az.imageResourceGroup, *disk.Name)
				if err != nil {
					az.logger.WithError(err).Warnf("Error deleting disk %+v", *disk.Name)
				} else {
					az.logger.Printf("Deleted disk %v", *disk.Name)
				}
			}
		}()
	}

	return nil
}

func (az *azureInstanceSet) cleanupNic(nic network.Interface) {
	_, delerr := az.netClient.delete(context.Background(), az.azconfig.ResourceGroup, *nic.Name)
	if delerr != nil {
		az.logger.WithError(delerr).Warnf("Error cleaning up NIC after failed create")
	}
}

func (az *azureInstanceSet) Create(
	instanceType arvados.InstanceType,
	imageID cloud.ImageID,
	newTags cloud.InstanceTags,
	initCommand cloud.InitCommand,
	publicKey ssh.PublicKey) (cloud.Instance, error) {

	az.stopWg.Add(1)
	defer az.stopWg.Done()

	if instanceType.AddedScratch > 0 {
		return nil, fmt.Errorf("cannot create instance type %q: driver does not implement non-zero AddedScratch (%d)", instanceType.Name, instanceType.AddedScratch)
	}

	name, err := randutil.String(15, "abcdefghijklmnopqrstuvwxyz0123456789")
	if err != nil {
		return nil, err
	}

	name = az.namePrefix + name

	tags := map[string]*string{}
	for k, v := range newTags {
		tags[k] = to.StringPtr(v)
	}
	tags["created-at"] = to.StringPtr(time.Now().Format(time.RFC3339Nano))

	networkResourceGroup := az.azconfig.NetworkResourceGroup
	if networkResourceGroup == "" {
		networkResourceGroup = az.azconfig.ResourceGroup
	}

	nicParameters := network.Interface{
		Location: &az.azconfig.Location,
		Tags:     tags,
		InterfacePropertiesFormat: &network.InterfacePropertiesFormat{
			IPConfigurations: &[]network.InterfaceIPConfiguration{
				{
					Name: to.StringPtr("ip1"),
					InterfaceIPConfigurationPropertiesFormat: &network.InterfaceIPConfigurationPropertiesFormat{
						Subnet: &network.Subnet{
							ID: to.StringPtr(fmt.Sprintf("/subscriptions/%s/resourceGroups/%s/providers"+
								"/Microsoft.Network/virtualnetworks/%s/subnets/%s",
								az.azconfig.SubscriptionID,
								networkResourceGroup,
								az.azconfig.Network,
								az.azconfig.Subnet)),
						},
						PrivateIPAllocationMethod: network.Dynamic,
					},
				},
			},
		},
	}
	nic, err := az.netClient.createOrUpdate(az.ctx, az.azconfig.ResourceGroup, name+"-nic", nicParameters)
	if err != nil {
		return nil, wrapAzureError(err)
	}

	var blobname string
	customData := base64.StdEncoding.EncodeToString([]byte("#!/bin/sh\n" + initCommand + "\n"))
	var storageProfile *compute.StorageProfile

	re := regexp.MustCompile(`^http(s?)://`)
	if re.MatchString(string(imageID)) {
		if az.blobcont == nil {
			az.cleanupNic(nic)
			return nil, wrapAzureError(errors.New("Invalid configuration: can't configure unmanaged image URL without StorageAccount and BlobContainer"))
		}
		blobname = fmt.Sprintf("%s-os.vhd", name)
		instanceVhd := fmt.Sprintf("https://%s.blob.%s/%s/%s",
			az.azconfig.StorageAccount,
			az.azureEnv.StorageEndpointSuffix,
			az.azconfig.BlobContainer,
			blobname)
		az.logger.Warn("using deprecated unmanaged image, see https://doc.arvados.org/ to migrate to managed disks")
		storageProfile = &compute.StorageProfile{
			OsDisk: &compute.OSDisk{
				OsType:       compute.Linux,
				Name:         to.StringPtr(name + "-os"),
				CreateOption: compute.DiskCreateOptionTypesFromImage,
				Image: &compute.VirtualHardDisk{
					URI: to.StringPtr(string(imageID)),
				},
				Vhd: &compute.VirtualHardDisk{
					URI: &instanceVhd,
				},
			},
		}
	} else {
		id := to.StringPtr("/subscriptions/" + az.azconfig.SubscriptionID + "/resourceGroups/" + az.imageResourceGroup + "/providers/Microsoft.Compute/images/" + string(imageID))
		if az.azconfig.SharedImageGalleryName != "" && az.azconfig.SharedImageGalleryImageVersion != "" {
			id = to.StringPtr("/subscriptions/" + az.azconfig.SubscriptionID + "/resourceGroups/" + az.imageResourceGroup + "/providers/Microsoft.Compute/galleries/" + az.azconfig.SharedImageGalleryName + "/images/" + string(imageID) + "/versions/" + az.azconfig.SharedImageGalleryImageVersion)
		} else if az.azconfig.SharedImageGalleryName != "" || az.azconfig.SharedImageGalleryImageVersion != "" {
			az.cleanupNic(nic)
			return nil, wrapAzureError(errors.New("Invalid configuration: SharedImageGalleryName and SharedImageGalleryImageVersion must both be set or both be empty"))
		}
		storageProfile = &compute.StorageProfile{
			ImageReference: &compute.ImageReference{
				ID: id,
			},
			OsDisk: &compute.OSDisk{
				OsType:       compute.Linux,
				Name:         to.StringPtr(name + "-os"),
				CreateOption: compute.DiskCreateOptionTypesFromImage,
			},
		}
	}

	vmParameters := compute.VirtualMachine{
		Location: &az.azconfig.Location,
		Tags:     tags,
		VirtualMachineProperties: &compute.VirtualMachineProperties{
			HardwareProfile: &compute.HardwareProfile{
				VMSize: compute.VirtualMachineSizeTypes(instanceType.ProviderType),
			},
			StorageProfile: storageProfile,
			NetworkProfile: &compute.NetworkProfile{
				NetworkInterfaces: &[]compute.NetworkInterfaceReference{
					{
						ID: nic.ID,
						NetworkInterfaceReferenceProperties: &compute.NetworkInterfaceReferenceProperties{
							Primary: to.BoolPtr(true),
						},
					},
				},
			},
			OsProfile: &compute.OSProfile{
				ComputerName:  &name,
				AdminUsername: to.StringPtr(az.azconfig.AdminUsername),
				LinuxConfiguration: &compute.LinuxConfiguration{
					DisablePasswordAuthentication: to.BoolPtr(true),
				},
				CustomData: &customData,
			},
		},
	}

	if publicKey != nil {
		vmParameters.VirtualMachineProperties.OsProfile.LinuxConfiguration.SSH = &compute.SSHConfiguration{
			PublicKeys: &[]compute.SSHPublicKey{
				{
					Path:    to.StringPtr("/home/" + az.azconfig.AdminUsername + "/.ssh/authorized_keys"),
					KeyData: to.StringPtr(string(ssh.MarshalAuthorizedKey(publicKey))),
				},
			},
		}
	}

	if instanceType.Preemptible {
		// Setting maxPrice to -1 is the equivalent of paying spot price, up to the
		// normal price. This means the node will not be pre-empted for price
		// reasons. It may still be pre-empted for capacity reasons though. And
		// Azure offers *no* SLA on spot instances.
		var maxPrice float64 = -1
		vmParameters.VirtualMachineProperties.Priority = compute.Spot
		vmParameters.VirtualMachineProperties.EvictionPolicy = compute.Delete
		vmParameters.VirtualMachineProperties.BillingProfile = &compute.BillingProfile{MaxPrice: &maxPrice}
	}

	vm, err := az.vmClient.createOrUpdate(az.ctx, az.azconfig.ResourceGroup, name, vmParameters)
	if err != nil {
		// Do some cleanup. Otherwise, an unbounded number of new unused nics and
		// blobs can pile up during times when VMs can't be created and the
		// dispatcher keeps retrying, because the garbage collection in manageBlobs
		// and manageNics is only triggered periodically. This is most important
		// for nics, because those are subject to a quota.
		az.cleanupNic(nic)

		if blobname != "" {
			_, delerr := az.blobcont.GetBlobReference(blobname).DeleteIfExists(nil)
			if delerr != nil {
				az.logger.WithError(delerr).Warnf("Error cleaning up vhd blob after failed create")
			}
		}

		// Leave cleaning up of managed disks to the garbage collection in manageDisks()

		return nil, wrapAzureError(err)
	}

	return &azureInstance{
		provider: az,
		nic:      nic,
		vm:       vm,
	}, nil
}

func (az *azureInstanceSet) Instances(cloud.InstanceTags) ([]cloud.Instance, error) {
	az.stopWg.Add(1)
	defer az.stopWg.Done()

	interfaces, err := az.manageNics()
	if err != nil {
		return nil, err
	}

	result, err := az.vmClient.listComplete(az.ctx, az.azconfig.ResourceGroup)
	if err != nil {
		return nil, wrapAzureError(err)
	}

	var instances []cloud.Instance
	for ; result.NotDone(); err = result.Next() {
		if err != nil {
			return nil, wrapAzureError(err)
		}
		instances = append(instances, &azureInstance{
			provider: az,
			vm:       result.Value(),
			nic:      interfaces[*(*result.Value().NetworkProfile.NetworkInterfaces)[0].ID],
		})
	}
	return instances, nil
}

// manageNics returns a list of Azure network interface resources.
// Also performs garbage collection of NICs which have "namePrefix",
// are not associated with a virtual machine and have a "created-at"
// time more than DeleteDanglingResourcesAfter (to prevent racing and
// deleting newly created NICs) in the past are deleted.
func (az *azureInstanceSet) manageNics() (map[string]network.Interface, error) {
	az.stopWg.Add(1)
	defer az.stopWg.Done()

	result, err := az.netClient.listComplete(az.ctx, az.azconfig.ResourceGroup)
	if err != nil {
		return nil, wrapAzureError(err)
	}

	interfaces := make(map[string]network.Interface)

	timestamp := time.Now()
	for ; result.NotDone(); err = result.Next() {
		if err != nil {
			az.logger.WithError(err).Warnf("Error listing nics")
			return interfaces, nil
		}
		if strings.HasPrefix(*result.Value().Name, az.namePrefix) {
			if result.Value().VirtualMachine != nil {
				interfaces[*result.Value().ID] = result.Value()
			} else {
				if result.Value().Tags["created-at"] != nil {
					createdAt, err := time.Parse(time.RFC3339Nano, *result.Value().Tags["created-at"])
					if err == nil {
						if timestamp.Sub(createdAt) > az.azconfig.DeleteDanglingResourcesAfter.Duration() {
							az.logger.Printf("Will delete %v because it is older than %s", *result.Value().Name, az.azconfig.DeleteDanglingResourcesAfter)
							az.deleteNIC <- *result.Value().Name
						}
					}
				}
			}
		}
	}
	return interfaces, nil
}

// manageBlobs garbage collects blobs (VM disk images) in the
// configured storage account container.  It will delete blobs which
// have "namePrefix", are "available" (which means they are not
// leased to a VM) and haven't been modified for
// DeleteDanglingResourcesAfter seconds.
func (az *azureInstanceSet) manageBlobs() {

	page := storage.ListBlobsParameters{Prefix: az.namePrefix}
	timestamp := time.Now()

	for {
		response, err := az.blobcont.ListBlobs(page)
		if err != nil {
			az.logger.WithError(err).Warn("Error listing blobs")
			return
		}
		for _, b := range response.Blobs {
			age := timestamp.Sub(time.Time(b.Properties.LastModified))
			if b.Properties.BlobType == storage.BlobTypePage &&
				b.Properties.LeaseState == "available" &&
				b.Properties.LeaseStatus == "unlocked" &&
				age.Seconds() > az.azconfig.DeleteDanglingResourcesAfter.Duration().Seconds() {

				az.logger.Printf("Blob %v is unlocked and not modified for %v seconds, will delete", b.Name, age.Seconds())
				az.deleteBlob <- b
			}
		}
		if response.NextMarker != "" {
			page.Marker = response.NextMarker
		} else {
			break
		}
	}
}

// manageDisks garbage collects managed compute disks (VM disk images) in the
// configured resource group.  It will delete disks which have "namePrefix",
// are "unattached" (which means they are not leased to a VM) and were created
// more than DeleteDanglingResourcesAfter seconds ago.  (Azure provides no
// modification timestamp on managed disks, there is only a creation timestamp)
func (az *azureInstanceSet) manageDisks() {

	re := regexp.MustCompile(`^` + regexp.QuoteMeta(az.namePrefix) + `.*-os$`)
	threshold := time.Now().Add(-az.azconfig.DeleteDanglingResourcesAfter.Duration())

	response, err := az.disksClient.listByResourceGroup(az.ctx, az.imageResourceGroup)
	if err != nil {
		az.logger.WithError(err).Warn("Error listing disks")
		return
	}

	for ; response.NotDone(); err = response.Next() {
		if err != nil {
			az.logger.WithError(err).Warn("Error getting next page of disks")
			return
		}
		for _, d := range response.Values() {
			if d.DiskProperties.DiskState == compute.Unattached &&
				d.Name != nil && re.MatchString(*d.Name) &&
				d.DiskProperties.TimeCreated.ToTime().Before(threshold) {

				az.logger.Printf("Disk %v is unlocked and was created at %+v, will delete", *d.Name, d.DiskProperties.TimeCreated.ToTime())
				az.deleteDisk <- d
			}
		}
	}
}

func (az *azureInstanceSet) InstanceQuotaGroup(arvados.InstanceType) cloud.InstanceQuotaGroup {
	// All instance types share one quota.
	return ""
}

func (az *azureInstanceSet) Stop() {
	az.stopFunc()
	az.stopWg.Wait()
	close(az.deleteNIC)
	close(az.deleteBlob)
	close(az.deleteDisk)
}

type azureInstance struct {
	provider *azureInstanceSet
	nic      network.Interface
	vm       compute.VirtualMachine
}

func (ai *azureInstance) ID() cloud.InstanceID {
	return cloud.InstanceID(*ai.vm.ID)
}

func (ai *azureInstance) String() string {
	return *ai.vm.Name
}

func (ai *azureInstance) ProviderType() string {
	return string(ai.vm.VirtualMachineProperties.HardwareProfile.VMSize)
}

func (ai *azureInstance) SetTags(newTags cloud.InstanceTags) error {
	ai.provider.stopWg.Add(1)
	defer ai.provider.stopWg.Done()

	tags := map[string]*string{}
	for k, v := range ai.vm.Tags {
		tags[k] = v
	}
	for k, v := range newTags {
		tags[k] = to.StringPtr(v)
	}

	vmParameters := compute.VirtualMachine{
		Location: &ai.provider.azconfig.Location,
		Tags:     tags,
	}
	vm, err := ai.provider.vmClient.createOrUpdate(ai.provider.ctx, ai.provider.azconfig.ResourceGroup, *ai.vm.Name, vmParameters)
	if err != nil {
		return wrapAzureError(err)
	}
	ai.vm = vm

	return nil
}

func (ai *azureInstance) Tags() cloud.InstanceTags {
	tags := cloud.InstanceTags{}
	for k, v := range ai.vm.Tags {
		tags[k] = *v
	}
	return tags
}

func (ai *azureInstance) Destroy() error {
	ai.provider.stopWg.Add(1)
	defer ai.provider.stopWg.Done()

	_, err := ai.provider.vmClient.delete(ai.provider.ctx, ai.provider.azconfig.ResourceGroup, *ai.vm.Name)
	return wrapAzureError(err)
}

func (ai *azureInstance) Address() string {
	if iprops := ai.nic.InterfacePropertiesFormat; iprops == nil {
		return ""
	} else if ipconfs := iprops.IPConfigurations; ipconfs == nil || len(*ipconfs) == 0 {
		return ""
	} else if ipconfprops := (*ipconfs)[0].InterfaceIPConfigurationPropertiesFormat; ipconfprops == nil {
		return ""
	} else if addr := ipconfprops.PrivateIPAddress; addr == nil {
		return ""
	} else {
		return *addr
	}
}

func (ai *azureInstance) PriceHistory(arvados.InstanceType) []cloud.InstancePrice {
	return nil
}

func (ai *azureInstance) RemoteUser() string {
	return ai.provider.azconfig.AdminUsername
}

func (ai *azureInstance) VerifyHostKey(ssh.PublicKey, *ssh.Client) error {
	return cloud.ErrNotImplemented
}
