diff --git a/PROJECT b/PROJECT index a5a71f1f7c0..00905ec53b7 100644 --- a/PROJECT +++ b/PROJECT @@ -86,4 +86,6 @@ resources: - group: azure version: v1alpha1 kind: MySQLFirewallRule - +- group: azure + version: v1alpha1 + kind: AzureVirtualMachine diff --git a/README.md b/README.md index 79c97963b85..b0b69389fb2 100644 --- a/README.md +++ b/README.md @@ -32,15 +32,16 @@ This project maintains [releases of the Azure Service Operator](https://github.c 1. [Resource Group](/docs/resourcegroup/resourcegroup.md) 2. [EventHub](/docs/eventhub/eventhub.md) 3. [Azure SQL](/docs/azuresql/azuresql.md) -4. [Azure Keyvault](/docs/keyvault/keyvault.md) -5. [Azure Rediscache](/docs/rediscache/rediscache.md) -6. [Storage Account](/docs/storage/storageaccount.md) -7. [Blob container](/docs/storage/blobcontainer.md) -8. [Azure Database for PostgreSQL](/docs/postgresql/postgresql.md) -9. [Virtual Network](/docs/virtualnetwork/virtualnetwork.md) -10.[Application Insights](/docs/appinsights/appinsights.md) -11.[API Management](/docs/apimgmt/apimgmt.md) -12.[Cosmos DB](/docs/cosmosdb/cosmosdb.md) +4. [Azure Database for PostgreSQL](/docs/postgresql/postgresql.md) +5. [Azure Database for MySQL](/docs/mysql/mysql.md) +6. [Azure Keyvault](/docs/keyvault/keyvault.md) +7. [Azure Rediscache](/docs/rediscache/rediscache.md) +8. [Storage Account](/docs/storage/storageaccount.md) +9. [Blob container](/docs/storage/blobcontainer.md) +10. [Virtual Network](/docs/virtualnetwork/virtualnetwork.md) +11. [Application Insights](/docs/appinsights/appinsights.md) +12. [API Management](/docs/apimgmt/apimgmt.md) +13. [Cosmos DB](/docs/cosmosdb/cosmosdb.md) For more information on deploying, troubleshooting & deleting resources, refer to [this](/docs/customresource.md) link diff --git a/api/v1alpha1/azurevirtualmachine_types.go b/api/v1alpha1/azurevirtualmachine_types.go new file mode 100644 index 00000000000..05555d355a0 --- /dev/null +++ b/api/v1alpha1/azurevirtualmachine_types.go @@ -0,0 +1,59 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +package v1alpha1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// EDIT THIS FILE! THIS IS SCAFFOLDING FOR YOU TO OWN! +// NOTE: json tags are required. Any new fields you add must have json tags for the fields to be serialized. + +// AzureVirtualMachineSpec defines the desired state of AzureVirtualMachine +type AzureVirtualMachineSpec struct { + // INSERT ADDITIONAL SPEC FIELDS - desired state of cluster + // Important: Run "make" to regenerate code after modifying this file + Location string `json:"location"` + ResourceGroup string `json:"resourceGroup"` + VMSize string `json:"vmSize"` + OSType OSType `json:"osType"` + AdminUserName string `json:"adminUserName"` + SSHPublicKeyData string `json:"sshPublicKeyData,omitempty"` + NetworkInterfaceName string `json:"networkInterfaceName"` + PlatformImageURN string `json:"platformImageURN"` +} + +type OSType string + +const ( + // Windows ... + Windows OSType = "Windows" + // Linux ... + Linux OSType = "Linux" +) + +// +kubebuilder:object:root=true +// +kubebuilder:subresource:status + +// AzureVirtualMachine is the Schema for the azurevirtualmachines API +type AzureVirtualMachine struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec AzureVirtualMachineSpec `json:"spec,omitempty"` + Status ASOStatus `json:"status,omitempty"` +} + +// +kubebuilder:object:root=true + +// AzureVirtualMachineList contains a list of AzureVirtualMachine +type AzureVirtualMachineList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []AzureVirtualMachine `json:"items"` +} + +func init() { + SchemeBuilder.Register(&AzureVirtualMachine{}, &AzureVirtualMachineList{}) +} diff --git a/api/v1alpha1/azurevirtualmachine_types_test.go b/api/v1alpha1/azurevirtualmachine_types_test.go new file mode 100644 index 00000000000..ec7b74a8961 --- /dev/null +++ b/api/v1alpha1/azurevirtualmachine_types_test.go @@ -0,0 +1,74 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +package v1alpha1 + +import ( + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" + + "golang.org/x/net/context" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" +) + +// These tests are written in BDD-style using Ginkgo framework. Refer to +// http://onsi.github.io/ginkgo to learn more. + +var _ = Describe("AzureVirtualMachine", func() { + var ( + key types.NamespacedName + created, fetched *AzureVirtualMachine + ) + + BeforeEach(func() { + // Add any setup steps that needs to be executed before each test + }) + + AfterEach(func() { + // Add any teardown steps that needs to be executed after each test + }) + + // Add Tests for OpenAPI validation (or additonal CRD features) specified in + // your API definition. + // Avoid adding tests for vanilla CRUD operations because they would + // test Kubernetes API server, which isn't the goal here. + Context("Create API", func() { + + It("should create an object successfully", func() { + + key = types.NamespacedName{ + Name: "foo", + Namespace: "default", + } + created = &AzureVirtualMachine{ + ObjectMeta: metav1.ObjectMeta{ + Name: "foo", + Namespace: "default", + }, + Spec: AzureVirtualMachineSpec{ + Location: "westus", + ResourceGroup: "foo-vm", + VMSize: "test", + OSType: OSType("Linux"), + AdminUserName: "test", + SSHPublicKeyData: "test", + NetworkInterfaceName: "test", + PlatformImageURN: "w:x:y:z", + }} + + By("creating an API obj") + Expect(k8sClient.Create(context.TODO(), created)).To(Succeed()) + + fetched = &AzureVirtualMachine{} + Expect(k8sClient.Get(context.TODO(), key, fetched)).To(Succeed()) + Expect(fetched).To(Equal(created)) + + By("deleting the created object") + Expect(k8sClient.Delete(context.TODO(), created)).To(Succeed()) + Expect(k8sClient.Get(context.TODO(), key, created)).ToNot(Succeed()) + }) + + }) + +}) diff --git a/api/v1alpha1/cosmosdb_types.go b/api/v1alpha1/cosmosdb_types.go index 34fea0935fc..1a0c32794fa 100644 --- a/api/v1alpha1/cosmosdb_types.go +++ b/api/v1alpha1/cosmosdb_types.go @@ -17,10 +17,12 @@ type CosmosDBSpec struct { // +kubebuilder:validation:MinLength=0 - Location string `json:"location,omitempty"` - ResourceGroup string `json:"resourceGroup"` - Kind CosmosDBKind `json:"kind,omitempty"` - Properties CosmosDBProperties `json:"properties,omitempty"` + Location string `json:"location,omitempty"` + ResourceGroup string `json:"resourceGroup"` + Kind CosmosDBKind `json:"kind,omitempty"` + Properties CosmosDBProperties `json:"properties,omitempty"` + VirtualNetworkRules *[]CosmosDBVirtualNetworkRule `json:"virtualNetworkRules,omitempty"` + KeyVaultToStoreSecrets string `json:"keyVaultToStoreSecrets,omitempty"` } // CosmosDBKind enumerates the values for kind. @@ -39,9 +41,12 @@ const ( // CosmosDBProperties the CosmosDBProperties of CosmosDB. type CosmosDBProperties struct { - // CosmosDBDatabaseAccountOfferType - The offer type for the Cosmos DB database account. + // DatabaseAccountOfferType - The offer type for the Cosmos DB database account. DatabaseAccountOfferType CosmosDBDatabaseAccountOfferType `json:"databaseAccountOfferType,omitempty"` - //Locations []CosmosDBLocation `json:"locations,omitempty"` + // IsVirtualNetworkFilterEnabled - Flag to indicate whether to enable/disable Virtual Network ACL rules. + IsVirtualNetworkFilterEnabled bool `json:"isVirtualNetworkFilterEnabled,omitempty"` + EnableMultipleWriteLocations bool `json:"enableMultipleWriteLocations,omitempty"` + MongoDBVersion string `json:"mongoDBVersion,omitempty"` } // +kubebuilder:validation:Enum=Standard @@ -82,6 +87,14 @@ type CosmosDBList struct { Items []CosmosDB `json:"items"` } +//CosmosDBVirtualNetworkRule virtual Network ACL Rule object +type CosmosDBVirtualNetworkRule struct { + // ID - Resource ID of a subnet, for example: /subscriptions/{subscriptionId}/resourceGroups/{groupName}/providers/Microsoft.Network/virtualNetworks/{virtualNetworkName}/subnets/{subnetName}. + SubnetID *string `json:"subnetID,omitempty"` + // IgnoreMissingVNetServiceEndpoint - Create firewall rule before the virtual network has vnet service endpoint enabled. + IgnoreMissingVNetServiceEndpoint *bool `json:"ignoreMissingVNetServiceEndpoint,omitempty"` +} + func init() { SchemeBuilder.Register(&CosmosDB{}, &CosmosDBList{}) } diff --git a/api/v1alpha1/mysqlserver_types.go b/api/v1alpha1/mysqlserver_types.go index 9fb8df5b540..3a3c8386512 100644 --- a/api/v1alpha1/mysqlserver_types.go +++ b/api/v1alpha1/mysqlserver_types.go @@ -17,6 +17,8 @@ type MySQLServerSpec struct { Sku AzureDBsSQLSku `json:"sku,omitempty"` ServerVersion ServerVersion `json:"serverVersion,omitempty"` SSLEnforcement SslEnforcementEnum `json:"sslEnforcement,omitempty"` + CreateMode string `json:"createMode,omitempty"` + ReplicaProperties ReplicaProperties `json:"replicaProperties,omitempty"` KeyVaultToStoreSecrets string `json:"keyVaultToStoreSecrets,omitempty"` } @@ -41,6 +43,10 @@ type MySQLServerList struct { Items []MySQLServer `json:"items"` } +type ReplicaProperties struct { + SourceServerId string `json:"sourceServerId,omitempty"` +} + func init() { SchemeBuilder.Register(&MySQLServer{}, &MySQLServerList{}) } @@ -55,14 +61,32 @@ func NewDefaultMySQLServer(name, resourceGroup, location string) *MySQLServer { Location: location, ResourceGroup: resourceGroup, Sku: AzureDBsSQLSku{ - Name: "B_Gen5_2", - Tier: SkuTier("Basic"), + Name: "GP_Gen5_4", + Tier: SkuTier("GeneralPurpose"), Family: "Gen5", Size: "51200", - Capacity: 2, + Capacity: 4, }, ServerVersion: ServerVersion("8.0"), SSLEnforcement: SslEnforcementEnumEnabled, + CreateMode: "Default", + }, + } +} + +func NewReplicaMySQLServer(name, resourceGroup, location string, sourceserverid string) *MySQLServer { + return &MySQLServer{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: "default", + }, + Spec: MySQLServerSpec{ + Location: location, + ResourceGroup: resourceGroup, + CreateMode: "Replica", + ReplicaProperties: ReplicaProperties{ + SourceServerId: sourceserverid, + }, }, } } diff --git a/api/v1alpha1/storageaccount_types.go b/api/v1alpha1/storageaccount_types.go index 01530effb97..41ba60b368c 100644 --- a/api/v1alpha1/storageaccount_types.go +++ b/api/v1alpha1/storageaccount_types.go @@ -29,6 +29,8 @@ type StorageAccountSpec struct { EnableHTTPSTrafficOnly *bool `json:"supportsHttpsTrafficOnly,omitempty"` DataLakeEnabled *bool `json:"dataLakeEnabled,omitempty"` + + NetworkRule *StorageNetworkRuleSet `json:"networkRule,omitempty"` } // StorageAccountSku the SKU of the storage account. @@ -97,6 +99,43 @@ type StorageAccountList struct { Items []StorageAccount `json:"items"` } +type Bypass string + +type StorageNetworkRuleSet struct { + // Bypass - Specifies whether traffic is bypassed for Logging/Metrics/AzureServices. + //Possible values are any combination of Logging|Metrics|AzureServices (For example, "Logging, Metrics"), or None to bypass none of those traffics. + //Possible values include: 'None', 'Logging', 'Metrics', 'AzureServices' + Bypass Bypass `json:"bypass,omitempty"` + // VirtualNetworkRules - Sets the virtual network rules + VirtualNetworkRules *[]VirtualNetworkRule `json:"virtualNetworkRules,omitempty"` + // IPRules - Sets the IP ACL rules + IPRules *[]IPRule `json:"ipRules,omitempty"` + // DefaultAction - Specifies the default action of allow or deny when no other rules match. Possible values include: 'DefaultActionAllow', 'DefaultActionDeny' + DefaultAction string `json:"defaultAction,omitempty"` +} + +const ( + + // AzureServices ... + AzureServices Bypass = "AzureServices" + // Logging ... + Logging Bypass = "Logging" + // Metrics ... + Metrics Bypass = "Metrics" + // None ... + None Bypass = "None" +) + +type VirtualNetworkRule struct { + // SubnetId - Resource ID of a subnet, for example: /subscriptions/{subscriptionId}/resourceGroups/{groupName}/providers/Microsoft.Network/virtualNetworks/{vnetName}/subnets/{subnetName}. + SubnetId *string `json:"subnetId,omitempty"` +} + +type IPRule struct { + // IPAddressOrRange - Specifies the IP or IP range in CIDR format. Only IPV4 address is allowed. + IPAddressOrRange *string `json:"ipAddressOrRange,omitempty"` +} + func init() { SchemeBuilder.Register(&StorageAccount{}, &StorageAccountList{}) } diff --git a/config/crd/kustomization.yaml b/config/crd/kustomization.yaml index a4978b866b5..d32a5f49f90 100644 --- a/config/crd/kustomization.yaml +++ b/config/crd/kustomization.yaml @@ -33,6 +33,7 @@ resources: - bases/azure.microsoft.com_mysqlfirewallrules.yaml - bases/azure.microsoft.com_azurepublicipaddresses.yaml - bases/azure.microsoft.com_azurenetworkinterfaces.yaml +- bases/azure.microsoft.com_azurevirtualmachines.yaml # +kubebuilder:scaffold:crdkustomizeresource #patches: @@ -65,6 +66,7 @@ resources: #- patches/webhook_in_storageaccounts.yaml #- patches/webhook_in_azurepublicipaddresses.yaml #- patches/webhook_in_azurenetworkinterfaces.yaml +#- patches/webhook_in_azurevirtualmachines.yaml # +kubebuilder:scaffold:crdkustomizewebhookpatch # [CAINJECTION] patches here are for enabling the CA injection for each CRD @@ -96,6 +98,7 @@ resources: #- patches/cainjection_in_storageaccounts.yaml #- patches/cainjection_in_azurepublicipaddresses.yaml #- patches/cainjection_in_azurenetworkinterfaces.yaml +#- patches/cainjection_in_azurevirtualmachines.yaml # +kubebuilder:scaffold:crdkustomizecainjectionpatch # the following config is for teaching kustomize how to do kustomization for CRDs. diff --git a/config/crd/patches/cainjection_in_azurevirtualmachines.yaml b/config/crd/patches/cainjection_in_azurevirtualmachines.yaml new file mode 100644 index 00000000000..bee5073111d --- /dev/null +++ b/config/crd/patches/cainjection_in_azurevirtualmachines.yaml @@ -0,0 +1,8 @@ +# The following patch adds a directive for certmanager to inject CA into the CRD +# CRD conversion requires k8s 1.13 or later. +apiVersion: apiextensions.k8s.io/v1beta1 +kind: CustomResourceDefinition +metadata: + annotations: + certmanager.k8s.io/inject-ca-from: $(CERTIFICATE_NAMESPACE)/$(CERTIFICATE_NAME) + name: azurevirtualmachines.azure.microsoft.com diff --git a/config/crd/patches/webhook_in_azurevirtualmachines.yaml b/config/crd/patches/webhook_in_azurevirtualmachines.yaml new file mode 100644 index 00000000000..fb9cf38eef8 --- /dev/null +++ b/config/crd/patches/webhook_in_azurevirtualmachines.yaml @@ -0,0 +1,17 @@ +# The following patch enables conversion webhook for CRD +# CRD conversion requires k8s 1.13 or later. +apiVersion: apiextensions.k8s.io/v1beta1 +kind: CustomResourceDefinition +metadata: + name: azurevirtualmachines.azure.microsoft.com +spec: + conversion: + strategy: Webhook + webhookClientConfig: + # this is "\n" used as a placeholder, otherwise it will be rejected by the apiserver for being blank, + # but we're going to set it later using the cert-manager (or potentially a patch if not using cert-manager) + caBundle: Cg== + service: + namespace: system + name: webhook-service + path: /convert diff --git a/config/samples/azure_v1alpha1_azurevirtualmachine.yaml b/config/samples/azure_v1alpha1_azurevirtualmachine.yaml new file mode 100644 index 00000000000..c4a54fec2c4 --- /dev/null +++ b/config/samples/azure_v1alpha1_azurevirtualmachine.yaml @@ -0,0 +1,14 @@ +apiVersion: azure.microsoft.com/v1alpha1 +kind: AzureVirtualMachine +metadata: + name: hpfvm20 +spec: + location: SouthCentralUS + resourceGroup: resourcegroup-azure-operators + vmSize: Standard_DS1_v2 + osType: Linux + adminUserName: azureuser + # SSH public key to be used with VM (eg cat ~/.ssh/id_rsa.pub) + sshPublicKeyData: "{ssh public key}" + networkInterfaceName: hpfnic20 + platformImageURN: Canonical:UbuntuServer:16.04-LTS:latest diff --git a/config/samples/azure_v1alpha1_blobcontainer.yaml b/config/samples/azure_v1alpha1_blobcontainer.yaml index b3dadf1baf3..a7491ee1fd9 100644 --- a/config/samples/azure_v1alpha1_blobcontainer.yaml +++ b/config/samples/azure_v1alpha1_blobcontainer.yaml @@ -4,8 +4,8 @@ metadata: name: blobcontainer-sample spec: location: westus - resourcegroup: resourcegroup-sample - accountname: storageaccount-sample + resourcegroup: resourcegroup-azure-operators + accountname: storageaccountsample777 # accessLevel - Specifies whether data in the container may be accessed publicly and the level of access. # Possible values include: 'Container', 'Blob', 'None' accesslevel: Container \ No newline at end of file diff --git a/config/samples/azure_v1alpha1_cosmosdb.yaml b/config/samples/azure_v1alpha1_cosmosdb.yaml index d3efa498467..2442ba22685 100644 --- a/config/samples/azure_v1alpha1_cosmosdb.yaml +++ b/config/samples/azure_v1alpha1_cosmosdb.yaml @@ -1,10 +1,24 @@ apiVersion: azure.microsoft.com/v1alpha1 kind: CosmosDB metadata: - name: cosmosdb-sample1908xyzkj + name: cosmosdb-sample-1 spec: kind: GlobalDocumentDB location: westus resourceGroup: resourcegroup-azure-operators properties: databaseAccountOfferType: Standard + enableMultipleWriteLocations: false + # optionally set the mongoDBVersion to "3.2" or "3.6", if omitted the default is "3.2" + # NOTE: kind must be set to MongoDB for this to take effect + #mongoDBVersion: "3.6" + +#optional for network rule set +# isVirtualNetworkFilterEnabled: true +# virtualNetworkRules: +# - subnetId: /subscriptions/{subscription_id}/resourceGroups/{resourcegroup}/providers/Microsoft.Network/virtualNetworks/{vnet_name}/subnets/{subnet_name} +# ignoreMissingServiceEndpoint: false + + # Use the field below to optionally specify a different keyvault + # to store the connectiong string secrets in + #keyVaultToStoreSecrets: asoSecretKeyVault \ No newline at end of file diff --git a/config/samples/azure_v1alpha1_mysqlserver.yaml b/config/samples/azure_v1alpha1_mysqlserver.yaml index 3b2ff695ab5..05d543e51f2 100644 --- a/config/samples/azure_v1alpha1_mysqlserver.yaml +++ b/config/samples/azure_v1alpha1_mysqlserver.yaml @@ -7,9 +7,13 @@ spec: resourceGroup: resourcegroup-azure-operators serverVersion: "8.0" sslEnforcement: Enabled + minimalTLSVersion: TLS10 # Possible values include: 'TLS10', 'TLS11', 'TLS12', 'Disabled' + infrastructureEncryption: Enabled # Possible values include: Enabled, Disabled + createMode: Default # Possible values include: Default, Replica, PointInTimeRestore (not implemented), GeoRestore (not implemented) sku: - name: B_Gen5_2 - tier: Basic - family: Gen5 + name: GP_Gen5_4 # tier + family + cores eg. - B_Gen4_1, GP_Gen5_4 + tier: GeneralPurpose # possible values - 'Basic', 'GeneralPurpose', 'MemoryOptimized' + family: Gen5 size: "51200" - capacity: 2 + capacity: 4 + diff --git a/config/samples/azure_v1alpha1_mysqlserver_replica.yaml b/config/samples/azure_v1alpha1_mysqlserver_replica.yaml new file mode 100644 index 00000000000..515e40589b4 --- /dev/null +++ b/config/samples/azure_v1alpha1_mysqlserver_replica.yaml @@ -0,0 +1,12 @@ +apiVersion: azure.microsoft.com/v1alpha1 +kind: MySQLServer +metadata: + name: mysqlserver-replica +spec: + location: eastus2 + resourceGroup: resourcegroup-azure-operators + createMode: Replica # Possible values include: Default, Replica, PointInTimeRestore (not implemented), GeoRestore (not implemented) + replicaProperties: + # sourceServer tier should be "GeneralPurpose" or higher for replica support + sourceServerId: /subscriptions/{SUBID}/resourceGroups/resourcegroup-azure-operators/providers/Microsoft.DBforMySQL/servers/mysqlserver-sample + diff --git a/config/samples/azure_v1alpha1_storageaccount.yaml b/config/samples/azure_v1alpha1_storageaccount.yaml index 12a1dd0a229..29cd0fe5cdf 100644 --- a/config/samples/azure_v1alpha1_storageaccount.yaml +++ b/config/samples/azure_v1alpha1_storageaccount.yaml @@ -10,3 +10,12 @@ spec: kind: StorageV2 accessTier: Hot supportsHttpsTrafficOnly: true +# Optional: networkRule + # networkRule: + # bypass: AzureServices # Possible values are AzureServices, Metrics, None, Logging + # defaultAction: Deny # Possible values are Allow, Deny + # virtualNetworkRules: + # - subnetId: /subscriptions/{subscription}/resourceGroups/{resourcegroup}/providers/Microsoft.Network/virtualNetworks/{vnet}/subnets/{subnet} + # ipRules: #could be an ip range or a ip address + # - ipAddressOrRange: 2.2.0.0/24 + # - ipAddressOrRange: 2.2.2.1 diff --git a/controllers/azurevirtualmachine_controller.go b/controllers/azurevirtualmachine_controller.go new file mode 100644 index 00000000000..a10447c180b --- /dev/null +++ b/controllers/azurevirtualmachine_controller.go @@ -0,0 +1,28 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +package controllers + +import ( + ctrl "sigs.k8s.io/controller-runtime" + + azurev1alpha1 "github.com/Azure/azure-service-operator/api/v1alpha1" +) + +// AzureVirtualMachineReconciler reconciles a AzureVirtualMachine object +type AzureVirtualMachineReconciler struct { + Reconciler *AsyncReconciler +} + +// +kubebuilder:rbac:groups=azure.microsoft.com,resources=azurevirtualmachines,verbs=get;list;watch;create;update;patch;delete +// +kubebuilder:rbac:groups=azure.microsoft.com,resources=azurevirtualmachines/status,verbs=get;update;patch + +func (r *AzureVirtualMachineReconciler) Reconcile(req ctrl.Request) (ctrl.Result, error) { + return r.Reconciler.Reconcile(req, &azurev1alpha1.AzureVirtualMachine{}) +} + +func (r *AzureVirtualMachineReconciler) SetupWithManager(mgr ctrl.Manager) error { + return ctrl.NewControllerManagedBy(mgr). + For(&azurev1alpha1.AzureVirtualMachine{}). + Complete(r) +} diff --git a/controllers/azurevirtualmachine_controller_test.go b/controllers/azurevirtualmachine_controller_test.go new file mode 100644 index 00000000000..f9e38720f71 --- /dev/null +++ b/controllers/azurevirtualmachine_controller_test.go @@ -0,0 +1,167 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// +build all azurevirtualmachine + +package controllers + +import ( + "context" + "crypto/rand" + "crypto/rsa" + "testing" + + azurev1alpha1 "github.com/Azure/azure-service-operator/api/v1alpha1" + "github.com/Azure/azure-service-operator/pkg/errhelp" + "golang.org/x/crypto/ssh" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func generateRandomSshPublicKeyString() string { + privateKey, _ := rsa.GenerateKey(rand.Reader, 2048) + publicRsaKey, _ := ssh.NewPublicKey(&privateKey.PublicKey) + sshPublicKeyData := string(ssh.MarshalAuthorizedKey(publicRsaKey)) + return sshPublicKeyData +} + +func TestVirtualMachineControllerNoResourceGroup(t *testing.T) { + t.Parallel() + defer PanicRecover(t) + ctx := context.Background() + + rgName := GenerateTestResourceNameWithRandom("rg", 10) + vmName := GenerateTestResourceNameWithRandom("vm", 10) + vmSize := "Standard_DS1_v2" + osType := azurev1alpha1.OSType("Linux") + adminUserName := GenerateTestResourceNameWithRandom("u", 10) + sshPublicKeyData := GenerateTestResourceNameWithRandom("ssh", 10) + nicName := GenerateTestResourceNameWithRandom("nic", 10) + platformImageUrn := "Canonical:UbuntuServer:16.04-LTS:latest" + + // Create a VM + vmInstance := &azurev1alpha1.AzureVirtualMachine{ + ObjectMeta: metav1.ObjectMeta{ + Name: vmName, + Namespace: "default", + }, + Spec: azurev1alpha1.AzureVirtualMachineSpec{ + Location: tc.resourceGroupLocation, + ResourceGroup: rgName, + VMSize: vmSize, + OSType: osType, + AdminUserName: adminUserName, + SSHPublicKeyData: sshPublicKeyData, + NetworkInterfaceName: nicName, + PlatformImageURN: platformImageUrn, + }, + } + + EnsureInstanceWithResult(ctx, t, tc, vmInstance, errhelp.ResourceGroupNotFoundErrorCode, false) + + EnsureDelete(ctx, t, tc, vmInstance) +} + +func TestVirtualMachineHappyPathWithNicPipVNetAndSubnet(t *testing.T) { + t.Parallel() + defer PanicRecover(t) + ctx := context.Background() + + // Create a vnet with a subnet + vnetName := GenerateTestResourceNameWithRandom("vnt", 10) + subnetName := GenerateTestResourceNameWithRandom("snt", 10) + vnetSubNetInstance := azurev1alpha1.VNetSubnets{ + SubnetName: subnetName, + SubnetAddressPrefix: "110.1.0.0/16", + } + pipName := GenerateTestResourceNameWithRandom("pip3", 10) + vnetInstance := &azurev1alpha1.VirtualNetwork{ + ObjectMeta: metav1.ObjectMeta{ + Name: vnetName, + Namespace: "default", + }, + Spec: azurev1alpha1.VirtualNetworkSpec{ + Location: tc.resourceGroupLocation, + ResourceGroup: tc.resourceGroupName, + AddressSpace: "110.0.0.0/8", + Subnets: []azurev1alpha1.VNetSubnets{vnetSubNetInstance}, + }, + } + + EnsureInstance(ctx, t, tc, vnetInstance) + + // Create a Public IP Address + publicIPAllocationMethod := "Static" + idleTimeoutInMinutes := 10 + publicIPAddressVersion := "IPv4" + skuName := "Basic" + pipInstance := &azurev1alpha1.AzurePublicIPAddress{ + ObjectMeta: metav1.ObjectMeta{ + Name: pipName, + Namespace: "default", + }, + Spec: azurev1alpha1.AzurePublicIPAddressSpec{ + Location: tc.resourceGroupLocation, + ResourceGroup: tc.resourceGroupName, + PublicIPAllocationMethod: publicIPAllocationMethod, + IdleTimeoutInMinutes: idleTimeoutInMinutes, + PublicIPAddressVersion: publicIPAddressVersion, + SkuName: skuName, + }, + } + + EnsureInstance(ctx, t, tc, pipInstance) + + // Create a NIC + nicName := GenerateTestResourceNameWithRandom("nic", 10) + nicInstance := &azurev1alpha1.AzureNetworkInterface{ + ObjectMeta: metav1.ObjectMeta{ + Name: nicName, + Namespace: "default", + }, + Spec: azurev1alpha1.AzureNetworkInterfaceSpec{ + Location: tc.resourceGroupLocation, + ResourceGroup: tc.resourceGroupName, + VNetName: vnetName, + SubnetName: subnetName, + PublicIPAddressName: pipName, + }, + } + + EnsureInstance(ctx, t, tc, nicInstance) + + // Create a VM + vmName := GenerateTestResourceNameWithRandom("vm", 10) + vmSize := "Standard_DS1_v2" + osType := azurev1alpha1.OSType("Linux") + vmImageUrn := "Canonical:UbuntuServer:16.04-LTS:latest" + userName := "azureuser" + + sshPublicKeyData := generateRandomSshPublicKeyString() + + vmInstance := &azurev1alpha1.AzureVirtualMachine{ + ObjectMeta: metav1.ObjectMeta{ + Name: vmName, + Namespace: "default", + }, + Spec: azurev1alpha1.AzureVirtualMachineSpec{ + Location: tc.resourceGroupLocation, + ResourceGroup: tc.resourceGroupName, + VMSize: vmSize, + OSType: osType, + AdminUserName: userName, + SSHPublicKeyData: sshPublicKeyData, + NetworkInterfaceName: nicName, + PlatformImageURN: vmImageUrn, + }, + } + + EnsureInstance(ctx, t, tc, vmInstance) + + EnsureDelete(ctx, t, tc, vmInstance) + + EnsureDelete(ctx, t, tc, nicInstance) + + EnsureDelete(ctx, t, tc, pipInstance) + + EnsureDelete(ctx, t, tc, vnetInstance) +} diff --git a/controllers/cosmosdb_controller_test.go b/controllers/cosmosdb_controller_test.go index f9ca7f45df0..31ad06cb5e7 100644 --- a/controllers/cosmosdb_controller_test.go +++ b/controllers/cosmosdb_controller_test.go @@ -10,25 +10,30 @@ import ( "testing" "github.com/Azure/azure-service-operator/api/v1alpha1" + "github.com/Azure/azure-service-operator/pkg/errhelp" + + "github.com/stretchr/testify/assert" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" ) func TestCosmosDBHappyPath(t *testing.T) { t.Parallel() defer PanicRecover(t) ctx := context.Background() + assert := assert.New(t) - cosmosDBAccountName := GenerateTestResourceNameWithRandom("cosmosdb", 8) - cosmosDBNamespace := "default" + name := GenerateTestResourceNameWithRandom("cosmosdb", 8) + namespace := "default" dbInstance := &v1alpha1.CosmosDB{ ObjectMeta: metav1.ObjectMeta{ - Name: cosmosDBAccountName, - Namespace: cosmosDBNamespace, + Name: name, + Namespace: namespace, }, Spec: v1alpha1.CosmosDBSpec{ - Location: "westus", + Location: tc.resourceGroupLocation, ResourceGroup: tc.resourceGroupName, Kind: v1alpha1.CosmosDBKindGlobalDocumentDB, Properties: v1alpha1.CosmosDBProperties{ @@ -37,8 +42,85 @@ func TestCosmosDBHappyPath(t *testing.T) { }, } + key := types.NamespacedName{Name: name, Namespace: namespace} + EnsureInstance(ctx, t, tc, dbInstance) + assert.Eventually(func() bool { + secret, err := tc.secretClient.Get(ctx, key) + return err == nil && len(secret) > 0 + }, tc.timeoutFast, tc.retry, "wait for cosmosdb to have secret") + EnsureDelete(ctx, t, tc, dbInstance) + assert.Eventually(func() bool { + _, err := tc.secretClient.Get(ctx, key) + return err != nil + }, tc.timeoutFast, tc.retry, "wait for cosmosdb to delete secret") + +} + +func TestCosmosDBControllerNoResourceGroup(t *testing.T) { + t.Parallel() + defer PanicRecover(t) + ctx := context.Background() + + rgLocation := tc.resourceGroupLocation + //wrong resource group name + resourceGroupName := "gone" + + cosmosDBAccountName := GenerateTestResourceNameWithRandom("cosmosdb", 8) + cosmosDBNamespace := "default" + + dbInstance1 := &v1alpha1.CosmosDB{ + ObjectMeta: metav1.ObjectMeta{ + Name: cosmosDBAccountName, + Namespace: cosmosDBNamespace, + }, + Spec: v1alpha1.CosmosDBSpec{ + Location: rgLocation, + ResourceGroup: resourceGroupName, + Kind: v1alpha1.CosmosDBKindGlobalDocumentDB, + Properties: v1alpha1.CosmosDBProperties{ + DatabaseAccountOfferType: v1alpha1.CosmosDBDatabaseAccountOfferTypeStandard, + }, + }, + } + + EnsureInstanceWithResult(ctx, t, tc, dbInstance1, errhelp.ResourceGroupNotFoundErrorCode, false) + EnsureDelete(ctx, t, tc, dbInstance1) +} + +func TestCosmosDBControllerInvalidLocation(t *testing.T) { + t.Parallel() + defer PanicRecover(t) + ctx := context.Background() + + resourceGroupName := tc.resourceGroupName + //rglocation doesnot exist + rgLocation := GenerateTestResourceNameWithRandom("cosmos-lo", 10) + + cosmosDBAccountName := GenerateTestResourceNameWithRandom("cosmos-db", 8) + cosmosDBNamespace := "default" + + dbInstance2 := &v1alpha1.CosmosDB{ + ObjectMeta: metav1.ObjectMeta{ + Name: cosmosDBAccountName, + Namespace: cosmosDBNamespace, + }, + Spec: v1alpha1.CosmosDBSpec{ + Location: rgLocation, + ResourceGroup: resourceGroupName, + Kind: v1alpha1.CosmosDBKindGlobalDocumentDB, + Properties: v1alpha1.CosmosDBProperties{ + DatabaseAccountOfferType: v1alpha1.CosmosDBDatabaseAccountOfferTypeStandard, + }, + }, + } + + //error meessage to be expected + errMessage := "The specified location '" + rgLocation + "' is invalid" + + EnsureInstanceWithResult(ctx, t, tc, dbInstance2, errMessage, false) + EnsureDelete(ctx, t, tc, dbInstance2) } diff --git a/controllers/helpers.go b/controllers/helpers.go index 29970bfe123..22153b36a2a 100644 --- a/controllers/helpers.go +++ b/controllers/helpers.go @@ -247,6 +247,15 @@ func EnsureInstanceWithResult(ctx context.Context, t *testing.T, tc TestContext, statused := ConvertToStatus(instance) // if we expect this resource to end up with provisioned == true then failedProvisioning == true is unrecoverable if provisioned && statused.Status.FailedProvisioning { + if strings.Contains(statused.Status.Message, "already exists") || strings.Contains(statused.Status.Message, "AlreadyExists") { + t.Log("") + t.Log("-------") + t.Log("unexpected failed provisioning encountered") + t.Logf("%+v\n", statused.Status) + t.Logf("current time %v\n", time.Now()) + t.Log("-------") + t.Log("") + } return helpers.NewStop(fmt.Errorf("Failed provisioning: %s", statused.Status.Message)) } if !strings.Contains(statused.Status.Message, message) || statused.Status.Provisioned != provisioned { diff --git a/controllers/mysql_combined_test.go b/controllers/mysql_combined_test.go index 59ad4976ec9..a0cc952fbbe 100644 --- a/controllers/mysql_combined_test.go +++ b/controllers/mysql_combined_test.go @@ -23,12 +23,18 @@ func TestMySQLHappyPath(t *testing.T) { rgLocation := "eastus2" rgName := tc.resourceGroupName mySQLServerName := GenerateTestResourceNameWithRandom("mysql-srv", 10) + mySQLReplicaName := GenerateTestResourceNameWithRandom("mysql-rep", 10) // Create the mySQLServer object and expect the Reconcile to be created mySQLServerInstance := azurev1alpha1.NewDefaultMySQLServer(mySQLServerName, rgName, rgLocation) RequireInstance(ctx, t, tc, mySQLServerInstance) + // Create a mySQL replica + mySQLReplicaInstance := azurev1alpha1.NewReplicaMySQLServer(mySQLReplicaName, rgName, rgLocation, mySQLServerInstance.Status.ResourceId) + + EnsureInstance(ctx, t, tc, mySQLReplicaInstance) + mySQLDBName := GenerateTestResourceNameWithRandom("mysql-db", 10) // Create the mySQLDB object and expect the Reconcile to be created @@ -65,4 +71,5 @@ func TestMySQLHappyPath(t *testing.T) { EnsureDelete(ctx, t, tc, ruleInstance) EnsureDelete(ctx, t, tc, mySQLDBInstance) EnsureDelete(ctx, t, tc, mySQLServerInstance) + EnsureDelete(ctx, t, tc, mySQLReplicaInstance) } diff --git a/controllers/postgresql_combined_controller_test.go b/controllers/postgresql_combined_controller_test.go index 31d2c6a12aa..befc6787633 100644 --- a/controllers/postgresql_combined_controller_test.go +++ b/controllers/postgresql_combined_controller_test.go @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -// +build all psql psqldatabase +// +build all psql package controllers @@ -10,24 +10,18 @@ import ( "testing" azurev1alpha1 "github.com/Azure/azure-service-operator/api/v1alpha1" - "github.com/stretchr/testify/assert" - apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/types" ) func TestPSQLDatabaseController(t *testing.T) { t.Parallel() defer PanicRecover(t) ctx := context.Background() - assert := assert.New(t) var rgName string var rgLocation string var postgreSQLServerName string var postgreSQLServerInstance *azurev1alpha1.PostgreSQLServer - var postgreSQLServerNamespacedName types.NamespacedName - var err error // Add any setup steps that needs to be executed before each test rgName = tc.resourceGroupName @@ -56,20 +50,7 @@ func TestPSQLDatabaseController(t *testing.T) { }, } - err = tc.k8sClient.Create(ctx, postgreSQLServerInstance) - assert.Equal(nil, err, "create postgreSQLServerInstance in k8s") - - postgreSQLServerNamespacedName = types.NamespacedName{Name: postgreSQLServerName, Namespace: "default"} - - assert.Eventually(func() bool { - _ = tc.k8sClient.Get(ctx, postgreSQLServerNamespacedName, postgreSQLServerInstance) - return HasFinalizer(postgreSQLServerInstance, finalizerName) - }, tc.timeout, tc.retry, "wait for postgreSQLserver to have finlizer") - - assert.Eventually(func() bool { - _ = tc.k8sClient.Get(ctx, postgreSQLServerNamespacedName, postgreSQLServerInstance) - return postgreSQLServerInstance.Status.Provisioned - }, tc.timeout, tc.retry, "wait for postgreSQLserver to be provisioned") + EnsureInstance(ctx, t, tc, postgreSQLServerInstance) postgreSQLDatabaseName := GenerateTestResourceNameWithRandom("psql-db", 10) @@ -85,28 +66,9 @@ func TestPSQLDatabaseController(t *testing.T) { }, } - err = tc.k8sClient.Create(ctx, postgreSQLDatabaseInstance) - assert.Equal(nil, err, "create postgreSQLDatabaseInstance in k8s") - - postgreSQLDatabaseNamespacedName := types.NamespacedName{Name: postgreSQLDatabaseName, Namespace: "default"} - - assert.Eventually(func() bool { - _ = tc.k8sClient.Get(ctx, postgreSQLDatabaseNamespacedName, postgreSQLDatabaseInstance) - return HasFinalizer(postgreSQLDatabaseInstance, finalizerName) - }, tc.timeout, tc.retry, "wait for postgreSQLDBInstance to have finalizer") - - assert.Eventually(func() bool { - _ = tc.k8sClient.Get(ctx, postgreSQLDatabaseNamespacedName, postgreSQLDatabaseInstance) - return postgreSQLDatabaseInstance.Status.Provisioned - }, tc.timeout, tc.retry, "wait for postgreSQLDBInstance to be provisioned") + EnsureInstance(ctx, t, tc, postgreSQLDatabaseInstance) - err = tc.k8sClient.Delete(ctx, postgreSQLDatabaseInstance) - assert.Equal(nil, err, "delete postgreSQLDatabaseInstance in k8s") - - assert.Eventually(func() bool { - err = tc.k8sClient.Get(ctx, postgreSQLDatabaseNamespacedName, postgreSQLDatabaseInstance) - return apierrors.IsNotFound(err) - }, tc.timeout, tc.retry, "wait for postgreSQLDBInstance to be gone from k8s") + EnsureDelete(ctx, t, tc, postgreSQLDatabaseInstance) // Test firewall rule ------------------------------- @@ -126,36 +88,11 @@ func TestPSQLDatabaseController(t *testing.T) { }, } - err = tc.k8sClient.Create(ctx, postgreSQLFirewallRuleInstance) - assert.Equal(nil, err, "create postgreSQLFirewallRuleInstance in k8s") - - postgreSQLFirewallRuleNamespacedName := types.NamespacedName{Name: postgreSQLFirewallRuleName, Namespace: "default"} - - assert.Eventually(func() bool { - _ = tc.k8sClient.Get(ctx, postgreSQLFirewallRuleNamespacedName, postgreSQLFirewallRuleInstance) - return HasFinalizer(postgreSQLFirewallRuleInstance, finalizerName) - }, tc.timeout, tc.retry, "wait for postgreSQLFirewallRuleInstance to have finalizer") + EnsureInstance(ctx, t, tc, postgreSQLFirewallRuleInstance) - assert.Eventually(func() bool { - _ = tc.k8sClient.Get(ctx, postgreSQLFirewallRuleNamespacedName, postgreSQLFirewallRuleInstance) - return postgreSQLFirewallRuleInstance.Status.Provisioned - }, tc.timeout, tc.retry, "wait for postgreSQLFirewallRuleInstance to be provisioned") - - err = tc.k8sClient.Delete(ctx, postgreSQLFirewallRuleInstance) - assert.Equal(nil, err, "delete postgreSQLFirewallRuleInstance in k8s") - - assert.Eventually(func() bool { - err = tc.k8sClient.Get(ctx, postgreSQLFirewallRuleNamespacedName, postgreSQLFirewallRuleInstance) - return apierrors.IsNotFound(err) - }, tc.timeout, tc.retry, "wait for postgreSQLFirewallRuleInstance to be gone from k8s") + EnsureDelete(ctx, t, tc, postgreSQLFirewallRuleInstance) // Add any teardown steps that needs to be executed after each test - err = tc.k8sClient.Delete(ctx, postgreSQLServerInstance) - assert.Equal(nil, err, "delete postgreSQLServerInstance in k8s") - - assert.Eventually(func() bool { - err = tc.k8sClient.Get(ctx, postgreSQLServerNamespacedName, postgreSQLServerInstance) - return apierrors.IsNotFound(err) - }, tc.timeout, tc.retry, "wait for postgreSQLServerInstance to be gone from k8s") + EnsureDelete(ctx, t, tc, postgreSQLServerInstance) } diff --git a/controllers/postgresqldatabase_controller_test.go b/controllers/postgresqldatabase_controller_test.go new file mode 100644 index 00000000000..2254fee4212 --- /dev/null +++ b/controllers/postgresqldatabase_controller_test.go @@ -0,0 +1,73 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// +build all psqldatabase + +package controllers + +import ( + "context" + "testing" + + azurev1alpha1 "github.com/Azure/azure-service-operator/api/v1alpha1" + "github.com/Azure/azure-service-operator/pkg/errhelp" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +//Postgresql database controller unhappy test cases + +func TestPSQLDatabaseControllerNoResourceGroup(t *testing.T) { + t.Parallel() + defer PanicRecover(t) + ctx := context.Background() + + // Add any setup steps that needs to be executed before each test + rgName := GenerateTestResourceNameWithRandom("psqlsrv-rg", 10) + + postgreSQLServerName := GenerateTestResourceNameWithRandom("psql-srv", 10) + postgreSQLDatabaseName := GenerateTestResourceNameWithRandom("psql-db", 10) + + // Create the PostgreSQLDatabase object and expect the Reconcile to be created + postgreSQLDatabaseInstance1 := &azurev1alpha1.PostgreSQLDatabase{ + ObjectMeta: metav1.ObjectMeta{ + Name: postgreSQLDatabaseName, + Namespace: "default", + }, + Spec: azurev1alpha1.PostgreSQLDatabaseSpec{ + ResourceGroup: rgName, + Server: postgreSQLServerName, + }, + } + + EnsureInstanceWithResult(ctx, t, tc, postgreSQLDatabaseInstance1, errhelp.ResourceGroupNotFoundErrorCode, false) + EnsureDelete(ctx, t, tc, postgreSQLDatabaseInstance1) + +} + +func TestPSQLDatabaseControllerNoSever(t *testing.T) { + t.Parallel() + defer PanicRecover(t) + ctx := context.Background() + + // Add any setup steps that needs to be executed before each test + rgName := tc.resourceGroupName + + postgreSQLServerName := GenerateTestResourceNameWithRandom("psql-srv", 10) + postgreSQLDatabaseName := GenerateTestResourceNameWithRandom("psql-db", 10) + + // Create the PostgreSQLDatabase object and expect the Reconcile to be created + postgreSQLDatabaseInstance2 := &azurev1alpha1.PostgreSQLDatabase{ + ObjectMeta: metav1.ObjectMeta{ + Name: postgreSQLDatabaseName, + Namespace: "default", + }, + Spec: azurev1alpha1.PostgreSQLDatabaseSpec{ + ResourceGroup: rgName, + Server: postgreSQLServerName, + }, + } + + EnsureInstanceWithResult(ctx, t, tc, postgreSQLDatabaseInstance2, errhelp.ResourceNotFound, false) + EnsureDelete(ctx, t, tc, postgreSQLDatabaseInstance2) + +} diff --git a/controllers/postgresqlfirewallrule_controller_test.go b/controllers/postgresqlfirewallrule_controller_test.go new file mode 100644 index 00000000000..4805e8bb26e --- /dev/null +++ b/controllers/postgresqlfirewallrule_controller_test.go @@ -0,0 +1,75 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// +build all psqlfirewallrule + +package controllers + +import ( + "context" + "testing" + + azurev1alpha1 "github.com/Azure/azure-service-operator/api/v1alpha1" + "github.com/Azure/azure-service-operator/pkg/errhelp" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestPSQLFirewallRuleControllerNoResourceGroup(t *testing.T) { + t.Parallel() + defer PanicRecover(t) + ctx := context.Background() + + // Add any setup steps that needs to be executed before each test + rgName := GenerateTestResourceNameWithRandom("psqlsrv-rg", 10) + + postgreSQLServerName := GenerateTestResourceNameWithRandom("psql-srv", 10) + postgreSQLFirewallRuleName := GenerateTestResourceNameWithRandom("psql-fwrule", 10) + + // Create the PostgreSQLFirewallRule object and expect the Reconcile to be created + postgreSQLFirewallRuleInstance := &azurev1alpha1.PostgreSQLFirewallRule{ + ObjectMeta: metav1.ObjectMeta{ + Name: postgreSQLFirewallRuleName, + Namespace: "default", + }, + Spec: azurev1alpha1.PostgreSQLFirewallRuleSpec{ + ResourceGroup: rgName, + Server: postgreSQLServerName, + StartIPAddress: "0.0.0.0", + EndIPAddress: "0.0.0.0", + }, + } + + EnsureInstanceWithResult(ctx, t, tc, postgreSQLFirewallRuleInstance, errhelp.ResourceGroupNotFoundErrorCode, false) + EnsureDelete(ctx, t, tc, postgreSQLFirewallRuleInstance) + +} + +func TestPSQLFirewallRuleControllerNoServer(t *testing.T) { + t.Parallel() + defer PanicRecover(t) + ctx := context.Background() + + // Add any setup steps that needs to be executed before each test + rgName := tc.resourceGroupName + + postgreSQLServerName := GenerateTestResourceNameWithRandom("psql-srv", 10) + postgreSQLFirewallRuleName := GenerateTestResourceNameWithRandom("psql-fwrule", 10) + + // Create the PostgreSQLFirewallRule object and expect the Reconcile to be created + postgreSQLFirewallRuleInstance := &azurev1alpha1.PostgreSQLFirewallRule{ + ObjectMeta: metav1.ObjectMeta{ + Name: postgreSQLFirewallRuleName, + Namespace: "default", + }, + Spec: azurev1alpha1.PostgreSQLFirewallRuleSpec{ + ResourceGroup: rgName, + Server: postgreSQLServerName, + StartIPAddress: "0.0.0.0", + EndIPAddress: "0.0.0.0", + }, + } + + EnsureInstanceWithResult(ctx, t, tc, postgreSQLFirewallRuleInstance, errhelp.ResourceNotFound, false) + EnsureDelete(ctx, t, tc, postgreSQLFirewallRuleInstance) + +} diff --git a/controllers/postgresqlserver_controller_test.go b/controllers/postgresqlserver_controller_test.go new file mode 100644 index 00000000000..c97948d06d6 --- /dev/null +++ b/controllers/postgresqlserver_controller_test.go @@ -0,0 +1,52 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// +build all psqlserver + +package controllers + +import ( + "context" + "testing" + + azurev1alpha1 "github.com/Azure/azure-service-operator/api/v1alpha1" + "github.com/Azure/azure-service-operator/pkg/errhelp" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +//test Postgre SQL server unhappy path +func TestPSQLServerControllerNoResourceGroup(t *testing.T) { + t.Parallel() + defer PanicRecover(t) + ctx := context.Background() + + // Add any setup steps that needs to be executed before each test + rgName := GenerateTestResourceNameWithRandom("psqlsrv-rg", 10) + rgLocation := tc.resourceGroupLocation + + postgreSQLServerName := GenerateTestResourceNameWithRandom("psql-srv", 10) + + // Create the PostgreSQLServer object and expect the Reconcile to be created + postgreSQLServerInstance := &azurev1alpha1.PostgreSQLServer{ + ObjectMeta: metav1.ObjectMeta{ + Name: postgreSQLServerName, + Namespace: "default", + }, + Spec: azurev1alpha1.PostgreSQLServerSpec{ + Location: rgLocation, + ResourceGroup: rgName, + Sku: azurev1alpha1.AzureDBsSQLSku{ + Name: "B_Gen5_2", + Tier: azurev1alpha1.SkuTier("Basic"), + Family: "Gen5", + Size: "51200", + Capacity: 2, + }, + ServerVersion: azurev1alpha1.ServerVersion("10"), + SSLEnforcement: azurev1alpha1.SslEnforcementEnumEnabled, + }, + } + EnsureInstanceWithResult(ctx, t, tc, postgreSQLServerInstance, errhelp.ResourceGroupNotFoundErrorCode, false) + EnsureDelete(ctx, t, tc, postgreSQLServerInstance) + +} diff --git a/controllers/storageaccount_controller_test.go b/controllers/storageaccount_controller_test.go index fca6e8f4ea0..6b82b83af33 100644 --- a/controllers/storageaccount_controller_test.go +++ b/controllers/storageaccount_controller_test.go @@ -10,18 +10,15 @@ import ( "testing" azurev1alpha1 "github.com/Azure/azure-service-operator/api/v1alpha1" - "github.com/Azure/go-autorest/autorest/to" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) -func TestStorageControllerHappyPath(t *testing.T) { +func TestStorageControllerHappyPathWithoutNetworkRule(t *testing.T) { t.Parallel() defer PanicRecover(t) ctx := context.Background() - StorageAccountName := GenerateAlphaNumTestResourceName("sadev") - // Create the ResourceGroup object and expect the Reconcile to be created saInstance := &azurev1alpha1.StorageAccount{ ObjectMeta: metav1.ObjectMeta{ @@ -39,10 +36,8 @@ func TestStorageControllerHappyPath(t *testing.T) { EnableHTTPSTrafficOnly: to.BoolPtr(true), }, } - // create rg EnsureInstance(ctx, t, tc, saInstance) - // delete rg EnsureDelete(ctx, t, tc, saInstance) } diff --git a/controllers/suite_test.go b/controllers/suite_test.go index fdb064b4c00..337d905aca7 100644 --- a/controllers/suite_test.go +++ b/controllers/suite_test.go @@ -154,7 +154,7 @@ func setup() error { secretClient, scheme.Scheme, ) - cosmosDbManager = resourcemanagercosmosdb.NewAzureCosmosDBManager() + cosmosDbManager = resourcemanagercosmosdb.NewAzureCosmosDBManager(secretClient) apiMgmtManager = resourcemanagerapimgmt.NewManager() resourceGroupManager = resourcegroupsresourcemanager.NewAzureResourceGroupManager() eventHubManagers = resourcemanagereventhub.AzureEventHubManagers @@ -745,9 +745,9 @@ func setup() error { log.Println("Creating SA:", storageAccountName) // Create the Storage Account and Container - _, _ = storageAccountManager.CreateStorage(context.Background(), resourceGroupName, storageAccountName, resourcegroupLocation, azurev1alpha1.StorageAccountSku{ + _, _, _ = storageAccountManager.CreateStorage(context.Background(), resourceGroupName, storageAccountName, resourcegroupLocation, azurev1alpha1.StorageAccountSku{ Name: "Standard_LRS", - }, "Storage", map[string]*string{}, "", nil, nil) + }, "Storage", map[string]*string{}, "", nil, nil, nil) // Storage account needs to be in "Suceeded" state // for container create to succeed diff --git a/docs/apimgmt/apimgmt.md b/docs/apimgmt/apimgmt.md index e69de29bb2d..bcf9a46cf90 100644 --- a/docs/apimgmt/apimgmt.md +++ b/docs/apimgmt/apimgmt.md @@ -0,0 +1,63 @@ +# API Management Operator + +The API Management suite is made up of the following operators: +* API Management Service +* API Management API + +Learn more about Azure API Management [here](https://docs.microsoft.com/en-us/azure/api-management/api-management-key-concepts). + +### API Management Service + +The API Management Service deploys an API Management instance into a specified resource group at the specified location. It also provides the option to link to an Application Insights instance for logging, and to place the API Management instance in a specified Virtual Network. + +Here is a [sample YAML](/config/samples/azure_v1alpha1_apimservice.yaml) to provision an API Management Service. + +The spec consists of the following fields: + +#### Required Fields +* `Tier` Specify the tier of the service. Options include: 'basic', 'standard', and 'premium'. +* `PublisherName` Specify the name of the publisher. +* `PublisherEmail` Specify the email of the publisher. + +#### Optional Fields +* `VnetType` Specify the type of vnet for the service. Options include: 'none', 'internal', and 'external'. If selecting either 'internal' or 'external', make sure to set the `Tier` to 'premium'. +* `VnetResourceGroup` Resource group of the Virtual Network +* `VnetName` Name of the Virtual Network +* `VnetSubnetName` Name of the Virtual Network Subnet +* `AppInsightsResourceGroup` Resource group of the Application Insights instance +* `AppInsightsName` Name of the Application Insights instance + +### API Management API + +The API Management API Operator creates an API with the specified properties in the specified API Management service. + +Here is a [sample YAML](/config/samples/azure_v1alpha1_apimgmtapi.yaml) to provision an API Management API. + +The spec consists of the following fields: + +* `apiService` The name of the API Management service to manage +* `apiId` Specify an ID for the API +* `properties` + * `apiRevision` Describes the Revision of the API. If no value is provided, default revision 1 is created + * `apiRevisionDescription` Description of the API Revision + * `apiVersion` Indicates the Version identifier of the API if the API is versioned + * `apiVersionDescription` Description of the API Version + * `apiVersionSet` API Version Set contains the common configuration for a set of API versions + * `id` Identifier for existing API Version Set. Omit this value to create a new Version Set. + * `name` The display Name of the API Version Set. + * `description` Description of API Version Set. + * `apiVersionSetID` A resource identifier for the related ApiVersionSet + * `description` Description of the API + * `displayName` Display name for the API. Must be 1 to 300 characters long + * `format` Format of the Content in which the API is getting imported. Possible values include: 'WadlXML', 'WadlLinkJSON', 'SwaggerJSON', 'SwaggerLinkJSON', 'Wsdl', 'WsdlLink', 'Openapi', 'Openapijson', 'OpenapiLink' + * `isCurrent` Indicate if this API revision is the current revision + * `isOnline` Indicate if this API is accessible via the gateway + * `path` Path for the API + * `protocols` Describes on which protocols the operations in this API can be invoked. Possible values are: 'http' or 'https' + * `serviceURL` Absolute URL of the backend service implementing this API. Cannot be more than 2000 characters long + * `sourceAPIID` API identifier of the source API + * `subscriptionRequired` Specify whether an API or Product subscription is required for accessing the API + +## Deploy, view and delete resources + +You can follow the steps [here](/docs/customresource.md) to deploy, view and delete resources. diff --git a/docs/mysql/mysql.md b/docs/mysql/mysql.md new file mode 100644 index 00000000000..4185aac9197 --- /dev/null +++ b/docs/mysql/mysql.md @@ -0,0 +1,60 @@ +# MySQL Operator + +## Resources Supported + +The MySQL operator suite consists of the following operators. + +1. MySQL server - Deploys an `Azure Database for MySQL server` given the Location, Resource group and other properties. This operator also helps creating read replicas for MySQL server. +2. MySQL database - Deploys a database under the given `Azure Database for MySQL server` +3. MySQL firewall rule - Deploys a firewall rule to allow access to the `Azure Database for MySQL server` from the specified IP range + +### MySQL server + +Here is a [sample YAML](/config/samples/azure_v1alpha1_mysqlserver.yaml) for the MySQL server. + +The value for kind, `MySQLServer` is the Custom Resource Definition (CRD) name. +`mysqlserver-sample` is the name of the MySQL server resource that will be created. + +The values under `spec` provide the values for the location where you want to create the server at and the Resource group in which you want to create it under. It also contains other values that are required to create the server like the `serverVersion`, `sslEnforcement` and the `sku` information. + +Along with creating the MySQL server, this operator also generates the admin username and password for the MySQL server and stores it in a kube secret or keyvault (based on what is specified) with the same name as the MySQL server. + +This secret contains the following fields. + +- `fullyqualifiedservername` : Fully qualified name of the MySQL server such as mysqlserver.mysql.database.azure.com +- `mysqlservername` : MySQL server name +- `username` : Server admin +- `password` : Password for the server admin +- `fullyqualifiedusername` : Fully qualified user name that is required by some apps such as @ + +For more information on where and how secrets are stored, look [here](/docs/secrets.md) + +#### Read Replicas in Azure Database for MySQL + +The MySQL server operator can also be used to create Read Replicas given the `sourceserverid` and the `location`. + +The replica inherits all other properties including the admin username and password from the source server. + +The operator reads the admin username and password for the source server from its secret (if available) and creates a secret with the same fields as described above for the replica. + +For more information on read replicas, refer [here](https://docs.microsoft.com/en-us/azure/mysql/concepts-read-replicas) + +### MySQL Database + +Here is a [sample YAML](/config/samples/azure_v1alpha1_mysqldatabase.yaml) for MySQL database + +Update the `resourcegroup` to where you want to provision the MySQL database. `server` is the name of the MySQL server where you want to create the database in. + +### MySQL firewall rule + +The MySQL firewall rule operator allows you to add a firewall rule to the MySQL server. + +Here is a [sample YAML](/config/samples/azure_v1alpha1_mysqlfirewallrule.yaml) for MySQL firewall rule + +The `server` indicates the MySQL server on which you want to configure the new MySQL firewall rule on and `resourceGroup` is the resource group of the MySQL server. The `startIpAddress` and `endIpAddress` indicate the IP range of sources to allow access to the server. + +*Note*: When the `startIpAddress` and `endIpAddress` are 0.0.0.0, it denotes a special case that adds a firewall rule to allow all Azure services to access the server. + +## Deploy, view and delete resources + +You can follow the steps [here](/docs/customresource.md) to deploy, view and delete resources. diff --git a/docs/virtualmachine/virtualmachine.md b/docs/virtualmachine/virtualmachine.md new file mode 100644 index 00000000000..1bd1854f60f --- /dev/null +++ b/docs/virtualmachine/virtualmachine.md @@ -0,0 +1,37 @@ +# Virtual Machine Operator + +This operator deploys an Azure Virtual Machine (VM) into a specified resource group at the specified location. Users can specify platform image, size, user name and public SSH key, etc. for the VM. + +Note that in the current version you can only create VM using Linux platform images. + +Learn more about Azure Virtual Machine [here](https://docs.microsoft.com/en-us/rest/api/compute/virtualmachines). + +Here is a [sample YAML](/config/samples/azure_v1alpha1_azurevirtualmachine.yaml) to provision a Virtual Machine. + +The spec is comprised of the following fields: + +* Location +* ResourceGroup +* VMSize +* AdminUserName +* SshPublicKeyData +* NetworkInterfaceName +* PlatformImageURN + +### Required Fields + +A Virtual Machine needs the following fields to deploy, along with a location and resource group. + +* `VMSize` specify the VM size for the virtual machine +* `AdminUserName` specify the user name for the virtual machine +* `SshPublicKeyData` specify the SSH public key data for loging into the virtual machine +* `NetworkInterfaceName` specify the network interface that the VM will use +* `PlatformImageURN` specify the platform image's uniform resource name (URN) in the 'publisher:offer:sku:version' format. + +### Optional Fields + +Not available. + +## Deploy, view and delete resources + +You can follow the steps [here](/docs/customresource.md) to deploy, view and delete resources. diff --git a/docs/virtualnetwork/networkinterface.md b/docs/virtualnetwork/networkinterface.md new file mode 100644 index 00000000000..ec090426548 --- /dev/null +++ b/docs/virtualnetwork/networkinterface.md @@ -0,0 +1,31 @@ +# Network Interface Operator + +This operator deploys an Azure Network Interface (NIC) into a specified resource group at the specified location. Users can specify underlying public IP address and virtual network configurations in their NIC setup. + +Learn more about Azure Network Interface [here](https://docs.microsoft.com/en-us/azure/virtual-network/virtual-network-network-interface). + +Here is a [sample YAML](/config/samples/azure_v1alpha1_azurenetworkinterface.yaml) to provision a Network Interface. + +The spec is comprised of the following fields: + +* Location +* ResourceGroup +* VNetName +* SubnetName +* PublicIPAddressName + +### Required Fields + +A Network Interface needs the following fields to deploy, along with a location and resource group. + +* `VNetName` specify the name for the virtual network that the network interface belongs to +* `SubnetName` specify the name for the subnet that the network interface belongs to +* `PublicIPAddressName` specify the name for the public IP address that the network interface uses + +### Optional Fields + +Not available. + +## Deploy, view and delete resources + +You can follow the steps [here](/docs/customresource.md) to deploy, view and delete resources. diff --git a/docs/virtualnetwork/publicipaddress.md b/docs/virtualnetwork/publicipaddress.md new file mode 100644 index 00000000000..adc1ea63e2d --- /dev/null +++ b/docs/virtualnetwork/publicipaddress.md @@ -0,0 +1,33 @@ +# Public IP Address Operator + +This operator deploys an Azure Public IP Address (PIP) into a specified resource group at the specified location. Users can specify IP allocation method, idle timeout, IP address version, and SKU. + +Learn more about Azure Public IP Address [here](https://docs.microsoft.com/en-us/azure/virtual-network/virtual-network-ip-addresses-overview-arm). + +Here is a [sample YAML](/config/samples/azure_v1alpha1_azurepublicipaddress.yaml) to provision a Public IP Address. + +The spec is comprised of the following fields: + +* Location +* ResourceGroup +* PublicIPAllocationMethod +* IdleTimeoutInMinutes +* PublicIPAddressVersion +* SkuName + +### Required Fields + +A Public IP Address needs the following fields to deploy, along with a location and resource group. + +* `PublicIPAllocationMethod` specify the allocation method for the public IP address, either 'Static' or 'Dynamic' +* `IdleTimeoutInMinutes` specify the idle timeout value (in minutes) for the public IP address +* `PublicIPAddressVersion` specify the version for the public IP address, either 'IPv4' or 'IPv6' +* `SkuName` specify the SKU name for the public IP address, either 'Basic' or 'Standard' + +### Optional Fields + +Not available. + +## Deploy, view and delete resources + +You can follow the steps [here](/docs/customresource.md) to deploy, view and delete resources. diff --git a/go.mod b/go.mod index aeb2caafcb5..eadaa38c31b 100644 --- a/go.mod +++ b/go.mod @@ -27,7 +27,7 @@ require ( github.com/satori/go.uuid v1.2.0 github.com/sethvargo/go-password v0.1.2 github.com/stretchr/testify v1.5.1 - golang.org/x/crypto v0.0.0-20200311171314-f7b00557c8c4 // indirect + golang.org/x/crypto v0.0.0-20200311171314-f7b00557c8c4 golang.org/x/net v0.0.0-20190620200207-3b0461eec859 golang.org/x/sys v0.0.0-20190621203818-d432491b9138 // indirect gopkg.in/yaml.v2 v2.2.8 // indirect diff --git a/go.sum b/go.sum index 7ea6274b6b7..50237bd6893 100644 --- a/go.sum +++ b/go.sum @@ -9,6 +9,7 @@ github.com/Azure/azure-sdk-for-go v38.0.0+incompatible/go.mod h1:9XXNKU+eRnpl9mo github.com/Azure/azure-sdk-for-go v40.6.0+incompatible h1:ULjp/a/UsBfnZcl45jjywhcBKex/k/A1cG9s9NapLFw= github.com/Azure/azure-sdk-for-go v41.0.0+incompatible h1:nQc4CAuBSr8rO0aZ90NvHoKyWYodhtzSAS4DPDrCtqo= github.com/Azure/azure-sdk-for-go v41.1.0+incompatible h1:AkS9XaeC8TDd0W0UiJRnEcYEHBO6xzILqqswHiNlSZQ= +github.com/Azure/azure-sdk-for-go v41.2.0+incompatible h1:JOlv1wDuxcJi1ExJpQLNfEj6znsTFt2TiwQMow2YaXI= github.com/Azure/go-autorest/autorest v0.1.0/go.mod h1:AKyIcETwSUFxIcs/Wnq/C+kwCtlEYGUVd7FPNb2slmg= github.com/Azure/go-autorest/autorest v0.5.0 h1:Mlm9qy2fpQ9MvfyI41G2Zf5B4CsgjjNbLOWszfK6KrY= github.com/Azure/go-autorest/autorest v0.5.0/go.mod h1:9HLKlQjVBH6U3oDfsXOeVc56THsLPw1L03yban4xThw= diff --git a/main.go b/main.go index 995bd302a06..b675d0a7b3e 100644 --- a/main.go +++ b/main.go @@ -38,6 +38,7 @@ import ( resourcemanagerstorage "github.com/Azure/azure-service-operator/pkg/resourcemanager/storages" blobContainerManager "github.com/Azure/azure-service-operator/pkg/resourcemanager/storages/blobcontainer" storageaccountManager "github.com/Azure/azure-service-operator/pkg/resourcemanager/storages/storageaccount" + vm "github.com/Azure/azure-service-operator/pkg/resourcemanager/vm" vnet "github.com/Azure/azure-service-operator/pkg/resourcemanager/vnet" "github.com/Azure/azure-service-operator/pkg/secrets" keyvaultSecrets "github.com/Azure/azure-service-operator/pkg/secrets/keyvault" @@ -126,6 +127,9 @@ func main() { ) eventhubNamespaceClient := resourcemanagereventhub.NewEventHubNamespaceClient() consumerGroupClient := resourcemanagereventhub.NewConsumerGroupClient() + cosmosDBClient := resourcemanagercosmosdb.NewAzureCosmosDBManager( + secretClient, + ) storageManagers := resourcemanagerstorage.AzureStorageManagers keyVaultManager := resourcemanagerkeyvault.NewAzureKeyVaultManager(mgr.GetScheme()) keyVaultKeyManager := &resourcemanagerkeyvault.KeyvaultKeyClient{ @@ -171,7 +175,7 @@ func main() { err = (&controllers.CosmosDBReconciler{ Reconciler: &controllers.AsyncReconciler{ Client: mgr.GetClient(), - AzureClient: resourcemanagercosmosdb.NewAzureCosmosDBManager(), + AzureClient: cosmosDBClient, Telemetry: telemetry.InitializeTelemetryDefault( "CosmosDB", ctrl.Log.WithName("controllers").WithName("CosmosDB"), @@ -640,6 +644,26 @@ func main() { setupLog.Error(err, "unable to create controller", "controller", "NetworkInterface") os.Exit(1) } + + if err = (&controllers.AzureVirtualMachineReconciler{ + Reconciler: &controllers.AsyncReconciler{ + Client: mgr.GetClient(), + AzureClient: vm.NewAzureVirtualMachineClient( + secretClient, + mgr.GetScheme(), + ), + Telemetry: telemetry.InitializeTelemetryDefault( + "VirtualMachine", + ctrl.Log.WithName("controllers").WithName("VirtualMachine"), + ), + Recorder: mgr.GetEventRecorderFor("VirtualMachine-controller"), + Scheme: scheme, + }, + }).SetupWithManager(mgr); err != nil { + setupLog.Error(err, "unable to create controller", "controller", "VirtualMachine") + os.Exit(1) + } + // +kubebuilder:scaffold:builder setupLog.Info("starting manager") diff --git a/pkg/errhelp/errhelp.go b/pkg/errhelp/errhelp.go index 58008895018..d2614f2e823 100644 --- a/pkg/errhelp/errhelp.go +++ b/pkg/errhelp/errhelp.go @@ -49,6 +49,7 @@ func StripErrorIDs(err error) string { patterns := []string{ "RequestID=", "CorrelationId:\\s", + "Tracking ID: ", } reg := regexp.MustCompile(fmt.Sprintf(`(%s)\S+`, strings.Join(patterns, "|"))) return reg.ReplaceAllString(err.Error(), "") diff --git a/pkg/errhelp/errors.go b/pkg/errhelp/errors.go index 38f676ef77e..e1f0f469859 100644 --- a/pkg/errhelp/errors.go +++ b/pkg/errhelp/errors.go @@ -42,6 +42,7 @@ const ( NotFoundErrorCode = "NotFound" NoSuchHost = "no such host" ParentNotFoundErrorCode = "ParentResourceNotFound" + PreconditionFailed = "PreconditionFailed" QuotaExceeded = "QuotaExceeded" ResourceGroupNotFoundErrorCode = "ResourceGroupNotFound" RegionDoesNotAllowProvisioning = "RegionDoesNotAllowProvisioning" @@ -55,6 +56,9 @@ const ( ServiceBusy = "ServiceBusy" NameNotAvailable = "NameNotAvailable" PublicIPIdleTimeoutIsOutOfRange = "PublicIPIdleTimeoutIsOutOfRange" + InvalidRequestContent = "InvalidRequestContent" + InternalServerError = "InternalServerError" + NetworkAclsValidationFailure = "NetworkAclsValidationFailure" ) func NewAzureError(err error) error { diff --git a/pkg/helpers/helpers_strings_test.go b/pkg/helpers/helpers_strings_test.go index 4bdf31dc9cc..6ac1dee4744 100644 --- a/pkg/helpers/helpers_strings_test.go +++ b/pkg/helpers/helpers_strings_test.go @@ -17,3 +17,53 @@ func TestGenerateRandomUsername(t *testing.T) { } } } + +func TestMakeResourceIDWithSubResource(t *testing.T) { + testOutput := MakeResourceID( + "00000000-0000-0000-0000-000000000000", + "test", + "Microsoft.Network", + "networkInterfaces", + "test", + "subnets", + "test", + ) + expectedOutput := "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/test/providers/Microsoft.Network/networkInterfaces/test/subnets/test" + + if testOutput != expectedOutput { + t.Errorf("Test output string '%s' is not as expected: '%s'.", testOutput, expectedOutput) + } +} + +func TestMakeResourceIDWithNoSubResource(t *testing.T) { + testOutput := MakeResourceID( + "00000000-0000-0000-0000-000000000000", + "test", + "Microsoft.Network", + "networkInterfaces", + "test", + "", + "", + ) + expectedOutput := "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/test/providers/Microsoft.Network/networkInterfaces/test" + + if testOutput != expectedOutput { + t.Errorf("Test output string '%s' is not as expected: '%s'.", testOutput, expectedOutput) + } +} + +func TestDecodingFromBase64EncodedString(t *testing.T) { + testOutput1 := FromBase64EncodedString("dGVzdA==") + expectedOutput1 := "test" + + if testOutput1 != expectedOutput1 { + t.Errorf("Test output string '%s' is not as expected: '%s'.", testOutput1, expectedOutput1) + } + + testOutput2 := FromBase64EncodedString("") + expectedOutput2 := "" + + if testOutput2 != expectedOutput2 { + t.Errorf("Test output string '%s' is not as expected: '%s'.", testOutput2, expectedOutput2) + } +} diff --git a/pkg/helpers/stringhelper.go b/pkg/helpers/stringhelper.go index 7f150a24993..6c269e260c6 100644 --- a/pkg/helpers/stringhelper.go +++ b/pkg/helpers/stringhelper.go @@ -5,6 +5,7 @@ package helpers import ( "crypto/sha256" + "encoding/base64" "encoding/json" "fmt" "math/rand" @@ -159,3 +160,38 @@ func ReplaceAny(s string, chars []string) string { reg := regexp.MustCompile(fmt.Sprintf(`(%s)`, strings.Join(chars, "|"))) return reg.ReplaceAllString(s, ".") } + +// MakeResourceID can be used to construct a resource ID using the input segments +// Sample 1: /subscriptions/88fd8cb2-8248-499e-9a2d-4929a4b0133c/resourceGroups/resourcegroup-azure-operators/providers/Microsoft.Network/publicIPAddresses/azurepublicipaddress-sample-3 +// Sample 2: /subscriptions/88fd8cb2-8248-499e-9a2d-4929a4b0133c/resourceGroups/resourcegroup-azure-operators/providers/Microsoft.Network/virtualNetworks/vnet-sample-hpf-1/subnets/test2 +func MakeResourceID(subscriptionID string, resourceGroupName string, provider string, resourceType string, resourceName string, subResourceType string, subResourceName string) string { + segments := []string{ + "subscriptions", + subscriptionID, + "resourceGroups", + resourceGroupName, + "providers", + provider, + resourceType, + resourceName, + } + + if subResourceType != "" && subResourceName != "" { + segments = append(segments, subResourceType, subResourceName) + } + + result := "/" + strings.Join(segments, "/") + + return result +} + +// FromBase64EncodedString can be used to decode a base64 encoded string into another string in the return. +func FromBase64EncodedString(input string) string { + output, err := base64.StdEncoding.DecodeString(input) + if err != nil { + _ = fmt.Errorf("cannot decode input string '%s' with error '%s'", input, err) + } + + decodedString := string(output) + return decodedString +} diff --git a/pkg/resourcemanager/azuresql/azuresqlaction/azuresqlaction_reconcile.go b/pkg/resourcemanager/azuresql/azuresqlaction/azuresqlaction_reconcile.go index 0dbc30db6d1..7cc5a5615cd 100644 --- a/pkg/resourcemanager/azuresql/azuresqlaction/azuresqlaction_reconcile.go +++ b/pkg/resourcemanager/azuresql/azuresqlaction/azuresqlaction_reconcile.go @@ -67,11 +67,17 @@ func (s *AzureSqlActionManager) Ensure(ctx context.Context, obj runtime.Object, if helpers.ContainsString(catch, azerr.Type) { return false, nil //requeue until server/RG ready } - return true, nil // unrecoverable error + + // unrecoverable error + instance.Status.Provisioned = false + instance.Status.Provisioning = false + instance.Status.FailedProvisioning = true + return true, nil } instance.Status.Provisioned = true instance.Status.Provisioning = false + instance.Status.FailedProvisioning = false instance.Status.Message = resourcemanager.SuccessMsg } @@ -111,16 +117,32 @@ func (s *AzureSqlActionManager) Ensure(ctx context.Context, obj runtime.Object, err := s.UpdateUserPassword(ctx, groupName, serverName, instance.Spec.DbUser, instance.Spec.DbName, adminKey, adminSecretClient, userSecretClient) if err != nil { - instance.Status.Message = err.Error() - return true, nil // unrecoverable error + instance.Status.Message = errhelp.StripErrorIDs(err) + + // catch firewall issue - keep cycling until it clears up + if strings.Contains(err.Error(), "create a firewall rule for this IP address") { + instance.Status.Provisioned = false + instance.Status.Provisioning = false + return false, nil + } + + // unrecoverable error + instance.Status.Provisioned = false + instance.Status.Provisioning = false + instance.Status.FailedProvisioning = true + return true, nil } instance.Status.Provisioned = true instance.Status.Provisioning = false + instance.Status.FailedProvisioning = false instance.Status.Message = resourcemanager.SuccessMsg } } else { instance.Status.Message = "Unrecognized action" + instance.Status.Provisioned = false + instance.Status.Provisioning = false + instance.Status.FailedProvisioning = true } return true, nil diff --git a/pkg/resourcemanager/azuresql/azuresqldb/azuresqldb_reconcile.go b/pkg/resourcemanager/azuresql/azuresqldb/azuresqldb_reconcile.go index db505b1806b..e6947b01aa1 100644 --- a/pkg/resourcemanager/azuresql/azuresqldb/azuresqldb_reconcile.go +++ b/pkg/resourcemanager/azuresql/azuresqldb/azuresqldb_reconcile.go @@ -6,6 +6,7 @@ package azuresqldb import ( "context" "fmt" + "strings" azurev1alpha1 "github.com/Azure/azure-service-operator/api/v1alpha1" "github.com/Azure/azure-service-operator/pkg/errhelp" @@ -65,18 +66,16 @@ func (db *AzureSqlDbManager) Ensure(ctx context.Context, obj runtime.Object, opt instance.Status.Message = resourcemanager.SuccessMsg instance.Status.ResourceId = *dbGet.ID return true, nil - } else { - azerr := errhelp.NewAzureErrorAzureError(err) - ignore := []string{ - errhelp.NotFoundErrorCode, - errhelp.ResourceNotFound, - errhelp.ResourceGroupNotFoundErrorCode, - } - if !helpers.ContainsString(ignore, azerr.Type) { - instance.Status.Message = err.Error() - instance.Status.Provisioning = false - return false, fmt.Errorf("AzureSqlDb GetDB error %v", err) - } + } + instance.Status.Message = fmt.Sprintf("AzureSqlDb Get error %s", err.Error()) + azerr := errhelp.NewAzureErrorAzureError(err) + requeuErrors := []string{ + errhelp.ParentNotFoundErrorCode, + errhelp.ResourceGroupNotFoundErrorCode, + } + if helpers.ContainsString(requeuErrors, azerr.Type) { + instance.Status.Provisioning = false + return false, nil } resp, err := db.CreateOrUpdateDB(ctx, groupName, location, server, labels, azureSQLDatabaseProperties) @@ -100,6 +99,13 @@ func (db *AzureSqlDbManager) Ensure(ctx context.Context, obj runtime.Object, opt return false, nil } + // if the database is busy, requeue + errorString := err.Error() + if strings.Contains(errorString, "Try again later") { + instance.Status.Provisioning = false + return false, nil + } + // assertion that a 404 error implies that the Azure SQL server hasn't been provisioned yet if resp != nil && resp.StatusCode == 404 { instance.Status.Message = fmt.Sprintf("Waiting for SQL Server %s to provision", server) diff --git a/pkg/resourcemanager/azuresql/azuresqlfailovergroup/azuresqlfailovergroup_reconcile.go b/pkg/resourcemanager/azuresql/azuresqlfailovergroup/azuresqlfailovergroup_reconcile.go index 72f57c28171..4a32fe22bff 100644 --- a/pkg/resourcemanager/azuresql/azuresqlfailovergroup/azuresqlfailovergroup_reconcile.go +++ b/pkg/resourcemanager/azuresql/azuresqlfailovergroup/azuresqlfailovergroup_reconcile.go @@ -58,6 +58,15 @@ func (fg *AzureSqlFailoverGroupManager) Ensure(ctx context.Context, obj runtime. return true, nil } instance.Status.Message = fmt.Sprintf("AzureSqlFailoverGroup Get error %s", err.Error()) + requeuErrors := []string{ + errhelp.ResourceGroupNotFoundErrorCode, + errhelp.ParentNotFoundErrorCode, + } + azerr := errhelp.NewAzureErrorAzureError(err) + if helpers.ContainsString(requeuErrors, azerr.Type) { + instance.Status.Provisioning = false + return false, nil + } _, err = fg.CreateOrUpdateFailoverGroup(ctx, groupName, serverName, failoverGroupName, sqlFailoverGroupProperties) if err != nil { diff --git a/pkg/resourcemanager/azuresql/azuresqlfirewallrule/azuresqlfirewallrule_reconcile.go b/pkg/resourcemanager/azuresql/azuresqlfirewallrule/azuresqlfirewallrule_reconcile.go index f5503939ea8..e6b1002fb9b 100644 --- a/pkg/resourcemanager/azuresql/azuresqlfirewallrule/azuresqlfirewallrule_reconcile.go +++ b/pkg/resourcemanager/azuresql/azuresqlfirewallrule/azuresqlfirewallrule_reconcile.go @@ -37,6 +37,15 @@ func (fw *AzureSqlFirewallRuleManager) Ensure(ctx context.Context, obj runtime.O return true, nil } instance.Status.Message = fmt.Sprintf("AzureSqlFirewallRule Get error %s", err.Error()) + requeuErrors := []string{ + errhelp.ResourceGroupNotFoundErrorCode, + errhelp.ParentNotFoundErrorCode, + } + azerr := errhelp.NewAzureErrorAzureError(err) + if helpers.ContainsString(requeuErrors, azerr.Type) { + instance.Status.Provisioning = false + return false, nil + } _, err = fw.CreateOrUpdateSQLFirewallRule(ctx, groupName, server, ruleName, startIP, endIP) if err != nil { diff --git a/pkg/resourcemanager/azuresql/azuresqluser/azuresqluser_reconcile.go b/pkg/resourcemanager/azuresql/azuresqluser/azuresqluser_reconcile.go index 28036683751..729f0e80aab 100644 --- a/pkg/resourcemanager/azuresql/azuresqluser/azuresqluser_reconcile.go +++ b/pkg/resourcemanager/azuresql/azuresqluser/azuresqluser_reconcile.go @@ -78,28 +78,44 @@ func (s *AzureSqlUserManager) Ensure(ctx context.Context, obj runtime.Object, op _, err = s.GetDB(ctx, instance.Spec.ResourceGroup, instance.Spec.Server, instance.Spec.DbName) if err != nil { - instance.Status.Message = err.Error() + instance.Status.Message = errhelp.StripErrorIDs(err) + instance.Status.Provisioning = false - catch := []string{ + requeuErrors := []string{ errhelp.ResourceNotFound, errhelp.ParentNotFoundErrorCode, errhelp.ResourceGroupNotFoundErrorCode, } azerr := errhelp.NewAzureErrorAzureError(err) - if helpers.ContainsString(catch, azerr.Type) { + if helpers.ContainsString(requeuErrors, azerr.Type) { return false, nil } - return false, err + + // if the database is busy, requeue + errorString := err.Error() + if strings.Contains(errorString, "Please retry the connection later") { + return false, nil + } + + // if this is an unmarshall error - igmore and continue, otherwise report error and requeue + if !strings.Contains(errorString, "cannot unmarshal array into Go struct field serviceError2.details") { + return false, err + } } db, err := s.ConnectToSqlDb(ctx, DriverName, instance.Spec.Server, instance.Spec.DbName, SqlServerPort, adminUser, adminPassword) if err != nil { instance.Status.Message = errhelp.StripErrorIDs(err) + instance.Status.Provisioning = false // catch firewall issue - keep cycling until it clears up if strings.Contains(err.Error(), "create a firewall rule for this IP address") { - instance.Status.Provisioned = false - instance.Status.Provisioning = false + return false, nil + } + + // if the database is busy, requeue + errorString := err.Error() + if strings.Contains(errorString, "Please retry the connection later") { return false, nil } @@ -262,7 +278,6 @@ func (s *AzureSqlUserManager) Ensure(ctx context.Context, obj runtime.Object, op err = s.GrantUserRoles(ctx, user, instance.Spec.Roles, db) if err != nil { - fmt.Println(err) instance.Status.Message = "GrantUserRoles failed" return false, fmt.Errorf("GrantUserRoles failed") } diff --git a/pkg/resourcemanager/azuresql/azuresqlvnetrule/azuresqlvnetrule_reconcile.go b/pkg/resourcemanager/azuresql/azuresqlvnetrule/azuresqlvnetrule_reconcile.go index dc64bfb88df..2d43032eac8 100644 --- a/pkg/resourcemanager/azuresql/azuresqlvnetrule/azuresqlvnetrule_reconcile.go +++ b/pkg/resourcemanager/azuresql/azuresqlvnetrule/azuresqlvnetrule_reconcile.go @@ -6,6 +6,7 @@ package azuresqlvnetrule import ( "context" "fmt" + "strings" "github.com/Azure/azure-sdk-for-go/services/preview/sql/mgmt/2015-05-01-preview/sql" azurev1alpha1 "github.com/Azure/azure-service-operator/api/v1alpha1" @@ -43,6 +44,15 @@ func (vr *AzureSqlVNetRuleManager) Ensure(ctx context.Context, obj runtime.Objec return false, nil } instance.Status.Message = fmt.Sprintf("AzureSqlVNetRule Get error %s", err.Error()) + requeuErrors := []string{ + errhelp.ResourceGroupNotFoundErrorCode, + errhelp.ParentNotFoundErrorCode, + } + azerr := errhelp.NewAzureErrorAzureError(err) + if helpers.ContainsString(requeuErrors, azerr.Type) { + instance.Status.Provisioning = false + return false, nil + } instance.Status.Provisioning = true _, err = vr.CreateOrUpdateSQLVNetRule(ctx, groupName, server, ruleName, virtualNetworkRG, virtualnetworkname, subnetName, ignoreendpoint) @@ -66,6 +76,13 @@ func (vr *AzureSqlVNetRuleManager) Ensure(ctx context.Context, obj runtime.Objec return false, nil } + // this happens when we try to create the VNet rule and the server doesnt exist yet + errorString := err.Error() + if strings.Contains(errorString, "does not have the server") { + instance.Status.Provisioning = false + return false, nil + } + return false, err } diff --git a/pkg/resourcemanager/config/env.go b/pkg/resourcemanager/config/env.go index 0359d6b64f7..553dd7393aa 100644 --- a/pkg/resourcemanager/config/env.go +++ b/pkg/resourcemanager/config/env.go @@ -50,7 +50,7 @@ func ParseEnvironment() error { authorizationServerURL = azureEnv.ActiveDirectoryEndpoint baseURI = azureEnv.ResourceManagerEndpoint // BaseURI() - locationDefault = envy.Get("AZURE_LOCATION_DEFAULT", "southcentralus") // DefaultLocation() + locationDefault = envy.Get("AZURE_LOCATION_DEFAULT", "westus2") // DefaultLocation() useDeviceFlow = ParseBoolFromEnvironment("AZURE_USE_DEVICEFLOW") // UseDeviceFlow() useMI = ParseBoolFromEnvironment("AZURE_USE_MI") // UseMI() keepResources = ParseBoolFromEnvironment("AZURE_SAMPLES_KEEP_RESOURCES") // KeepResources() diff --git a/pkg/resourcemanager/cosmosdbs/cosmosdb.go b/pkg/resourcemanager/cosmosdbs/cosmosdb.go index 0d22bb85e85..8c00b59f7ee 100644 --- a/pkg/resourcemanager/cosmosdbs/cosmosdb.go +++ b/pkg/resourcemanager/cosmosdbs/cosmosdb.go @@ -10,15 +10,17 @@ import ( "github.com/Azure/azure-sdk-for-go/services/cosmos-db/mgmt/2015-04-08/documentdb" "github.com/Azure/azure-service-operator/api/v1alpha1" - "github.com/Azure/azure-service-operator/pkg/errhelp" "github.com/Azure/azure-service-operator/pkg/resourcemanager/config" "github.com/Azure/azure-service-operator/pkg/resourcemanager/iam" + "github.com/Azure/azure-service-operator/pkg/secrets" "github.com/Azure/go-autorest/autorest" "github.com/Azure/go-autorest/autorest/to" ) // AzureCosmosDBManager is the struct which contains helper functions for resource groups -type AzureCosmosDBManager struct{} +type AzureCosmosDBManager struct { + SecretClient secrets.SecretClient +} func getCosmosDBClient() (documentdb.DatabaseAccountsClient, error) { cosmosDBClient := documentdb.NewDatabaseAccountsClientWithBaseURI(config.BaseURI(), config.SubscriptionID()) @@ -28,9 +30,9 @@ func getCosmosDBClient() (documentdb.DatabaseAccountsClient, error) { cosmosDBClient = documentdb.DatabaseAccountsClient{} } else { cosmosDBClient.Authorizer = a - cosmosDBClient.AddToUserAgent(config.UserAgent()) } + err = cosmosDBClient.AddToUserAgent(config.UserAgent()) return cosmosDBClient, err } @@ -41,15 +43,27 @@ func (*AzureCosmosDBManager) CreateOrUpdateCosmosDB( cosmosDBName string, location string, kind v1alpha1.CosmosDBKind, - dbType v1alpha1.CosmosDBDatabaseAccountOfferType, - tags map[string]*string) (*documentdb.DatabaseAccount, *errhelp.AzureError) { + networkRule *[]v1alpha1.CosmosDBVirtualNetworkRule, + properties v1alpha1.CosmosDBProperties, + tags map[string]*string) (*documentdb.DatabaseAccount, error) { cosmosDBClient, err := getCosmosDBClient() if err != nil { - return nil, errhelp.NewAzureErrorAzureError(err) + return nil, err } dbKind := documentdb.DatabaseAccountKind(kind) - sDBType := string(dbType) + sDBType := string(properties.DatabaseAccountOfferType) + bWriteLocal := bool(properties.EnableMultipleWriteLocations) + vnetEnabled := bool(properties.IsVirtualNetworkFilterEnabled) + + var capabilities []documentdb.Capability + if dbKind == documentdb.MongoDB && properties.MongoDBVersion == "3.6" { + capabilities = []documentdb.Capability{ + {Name: to.StringPtr("EnableMongo")}, + } + } else { + capabilities = make([]documentdb.Capability, 0) + } /* * Current state of Locations and CosmosDB properties: @@ -72,6 +86,18 @@ func (*AzureCosmosDBManager) CreateOrUpdateCosmosDB( locationsArray := []documentdb.Location{ locationObj, } + + vNetRulesSet := []documentdb.VirtualNetworkRule{} + if networkRule != nil { + for _, i := range *networkRule { + subnetID := i.SubnetID + ignoreEndpoint := i.IgnoreMissingVNetServiceEndpoint + vNetRulesSet = append(vNetRulesSet, documentdb.VirtualNetworkRule{ + ID: subnetID, + IgnoreMissingVNetServiceEndpoint: ignoreEndpoint, + }) + } + } createUpdateParams := documentdb.DatabaseAccountCreateUpdateParameters{ Location: to.StringPtr(location), Tags: tags, @@ -81,9 +107,11 @@ func (*AzureCosmosDBManager) CreateOrUpdateCosmosDB( ID: &cosmosDBName, DatabaseAccountCreateUpdateProperties: &documentdb.DatabaseAccountCreateUpdateProperties{ DatabaseAccountOfferType: &sDBType, - EnableMultipleWriteLocations: to.BoolPtr(false), - IsVirtualNetworkFilterEnabled: to.BoolPtr(false), + IsVirtualNetworkFilterEnabled: &vnetEnabled, + VirtualNetworkRules: &vNetRulesSet, + EnableMultipleWriteLocations: &bWriteLocal, Locations: &locationsArray, + Capabilities: &capabilities, }, } createUpdateFuture, err := cosmosDBClient.CreateOrUpdate( @@ -91,13 +119,13 @@ func (*AzureCosmosDBManager) CreateOrUpdateCosmosDB( if err != nil { // initial create request failed, wrap error - return nil, errhelp.NewAzureErrorAzureError(err) + return nil, err } result, err := createUpdateFuture.Result(cosmosDBClient) if err != nil { // there is no immediate result, wrap error - return &result, errhelp.NewAzureErrorAzureError(err) + return &result, err } return &result, nil } @@ -106,15 +134,15 @@ func (*AzureCosmosDBManager) CreateOrUpdateCosmosDB( func (*AzureCosmosDBManager) GetCosmosDB( ctx context.Context, groupName string, - cosmosDBName string) (*documentdb.DatabaseAccount, *errhelp.AzureError) { + cosmosDBName string) (*documentdb.DatabaseAccount, error) { cosmosDBClient, err := getCosmosDBClient() if err != nil { - return nil, errhelp.NewAzureErrorAzureError(err) + return nil, err } result, err := cosmosDBClient.Get(ctx, groupName, cosmosDBName) if err != nil { - return &result, errhelp.NewAzureErrorAzureError(err) + return &result, err } return &result, nil } @@ -122,15 +150,15 @@ func (*AzureCosmosDBManager) GetCosmosDB( // CheckNameExistsCosmosDB checks if the global account name already exists func (*AzureCosmosDBManager) CheckNameExistsCosmosDB( ctx context.Context, - accountName string) (bool, *errhelp.AzureError) { + accountName string) (bool, error) { cosmosDBClient, err := getCosmosDBClient() if err != nil { - return false, errhelp.NewAzureErrorAzureError(err) + return false, err } response, err := cosmosDBClient.CheckNameExists(ctx, accountName) if err != nil { - return false, errhelp.NewAzureErrorAzureError(err) + return false, err } switch response.StatusCode { @@ -139,7 +167,7 @@ func (*AzureCosmosDBManager) CheckNameExistsCosmosDB( case http.StatusOK: return true, nil default: - return false, errhelp.NewAzureErrorAzureError(fmt.Errorf("unhandled status code for CheckNameExists")) + return false, fmt.Errorf("unhandled status code for CheckNameExists") } } @@ -147,20 +175,38 @@ func (*AzureCosmosDBManager) CheckNameExistsCosmosDB( func (*AzureCosmosDBManager) DeleteCosmosDB( ctx context.Context, groupName string, - cosmosDBName string) (*autorest.Response, *errhelp.AzureError) { + cosmosDBName string) (*autorest.Response, error) { cosmosDBClient, err := getCosmosDBClient() if err != nil { - return nil, errhelp.NewAzureErrorAzureError(err) + return nil, err } deleteFuture, err := cosmosDBClient.Delete(ctx, groupName, cosmosDBName) if err != nil { - return nil, errhelp.NewAzureErrorAzureError(err) + return nil, err } ar, err := deleteFuture.Result(cosmosDBClient) if err != nil { - return nil, errhelp.NewAzureErrorAzureError(err) + return nil, err } return &ar, nil } + +// ListKeys lists the read & write keys for a database account +func (*AzureCosmosDBManager) ListKeys( + ctx context.Context, + groupName string, + accountName string) (*documentdb.DatabaseAccountListKeysResult, error) { + client, err := getCosmosDBClient() + if err != nil { + return nil, err + } + + result, err := client.ListKeys(ctx, groupName, accountName) + if err != nil { + return nil, err + } + + return &result, nil +} diff --git a/pkg/resourcemanager/cosmosdbs/cosmosdb_manager.go b/pkg/resourcemanager/cosmosdbs/cosmosdb_manager.go index f916e70fd54..d0b01fc3f05 100644 --- a/pkg/resourcemanager/cosmosdbs/cosmosdb_manager.go +++ b/pkg/resourcemanager/cosmosdbs/cosmosdb_manager.go @@ -8,29 +8,32 @@ import ( "github.com/Azure/azure-sdk-for-go/services/cosmos-db/mgmt/2015-04-08/documentdb" "github.com/Azure/azure-service-operator/api/v1alpha1" - "github.com/Azure/azure-service-operator/pkg/errhelp" "github.com/Azure/azure-service-operator/pkg/resourcemanager" + "github.com/Azure/azure-service-operator/pkg/secrets" "github.com/Azure/go-autorest/autorest" ) // NewAzureCosmosDBManager creates a new cosmos db client -func NewAzureCosmosDBManager() *AzureCosmosDBManager { - return &AzureCosmosDBManager{} +func NewAzureCosmosDBManager(secretClient secrets.SecretClient) *AzureCosmosDBManager { + return &AzureCosmosDBManager{secretClient} } // CosmosDBManager client functions type CosmosDBManager interface { // CreateOrUpdateCosmosDB creates a new cosmos database account - CreateOrUpdateCosmosDB(ctx context.Context, groupName string, cosmosDBName string, location string, kind v1alpha1.CosmosDBKind, dbType v1alpha1.CosmosDBDatabaseAccountOfferType, tags map[string]*string) (*documentdb.DatabaseAccount, *errhelp.AzureError) + CreateOrUpdateCosmosDB(ctx context.Context, groupName string, cosmosDBName string, location string, kind v1alpha1.CosmosDBKind, networkRule *[]v1alpha1.CosmosDBVirtualNetworkRule, properties v1alpha1.CosmosDBProperties, tags map[string]*string) (*documentdb.DatabaseAccount, error) // GetCosmosDB gets a cosmos database account - GetCosmosDB(ctx context.Context, groupName string, cosmosDBName string) (*documentdb.DatabaseAccount, *errhelp.AzureError) + GetCosmosDB(ctx context.Context, groupName string, cosmosDBName string) (*documentdb.DatabaseAccount, error) // DeleteCosmosDB removes the cosmos database account - DeleteCosmosDB(ctx context.Context, groupName string, cosmosDBName string) (*autorest.Response, *errhelp.AzureError) + DeleteCosmosDB(ctx context.Context, groupName string, cosmosDBName string) (*autorest.Response, error) // CheckNameExistsCosmosDB check if the account name already exists globally - CheckNameExistsCosmosDB(ctx context.Context, accountName string) (bool, *errhelp.AzureError) + CheckNameExistsCosmosDB(ctx context.Context, accountName string) (bool, error) + + // ListKeys lists the read & write keys for a database account + ListKeys(ctx context.Context, groupName string, accountName string) (*documentdb.DatabaseAccountListKeysResult, error) resourcemanager.ARMClient } diff --git a/pkg/resourcemanager/cosmosdbs/cosmosdb_reconcile.go b/pkg/resourcemanager/cosmosdbs/cosmosdb_reconcile.go index 4eb8f6b0a0f..88120e15809 100644 --- a/pkg/resourcemanager/cosmosdbs/cosmosdb_reconcile.go +++ b/pkg/resourcemanager/cosmosdbs/cosmosdb_reconcile.go @@ -6,6 +6,7 @@ package cosmosdbs import ( "context" "fmt" + "strings" "github.com/Azure/azure-service-operator/api/v1alpha1" "github.com/Azure/azure-service-operator/pkg/errhelp" @@ -18,70 +19,74 @@ import ( // Ensure ensures that cosmosdb is provisioned as specified func (m *AzureCosmosDBManager) Ensure(ctx context.Context, obj runtime.Object, opts ...resourcemanager.ConfigOption) (bool, error) { + options := &resourcemanager.Options{} + for _, opt := range opts { + opt(options) + } + + if options.SecretClient != nil { + m.SecretClient = options.SecretClient + } + instance, err := m.convert(obj) if err != nil { return false, err } hash := helpers.Hash256(instance.Spec) - if instance.Status.SpecHash != hash { - // need to push a create or update - instance.Status.SpecHash = hash - } else if instance.Status.Provisioned { - // provisioned and no changes needed + + if instance.Status.SpecHash == hash && instance.Status.Provisioned { instance.Status.RequestedAt = nil return true, nil - } else if instance.Status.Provisioning { - // get the instance and update status - db, azerr := m.GetCosmosDB(ctx, instance.Spec.ResourceGroup, instance.Name) - if azerr == nil { - instance.Status.ResourceId = *db.ID - instance.Status.State = *db.ProvisioningState - - if instance.Status.State == "Creating" { - instance.Status.Message = "Waiting for resource to finish creation" - return false, nil - } + } + instance.Status.Provisioned = false - if instance.Status.State == "Succeeded" { - instance.Status.Message = resourcemanager.SuccessMsg - instance.Status.Provisioning = false - instance.Status.Provisioned = true - return true, nil - } + // get the instance and update status + db, err := m.GetCosmosDB(ctx, instance.Spec.ResourceGroup, instance.Name) + if err != nil { + azerr := errhelp.NewAzureErrorAzureError(err) - if instance.Status.State == "Failed" { - instance.Status.Message = "Failed to provision CosmosDB" - instance.Status.Provisioning = false - return true, nil - } - } else if azerr.Type == errhelp.ResourceGroupNotFoundErrorCode { + instance.Status.Message = err.Error() + + switch azerr.Type { + case errhelp.ResourceGroupNotFoundErrorCode, errhelp.ParentNotFoundErrorCode: instance.Status.Provisioning = false - instance.Status.Message = fmt.Sprintf("Waiting for resource group '%s' to be available", instance.Spec.ResourceGroup) instance.Status.State = "Waiting" return false, nil - } else if azerr.Type == errhelp.ResourceNotFound { - exists, azerr := m.CheckNameExistsCosmosDB(ctx, instance.Name) - if azerr != nil { - instance.Status.Provisioning = false - instance.Status.Message = "Unexpected error occurred during resource request" - instance.Status.State = "Failed" - return false, err - } else if exists { - // get request returned resource not found and the name already exists - // so it must exist in a different resource group, user must fix it - instance.Status.Provisioning = false - instance.Status.Message = "CosmosDB name already exists" - instance.Status.State = "Failed" - return true, nil - } - } else { + } + + } else { + instance.Status.ResourceId = *db.ID + instance.Status.State = *db.ProvisioningState + } + + if instance.Status.State == "Creating" { + // avoid multiple CreateOrUpdate requests while resource is already creating + return false, nil + } + + if instance.Status.State == "Succeeded" { + // provisioning is complete, update the secrets + if err = m.createOrUpdateAccountKeysSecret(ctx, instance); err != nil { + instance.Status.Message = err.Error() + return false, err + } + + if instance.Status.SpecHash == hash { + instance.Status.Message = resourcemanager.SuccessMsg instance.Status.Provisioning = false - instance.Status.Message = azerr.Error() - return false, azerr.Original + instance.Status.Provisioned = true + return true, nil } } + if instance.Status.State == "Failed" { + instance.Status.Message = "Failed to provision CosmosDB" + instance.Status.Provisioning = false + instance.Status.Provisioned = false + return true, nil + } + instance.Status.Provisioning = true tags := helpers.LabelsToTags(instance.GetLabels()) @@ -89,98 +94,113 @@ func (m *AzureCosmosDBManager) Ensure(ctx context.Context, obj runtime.Object, o groupName := instance.Spec.ResourceGroup location := instance.Spec.Location kind := instance.Spec.Kind - dbType := instance.Spec.Properties.DatabaseAccountOfferType - - db, azerr := m.CreateOrUpdateCosmosDB(ctx, groupName, accountName, location, kind, dbType, tags) + networkRule := instance.Spec.VirtualNetworkRules - // everything is in a created/updated state - if azerr == nil { - instance.Status.Provisioned = true - instance.Status.Provisioning = false - instance.Status.Message = resourcemanager.SuccessMsg - instance.Status.State = "Succeeded" - instance.Status.ResourceId = *db.ID - return true, nil + cosmosDBProperties := v1alpha1.CosmosDBProperties{ + DatabaseAccountOfferType: instance.Spec.Properties.DatabaseAccountOfferType, + EnableMultipleWriteLocations: instance.Spec.Properties.EnableMultipleWriteLocations, + MongoDBVersion: instance.Spec.Properties.MongoDBVersion, + IsVirtualNetworkFilterEnabled: instance.Spec.Properties.IsVirtualNetworkFilterEnabled, } - switch azerr.Type { - - case errhelp.AsyncOpIncompleteError: - instance.Status.Message = "Resource request successfully submitted to Azure" - instance.Status.State = "Creating" + db, err = m.CreateOrUpdateCosmosDB(ctx, groupName, accountName, location, kind, networkRule, cosmosDBProperties, tags) + if err != nil { + azerr := errhelp.NewAzureErrorAzureError(err) + instance.Status.Message = err.Error() + + switch azerr.Type { + case errhelp.AsyncOpIncompleteError: + instance.Status.State = "Creating" + instance.Status.Message = "Resource request successfully submitted to Azure" + instance.Status.SpecHash = hash + return false, nil + case errhelp.InvalidResourceLocation, errhelp.LocationNotAvailableForResourceType: + instance.Status.Provisioning = false + instance.Status.Message = azerr.Error() + return true, nil + case errhelp.ResourceGroupNotFoundErrorCode, errhelp.ParentNotFoundErrorCode: + instance.Status.Provisioning = false + case errhelp.NotFoundErrorCode: + nameExists, err := m.CheckNameExistsCosmosDB(ctx, accountName) + if err != nil { + instance.Status.Message = err.Error() + } + if nameExists { + instance.Status.Provisioning = false + instance.Status.Message = "CosmosDB Account name already exists" + return true, nil + } + } - case errhelp.InvalidResourceLocation: - instance.Status.Provisioning = false - instance.Status.Message = azerr.Reason - return true, nil + return false, err + } + if err = m.createOrUpdateAccountKeysSecret(ctx, instance); err != nil { + instance.Status.Message = err.Error() + return false, err } + + instance.Status.SpecHash = hash + instance.Status.ResourceId = *db.ID + instance.Status.State = *db.ProvisioningState + instance.Status.Provisioned = true + instance.Status.Provisioning = false + instance.Status.Message = resourcemanager.SuccessMsg return false, nil } // Delete drops cosmosdb func (m *AzureCosmosDBManager) Delete(ctx context.Context, obj runtime.Object, opts ...resourcemanager.ConfigOption) (bool, error) { + options := &resourcemanager.Options{} + for _, opt := range opts { + opt(options) + } + + if options.SecretClient != nil { + m.SecretClient = options.SecretClient + } + instance, err := m.convert(obj) if err != nil { return false, err } - accountName := instance.ObjectMeta.Name - groupName := instance.Spec.ResourceGroup - // if the resource is in a failed state it was never created or could never be verified // so we skip attempting to delete the resrouce from Azure if instance.Status.FailedProvisioning { return false, nil } - notFoundErrors := []string{ - errhelp.NotFoundErrorCode, // happens on first request after deletion succeeds - errhelp.ResourceNotFound, // happens on subsequent requests after deletion succeeds - errhelp.ResourceGroupNotFoundErrorCode, // database doesn't exist in this resource group but the name exists globally - } - - // fetch the latest to inspect provisioning state - cosmosDB, azerr := m.GetCosmosDB(ctx, groupName, accountName) - if azerr != nil { - // deletion finished - if helpers.ContainsString(notFoundErrors, azerr.Type) { - return false, nil - } - - //TODO: are there other errors that need handling here? - instance.Status.Message = azerr.Error() - return true, azerr.Original - } - - instance.Status.State = *cosmosDB.ProvisioningState + groupName := instance.Spec.ResourceGroup + accountName := instance.ObjectMeta.Name - // already deleting the resource, try again later - if instance.Status.State == "Deleting" { - return true, nil - } + // try to delete the cosmosdb instance & secrets + _, err = m.DeleteCosmosDB(ctx, groupName, accountName) + if err != nil { + azerr := errhelp.NewAzureErrorAzureError(err) - // try to delete the cosmosdb instance - _, azerr = m.DeleteCosmosDB(ctx, groupName, accountName) - if azerr != nil { - // this is likely to happen on first try due to not waiting for the future to complete - if azerr.Type == errhelp.AsyncOpIncompleteError { + // request submitted or already in progress + if azerr.Type == errhelp.AsyncOpIncompleteError || (azerr.Type == errhelp.PreconditionFailed && strings.Contains(azerr.Reason, "operation in progress")) { + instance.Status.State = "Deleting" instance.Status.Message = "Deletion request submitted successfully" return true, nil } - // already deleted - if helpers.ContainsString(notFoundErrors, azerr.Type) { - return false, nil + notFound := []string{ + errhelp.NotFoundErrorCode, + errhelp.ResourceNotFound, + errhelp.ResourceGroupNotFoundErrorCode, + } + if helpers.ContainsString(notFound, azerr.Type) { + return false, m.deleteAccountKeysSecret(ctx, instance) } // unhandled error instance.Status.Message = azerr.Error() - return false, azerr.Original + return false, err } - // second delete calls succeed immediately - return false, nil + return false, m.deleteAccountKeysSecret(ctx, instance) } // GetParents returns the parents of cosmosdb @@ -217,3 +237,36 @@ func (m *AzureCosmosDBManager) convert(obj runtime.Object) (*v1alpha1.CosmosDB, } return db, nil } + +func (m *AzureCosmosDBManager) createOrUpdateAccountKeysSecret(ctx context.Context, instance *v1alpha1.CosmosDB) error { + result, err := m.ListKeys(ctx, instance.Spec.ResourceGroup, instance.ObjectMeta.Name) + if err != nil { + return err + } + + secretKey := types.NamespacedName{ + Name: instance.Name, + Namespace: instance.Namespace, + } + secretData := map[string][]byte{ + "primaryConnectionString": []byte(*result.PrimaryMasterKey), + "secondaryConnectionString": []byte(*result.SecondaryMasterKey), + "primaryReadonlyMasterKey": []byte(*result.PrimaryReadonlyMasterKey), + "secondaryReadonlyMasterKey": []byte(*result.SecondaryReadonlyMasterKey), + } + + err = m.SecretClient.Upsert(ctx, secretKey, secretData) + if err != nil { + return err + } + + return nil +} + +func (m *AzureCosmosDBManager) deleteAccountKeysSecret(ctx context.Context, instance *v1alpha1.CosmosDB) error { + secretKey := types.NamespacedName{ + Name: instance.Name, + Namespace: instance.Namespace, + } + return m.SecretClient.Delete(ctx, secretKey) +} diff --git a/pkg/resourcemanager/keyvaults/keyvault.go b/pkg/resourcemanager/keyvaults/keyvault.go index 2522fe0f3af..17fc2e07edd 100644 --- a/pkg/resourcemanager/keyvaults/keyvault.go +++ b/pkg/resourcemanager/keyvaults/keyvault.go @@ -7,6 +7,7 @@ import ( "context" "fmt" "strings" + "time" auth "github.com/Azure/azure-sdk-for-go/services/graphrbac/1.6/graphrbac" "github.com/Azure/azure-sdk-for-go/services/keyvault/mgmt/2018-02-14/keyvault" @@ -21,6 +22,7 @@ import ( "github.com/Azure/go-autorest/autorest" "github.com/Azure/go-autorest/autorest/to" uuid "github.com/satori/go.uuid" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/types" ) @@ -447,6 +449,15 @@ func (k *azureKeyVaultManager) Ensure(ctx context.Context, obj runtime.Object, o } if helpers.ContainsString(catchUnrecoverableErrors, azerr.Type) { // Unrecoverable error, so stop reconcilation + switch azerr.Type { + case errhelp.AlreadyExists: + timeNow := metav1.NewTime(time.Now()) + if timeNow.Sub(instance.Status.RequestedAt.Time) < (30 * time.Second) { + instance.Status.Provisioning = true + return false, nil + } + + } instance.Status.Message = "Reconcilation hit unrecoverable error " + err.Error() return true, nil } diff --git a/pkg/resourcemanager/mock/storages/storageaccount.go b/pkg/resourcemanager/mock/storages/storageaccount.go index 83f8d88a115..48a939f73a4 100644 --- a/pkg/resourcemanager/mock/storages/storageaccount.go +++ b/pkg/resourcemanager/mock/storages/storageaccount.go @@ -50,7 +50,7 @@ func (manager *mockStorageManager) CreateStorage(ctx context.Context, groupName kind azurev1alpha1.StorageAccountKind, tags map[string]*string, accessTier azurev1alpha1.StorageAccountAccessTier, - enableHTTPsTrafficOnly *bool, dataLakeEnabled *bool) (result storage.Account, err error) { + enableHTTPsTrafficOnly *bool, dataLakeEnabled *bool, networkRule *azurev1alpha1.NetworkRuleSet) (result storage.Account, err error) { s := storageResource{ resourceGroupName: groupName, storageAccountName: storageAccountName, diff --git a/pkg/resourcemanager/mysql/server/client.go b/pkg/resourcemanager/mysql/server/client.go index c6a8804989f..22f78de705d 100644 --- a/pkg/resourcemanager/mysql/server/client.go +++ b/pkg/resourcemanager/mysql/server/client.go @@ -5,11 +5,13 @@ package server import ( "context" + "strings" mysql "github.com/Azure/azure-sdk-for-go/services/mysql/mgmt/2017-12-01/mysql" "github.com/Azure/azure-service-operator/pkg/resourcemanager/config" "github.com/Azure/azure-service-operator/pkg/resourcemanager/iam" "github.com/Azure/azure-service-operator/pkg/secrets" + "github.com/Azure/go-autorest/autorest/to" "k8s.io/apimachinery/pkg/runtime" ) @@ -59,34 +61,56 @@ func (m *MySQLServerClient) CheckServerNameAvailability(ctx context.Context, ser } -func (m *MySQLServerClient) CreateServerIfValid(ctx context.Context, servername string, resourcegroup string, location string, tags map[string]*string, serverversion mysql.ServerVersion, sslenforcement mysql.SslEnforcementEnum, skuInfo mysql.Sku, adminlogin string, adminpassword string) (future mysql.ServersCreateFuture, err error) { +func (m *MySQLServerClient) CreateServerIfValid(ctx context.Context, servername string, resourcegroup string, location string, tags map[string]*string, serverversion mysql.ServerVersion, sslenforcement mysql.SslEnforcementEnum, skuInfo mysql.Sku, adminlogin string, adminpassword string, createmode string, sourceserver string) (pollingURL string, server mysql.Server, err error) { client := getMySQLServersClient() // Check if name is valid if this is the first create call valid, err := m.CheckServerNameAvailability(ctx, servername) - if valid == false { - return future, err + if !valid { + return "", server, err } - - return client.Create( - ctx, - resourcegroup, - servername, - mysql.ServerForCreate{ - Location: &location, - Tags: tags, - Properties: &mysql.ServerPropertiesForDefaultCreate{ - AdministratorLogin: &adminlogin, - AdministratorLoginPassword: &adminpassword, - Version: serverversion, - SslEnforcement: sslenforcement, - //StorageProfile: &mysql.StorageProfile{}, - CreateMode: mysql.CreateModeServerPropertiesForCreate, + var result mysql.ServersCreateFuture + if strings.EqualFold(createmode, "replica") { + result, _ = client.Create( + ctx, + resourcegroup, + servername, + mysql.ServerForCreate{ + Location: &location, + Tags: tags, + Properties: &mysql.ServerPropertiesForReplica{ + SourceServerID: to.StringPtr(sourceserver), + CreateMode: mysql.CreateModeReplica, + }, + }, + ) + + } else { + + result, _ = client.Create( + ctx, + resourcegroup, + servername, + mysql.ServerForCreate{ + Location: &location, + Tags: tags, + Properties: &mysql.ServerPropertiesForDefaultCreate{ + AdministratorLogin: &adminlogin, + AdministratorLoginPassword: &adminpassword, + Version: serverversion, + SslEnforcement: sslenforcement, + //StorageProfile: &mysql.StorageProfile{}, + CreateMode: mysql.CreateModeServerPropertiesForCreate, + }, + Sku: &skuInfo, }, - Sku: &skuInfo, - }, - ) + ) + } + + res, err := result.Result(client) + return result.PollingURL(), res, err + } func (m *MySQLServerClient) DeleteServer(ctx context.Context, resourcegroup string, servername string) (status string, err error) { diff --git a/pkg/resourcemanager/mysql/server/manager.go b/pkg/resourcemanager/mysql/server/manager.go index cc4b620f8d0..f90b58e5c70 100644 --- a/pkg/resourcemanager/mysql/server/manager.go +++ b/pkg/resourcemanager/mysql/server/manager.go @@ -11,7 +11,7 @@ import ( ) type MySQLServerManager interface { - CreateServerIfValid(ctx context.Context, servername string, resourcegroup string, location string, tags map[string]*string, serverversion mysql.ServerVersion, sslenforcement mysql.SslEnforcementEnum, skuInfo mysql.Sku, adminlogin string, adminpassword string) (mysql.ServersCreateFuture, error) + CreateServerIfValid(ctx context.Context, servername string, resourcegroup string, location string, tags map[string]*string, serverversion mysql.ServerVersion, sslenforcement mysql.SslEnforcementEnum, skuInfo mysql.Sku, adminlogin string, adminpassword string, createmode string, sourceserver string) (pollingURL string, server mysql.Server, err error) DeleteServer(ctx context.Context, resourcegroup string, servername string) (string, error) GetServer(ctx context.Context, resourcegroup string, servername string) (mysql.Server, error) diff --git a/pkg/resourcemanager/mysql/server/reconcile.go b/pkg/resourcemanager/mysql/server/reconcile.go index 94155a7c50b..c8a75490add 100644 --- a/pkg/resourcemanager/mysql/server/reconcile.go +++ b/pkg/resourcemanager/mysql/server/reconcile.go @@ -6,6 +6,7 @@ package server import ( "context" "fmt" + "strings" mysql "github.com/Azure/azure-sdk-for-go/services/mysql/mgmt/2017-12-01/mysql" "github.com/Azure/azure-service-operator/api/v1alpha1" @@ -13,6 +14,7 @@ import ( "github.com/Azure/azure-service-operator/pkg/errhelp" "github.com/Azure/azure-service-operator/pkg/helpers" "github.com/Azure/azure-service-operator/pkg/resourcemanager" + "github.com/Azure/azure-service-operator/pkg/resourcemanager/pollclient" "github.com/Azure/azure-service-operator/pkg/secrets" "github.com/Azure/go-autorest/autorest/to" "k8s.io/apimachinery/pkg/runtime" @@ -35,37 +37,80 @@ func (m *MySQLServerClient) Ensure(ctx context.Context, obj runtime.Object, opts return true, err } + createmode := "Default" + if len(instance.Spec.CreateMode) != 0 { + createmode = instance.Spec.CreateMode + } + + // If a replica is requested, ensure that source server is specified + if strings.EqualFold(createmode, "replica") { + if len(instance.Spec.ReplicaProperties.SourceServerId) == 0 { + instance.Status.Message = "Replica requested but source server unspecified" + return true, nil + } + } + // Check to see if secret exists and if yes retrieve the admin login and password secret, err := m.GetOrPrepareSecret(ctx, instance) if err != nil { return false, err } - // Update secret err = m.AddServerCredsToSecrets(ctx, instance.Name, secret, instance) if err != nil { return false, err } // convert kube labels to expected tag format - labels := map[string]*string{} - for k, v := range instance.GetLabels() { - labels[k] = &v - } + labels := helpers.LabelsToTags(instance.GetLabels()) // Check if this server already exists and its state if it does. This is required // to overcome the issue with the lack of idempotence of the Create call + hash := "" server, err := m.GetServer(ctx, instance.Spec.ResourceGroup, instance.Name) - if err == nil { + if err != nil { + // handle failures in the async operation + if instance.Status.PollingURL != "" { + pClient := pollclient.NewPollClient() + res, err := pClient.Get(ctx, instance.Status.PollingURL) + if err != nil { + instance.Status.Provisioning = false + return false, err + } + + if res.Status == "Failed" { + instance.Status.Provisioning = false + instance.Status.RequestedAt = nil + ignore := []string{ + errhelp.SubscriptionDoesNotHaveServer, + errhelp.ServiceBusy, + } + if !helpers.ContainsString(ignore, res.Error.Code) { + instance.Status.Message = res.Error.Error() + return true, nil + } + } + } + } else { instance.Status.State = string(server.UserVisibleState) + + hash = helpers.Hash256(instance.Spec) + if instance.Status.SpecHash == hash && (instance.Status.Provisioned || instance.Status.FailedProvisioning) { + instance.Status.RequestedAt = nil + return true, nil + } if server.UserVisibleState == mysql.ServerStateReady { + // Update secret with FQ name of the server. We ignore the error. + m.UpdateServerNameInSecret(ctx, instance.Name, secret, *server.FullyQualifiedDomainName, instance) + instance.Status.Provisioned = true instance.Status.Provisioning = false instance.Status.Message = resourcemanager.SuccessMsg instance.Status.ResourceId = *server.ID + instance.Status.State = string(server.UserVisibleState) + instance.Status.SpecHash = hash return true, nil } - return false, nil } // if the create has been sent with no error we need to wait before calling it again @@ -84,7 +129,7 @@ func (m *MySQLServerClient) Ensure(ctx context.Context, obj runtime.Object, opts Family: to.StringPtr(instance.Spec.Sku.Family), } - _, err = m.CreateServerIfValid( + pollURL, server, err := m.CreateServerIfValid( ctx, instance.Name, instance.Spec.ResourceGroup, @@ -95,20 +140,41 @@ func (m *MySQLServerClient) Ensure(ctx context.Context, obj runtime.Object, opts skuInfo, adminlogin, adminpassword, + createmode, + instance.Spec.ReplicaProperties.SourceServerId, ) if err != nil { // let the user know what happened - instance.Status.Message = err.Error() + instance.Status.Message = errhelp.StripErrorIDs(err) instance.Status.Provisioning = false azerr := errhelp.NewAzureErrorAzureError(err) - switch azerr.Type { - case errhelp.ResourceGroupNotFoundErrorCode, errhelp.ParentNotFoundErrorCode: - // errors we expect might happen that we are ok with waiting for + catchRequeue := []string{ + errhelp.ResourceGroupNotFoundErrorCode, + errhelp.ParentNotFoundErrorCode, + errhelp.AsyncOpIncompleteError, + errhelp.SubscriptionDoesNotHaveServer, + errhelp.ServiceBusy, + } + catchUnrecoverable := []string{ + errhelp.ProvisioningDisabled, + errhelp.LocationNotAvailableForResourceType, + errhelp.InvalidRequestContent, + errhelp.InternalServerError, + } + + // handle the errors + if helpers.ContainsString(catchRequeue, azerr.Type) { + if azerr.Type == errhelp.AsyncOpIncompleteError { + instance.Status.Provisioning = true + instance.Status.PollingURL = pollURL + } return false, nil - case errhelp.ProvisioningDisabled, errhelp.LocationNotAvailableForResourceType: + } + + if helpers.ContainsString(catchUnrecoverable, azerr.Type) { // Unrecoverable error, so stop reconcilation - instance.Status.Message = "Reconcilation hit unrecoverable error: " + err.Error() + instance.Status.Message = "Reconcilation hit unrecoverable error: " + errhelp.StripErrorIDs(err) return true, nil } @@ -216,26 +282,70 @@ func (m *MySQLServerClient) AddServerCredsToSecrets(ctx context.Context, secretN return nil } +// UpdateSecretWithFullServerName updates the secret with the fully qualified server name +func (m *MySQLServerClient) UpdateServerNameInSecret(ctx context.Context, secretName string, data map[string][]byte, fullservername string, instance *azurev1alpha1.MySQLServer) error { + key := types.NamespacedName{ + Name: secretName, + Namespace: instance.Namespace, + } + + data["fullyQualifiedServerName"] = []byte(fullservername) + + err := m.SecretClient.Upsert(ctx, + key, + data, + secrets.WithOwner(instance), + secrets.WithScheme(m.Scheme), + ) + if err != nil { + return err + } + + return nil +} + // GetOrPrepareSecret gets tje admin credentials if they are stored or generates some if not func (m *MySQLServerClient) GetOrPrepareSecret(ctx context.Context, instance *azurev1alpha1.MySQLServer) (map[string][]byte, error) { name := instance.Name + createmode := instance.Spec.CreateMode - secret := map[string][]byte{} + // If createmode == default, then this is a new server creation, so generate username/password + // If createmode == replica, then get the credentials from the source server secret and use that - key := types.NamespacedName{Name: name, Namespace: instance.Namespace} - if stored, err := m.SecretClient.Get(ctx, key); err == nil { - return stored, nil + secret := map[string][]byte{} + var key types.NamespacedName + var Username string + var Password string + + if strings.EqualFold(createmode, "default") { // new Mysql server creation + // See if secret already exists and return if it does + key = types.NamespacedName{Name: name, Namespace: instance.Namespace} + if stored, err := m.SecretClient.Get(ctx, key); err == nil { + return stored, nil + } + // Generate random username password if secret does not exist already + Username = helpers.GenerateRandomUsername(10) + Password = helpers.NewPassword() + } else { // replica + sourceServerId := instance.Spec.ReplicaProperties.SourceServerId + if len(sourceServerId) != 0 { + // Parse to get source server name + sourceServerIdSplit := strings.Split(sourceServerId, "/") + sourceserver := sourceServerIdSplit[len(sourceServerIdSplit)-1] + + // Get the username and password from the source server's secret + key = types.NamespacedName{Name: sourceserver, Namespace: instance.Namespace} + if sourcesecret, err := m.SecretClient.Get(ctx, key); err == nil { + Username = string(sourcesecret["username"]) + Password = string(sourcesecret["password"]) + } + } } - randomUsername := helpers.GenerateRandomUsername(10) - randomPassword := helpers.NewPassword() - - secret["username"] = []byte(randomUsername) - secret["fullyQualifiedUsername"] = []byte(fmt.Sprintf("%s@%s", randomUsername, name)) - secret["password"] = []byte(randomPassword) - secret["postgreSqlServerName"] = []byte(name) - // TODO: The below may not be right for non Azure public cloud. - secret["fullyQualifiedServerName"] = []byte(name + ".mysql.database.azure.com") - + // Populate secret fields + secret["username"] = []byte(Username) + secret["fullyQualifiedUsername"] = []byte(fmt.Sprintf("%s@%s", Username, name)) + secret["password"] = []byte(Password) + secret["mySqlServerName"] = []byte(name) return secret, nil } diff --git a/pkg/resourcemanager/nic/client.go b/pkg/resourcemanager/nic/client.go index 91bf3f4747c..e83bf91a63d 100644 --- a/pkg/resourcemanager/nic/client.go +++ b/pkg/resourcemanager/nic/client.go @@ -5,9 +5,9 @@ package nic import ( "context" - "strings" vnetwork "github.com/Azure/azure-sdk-for-go/services/network/mgmt/2019-09-01/network" + "github.com/Azure/azure-service-operator/pkg/helpers" "github.com/Azure/azure-service-operator/pkg/resourcemanager/config" "github.com/Azure/azure-service-operator/pkg/resourcemanager/iam" "github.com/Azure/azure-service-operator/pkg/secrets" @@ -34,32 +34,11 @@ func getNetworkInterfaceClient() vnetwork.InterfacesClient { return nicClient } -func MakeResourceId(subscriptionId string, resourceGroupName string, provider string, resourceType string, resourceName string, subResourceType string, subResourceName string) string { - // Sample 1: /subscriptions/88fd8cb2-8248-499e-9a2d-4929a4b0133c/resourceGroups/resourcegroup-azure-operators/providers/Microsoft.Network/publicIPAddresses/azurepublicipaddress-sample-3 - // Sample 2: /subscriptions/88fd8cb2-8248-499e-9a2d-4929a4b0133c/resourceGroups/resourcegroup-azure-operators/providers/Microsoft.Network/virtualNetworks/vnet-sample-hpf-1/subnets/test2 - segments := []string{ - "subscriptions", - subscriptionId, - "resourceGroups", - resourceGroupName, - "providers", - provider, - resourceType, - resourceName, - subResourceType, - subResourceName, - } - - result := "/" + strings.Join(segments, "/") - - return result -} - func (m *AzureNetworkInterfaceClient) CreateNetworkInterface(ctx context.Context, location string, resourceGroupName string, resourceName string, vnetName string, subnetName string, publicIPAddressName string) (future vnetwork.InterfacesCreateOrUpdateFuture, err error) { client := getNetworkInterfaceClient() - subnetIDInput := MakeResourceId( + subnetIDInput := helpers.MakeResourceID( client.SubscriptionID, resourceGroupName, "Microsoft.Network", @@ -69,7 +48,7 @@ func (m *AzureNetworkInterfaceClient) CreateNetworkInterface(ctx context.Context subnetName, ) - publicIPAddressIDInput := MakeResourceId( + publicIPAddressIDInput := helpers.MakeResourceID( client.SubscriptionID, resourceGroupName, "Microsoft.Network", diff --git a/pkg/resourcemanager/nic/manager.go b/pkg/resourcemanager/nic/manager.go index da8a7754b79..32a9f649bcf 100644 --- a/pkg/resourcemanager/nic/manager.go +++ b/pkg/resourcemanager/nic/manager.go @@ -23,7 +23,7 @@ type NetworkInterfaceManager interface { resourceName string, resourceGroupName string) (string, error) - GetNetwork(ctx context.Context, + GetNetworkInterface(ctx context.Context, resourceGroupName string, resourceName string) (network.Interface, error) diff --git a/pkg/resourcemanager/storages/blobcontainer/blob_container_reconcile.go b/pkg/resourcemanager/storages/blobcontainer/blob_container_reconcile.go index bf3d5b82b9a..ea7c46f44a6 100644 --- a/pkg/resourcemanager/storages/blobcontainer/blob_container_reconcile.go +++ b/pkg/resourcemanager/storages/blobcontainer/blob_container_reconcile.go @@ -125,6 +125,13 @@ func (bc *AzureBlobContainerManager) GetParents(obj runtime.Object) ([]resourcem } return []resourcemanager.KubeParent{ + { + Key: types.NamespacedName{ + Name: instance.Spec.AccountName, + Namespace: instance.Namespace, + }, + Target: &azurev1alpha1.StorageAccount{}, + }, { Key: types.NamespacedName{ Name: instance.Spec.ResourceGroup, @@ -133,6 +140,7 @@ func (bc *AzureBlobContainerManager) GetParents(obj runtime.Object) ([]resourcem Target: &azurev1alpha1.ResourceGroup{}, }, }, nil + } func (bc *AzureBlobContainerManager) GetStatus(obj runtime.Object) (*azurev1alpha1.ASOStatus, error) { diff --git a/pkg/resourcemanager/storages/storageaccount/storageaccount.go b/pkg/resourcemanager/storages/storageaccount/storageaccount.go index f8357e57817..e2e75b353d6 100644 --- a/pkg/resourcemanager/storages/storageaccount/storageaccount.go +++ b/pkg/resourcemanager/storages/storageaccount/storageaccount.go @@ -7,8 +7,10 @@ import ( "context" "errors" "log" + "strings" "github.com/Azure/azure-sdk-for-go/services/storage/mgmt/2019-04-01/storage" + "github.com/Azure/azure-service-operator/api/v1alpha1" azurev1alpha1 "github.com/Azure/azure-service-operator/api/v1alpha1" "github.com/Azure/azure-service-operator/pkg/resourcemanager/config" "github.com/Azure/azure-service-operator/pkg/resourcemanager/iam" @@ -18,6 +20,56 @@ import ( type azureStorageManager struct{} +// ParseNetworkPolicy - helper function to parse network policies from Kubernetes spec +func ParseNetworkPolicy(ruleSet *v1alpha1.StorageNetworkRuleSet) storage.NetworkRuleSet { + + bypass := storage.AzureServices + switch ruleSet.Bypass { + case "AzureServices": + bypass = storage.AzureServices + case "None": + bypass = storage.None + case "Logging": + bypass = storage.Logging + case "Metrics": + bypass = storage.Metrics + } + + defaultAction := storage.DefaultActionDeny + if strings.EqualFold(ruleSet.DefaultAction, "allow") { + defaultAction = storage.DefaultActionAllow + } + + var ipInstances []storage.IPRule + if ruleSet.IPRules != nil { + for _, i := range *ruleSet.IPRules { + ipmask := i.IPAddressOrRange + ipInstances = append(ipInstances, storage.IPRule{ + IPAddressOrRange: ipmask, + Action: storage.Allow, + }) + } + } + + var vnetInstances []storage.VirtualNetworkRule + if ruleSet.VirtualNetworkRules != nil { + for _, i := range *ruleSet.VirtualNetworkRules { + vnetID := i.SubnetId + vnetInstances = append(vnetInstances, storage.VirtualNetworkRule{ + VirtualNetworkResourceID: vnetID, + Action: storage.Allow, + }) + } + } + + return storage.NetworkRuleSet{ + Bypass: bypass, + DefaultAction: defaultAction, + IPRules: &ipInstances, + VirtualNetworkRules: &vnetInstances, + } +} + func getStoragesClient() storage.AccountsClient { storagesClient := storage.NewAccountsClientWithBaseURI(config.BaseURI(), config.SubscriptionID()) a, err := iam.GetResourceManagementAuthorizer() @@ -30,14 +82,15 @@ func getStoragesClient() storage.AccountsClient { } // CreateStorage creates a new storage account -func (_ *azureStorageManager) CreateStorage(ctx context.Context, groupName string, +func (_ *azureStorageManager) CreateStorage(ctx context.Context, + groupName string, storageAccountName string, location string, sku azurev1alpha1.StorageAccountSku, kind azurev1alpha1.StorageAccountKind, tags map[string]*string, accessTier azurev1alpha1.StorageAccountAccessTier, - enableHTTPsTrafficOnly *bool, dataLakeEnabled *bool) (result storage.Account, err error) { + enableHTTPsTrafficOnly *bool, dataLakeEnabled *bool, networkRule *storage.NetworkRuleSet) (pollingURL string, result storage.Account, err error) { storagesClient := getStoragesClient() @@ -46,16 +99,19 @@ func (_ *azureStorageManager) CreateStorage(ctx context.Context, groupName strin checkAccountParams := storage.AccountCheckNameAvailabilityParameters{Name: &storageAccountName, Type: &storageType} checkNameResult, err := storagesClient.CheckNameAvailability(ctx, checkAccountParams) if err != nil { - return result, err + return "", result, err } if dataLakeEnabled == to.BoolPtr(true) && kind != "StorageV2" { - return result, errors.New("unable to create datalake enabled storage account") + err = errors.New("unable to create datalake enabled storage account") + return } if *checkNameResult.NameAvailable == false { if checkNameResult.Reason == storage.AccountNameInvalid { - return result, errors.New("AccountNameInvalid") + err = errors.New("AccountNameInvalid") + return } else if checkNameResult.Reason == storage.AlreadyExists { - return result, errors.New("AlreadyExists") + err = errors.New("AlreadyExists") + return } } @@ -73,16 +129,19 @@ func (_ *azureStorageManager) CreateStorage(ctx context.Context, groupName strin AccessTier: sAccessTier, EnableHTTPSTrafficOnly: enableHTTPsTrafficOnly, IsHnsEnabled: dataLakeEnabled, + NetworkRuleSet: networkRule, }, } //log.Println(fmt.Sprintf("creating storage '%s' in resource group '%s' and location: %v", storageAccountName, groupName, location)) future, err := storagesClient.Create(ctx, groupName, storageAccountName, params) if err != nil { - return result, err + return "", result, err } - return future.Result(storagesClient) + result, err = future.Result(storagesClient) + + return future.PollingURL(), result, err } diff --git a/pkg/resourcemanager/storages/storageaccount/storageaccount_manager.go b/pkg/resourcemanager/storages/storageaccount/storageaccount_manager.go index 00e5a6577f1..5ef05d8e7d2 100644 --- a/pkg/resourcemanager/storages/storageaccount/storageaccount_manager.go +++ b/pkg/resourcemanager/storages/storageaccount/storageaccount_manager.go @@ -18,14 +18,15 @@ func New() *azureStorageManager { } type StorageManager interface { - CreateStorage(ctx context.Context, groupName string, + CreateStorage(ctx context.Context, + groupName string, storageAccountName string, location string, sku azurev1alpha1.StorageAccountSku, kind azurev1alpha1.StorageAccountKind, tags map[string]*string, accessTier azurev1alpha1.StorageAccountAccessTier, - enableHTTPsTrafficOnly *bool, dataLakeEnabled *bool) (result storage.Account, err error) + enableHTTPsTrafficOnly *bool, dataLakeEnabled *bool, networkRule *storage.NetworkRuleSet) (pollingURL string, result storage.Account, err error) // Get gets the description of the specified storage account. // Parameters: diff --git a/pkg/resourcemanager/storages/storageaccount/storageaccount_reconcile.go b/pkg/resourcemanager/storages/storageaccount/storageaccount_reconcile.go index 488a3b73d01..b21e2e2b682 100644 --- a/pkg/resourcemanager/storages/storageaccount/storageaccount_reconcile.go +++ b/pkg/resourcemanager/storages/storageaccount/storageaccount_reconcile.go @@ -7,10 +7,13 @@ import ( "context" "fmt" + "github.com/Azure/azure-sdk-for-go/services/storage/mgmt/2019-04-01/storage" azurev1alpha1 "github.com/Azure/azure-service-operator/api/v1alpha1" "github.com/Azure/azure-service-operator/pkg/errhelp" "github.com/Azure/azure-service-operator/pkg/helpers" "github.com/Azure/azure-service-operator/pkg/resourcemanager" + "github.com/Azure/azure-service-operator/pkg/resourcemanager/pollclient" + "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/types" ) @@ -31,24 +34,48 @@ func (sa *azureStorageManager) Ensure(ctx context.Context, obj runtime.Object, o accessTier := instance.Spec.AccessTier enableHTTPSTrafficOnly := instance.Spec.EnableHTTPSTrafficOnly dataLakeEnabled := instance.Spec.DataLakeEnabled + pollURL := instance.Status.PollingURL - // convert kube labels to expected tag format - labels := map[string]*string{} - for k, v := range instance.GetLabels() { - value := v - labels[k] = &value + networkAcls := storage.NetworkRuleSet{} + + if instance.Spec.NetworkRule != nil { + networkAcls = ParseNetworkPolicy(instance.Spec.NetworkRule) } + // convert kube labels to expected tag format + labels := helpers.LabelsToTags(instance.GetLabels()) hash := "" stor, err := sa.GetStorage(ctx, groupName, name) if err != nil { instance.Status.Message = err.Error() instance.Status.State = "NotReady" + + // handle failures in the async operation + if pollURL != "" { + pClient := pollclient.NewPollClient() + res, err := pClient.Get(ctx, pollURL) + azerr := errhelp.NewAzureErrorAzureError(err) + if err != nil { + if azerr.Type == errhelp.NetworkAclsValidationFailure { + instance.Status.Message = "Unable to provision Azure Storage Account due to error: " + errhelp.StripErrorIDs(err) + instance.Status.Provisioning = false + instance.Status.Provisioned = false + return true, nil + } + return false, err + } + + if res.Status == "Failed" { + instance.Status.Message = res.Error.Error() + instance.Status.Provisioning = false + return true, nil + } + } } else { instance.Status.State = string(stor.ProvisioningState) hash = helpers.Hash256(instance.Spec) - if instance.Status.SpecHash == hash && instance.Status.Provisioned { + if instance.Status.SpecHash == hash && (instance.Status.Provisioned || instance.Status.FailedProvisioning) { instance.Status.RequestedAt = nil return true, nil } @@ -60,13 +87,14 @@ func (sa *azureStorageManager) Ensure(ctx context.Context, obj runtime.Object, o instance.Status.Provisioning = false instance.Status.SpecHash = hash instance.Status.ResourceId = *stor.ID + instance.Status.PollingURL = "" return true, nil } instance.Status.Provisioning = true instance.Status.Provisioned = false - _, err = sa.CreateStorage(ctx, groupName, name, location, sku, kind, labels, accessTier, enableHTTPSTrafficOnly, dataLakeEnabled) + pollURL, _, err = sa.CreateStorage(ctx, groupName, name, location, sku, kind, labels, accessTier, enableHTTPSTrafficOnly, dataLakeEnabled, &networkAcls) if err != nil { instance.Status.Message = err.Error() azerr := errhelp.NewAzureErrorAzureError(err) @@ -100,19 +128,26 @@ func (sa *azureStorageManager) Ensure(ctx context.Context, obj runtime.Object, o instance.Status.Message = "Storage Account Already exists somewhere else" return true, nil } + + instance.Status.Message = "Storage Account already exists and should be available shortly" instance.Status.Provisioning = true } if azerr.Type == errhelp.AsyncOpIncompleteError { instance.Status.Provisioning = true + instance.Status.PollingURL = pollURL } return false, nil } stop := []string{ errhelp.AccountNameInvalid, + errhelp.NetworkAclsValidationFailure, } if helpers.ContainsString(stop, azerr.Type) { + instance.Status.Message = "Unable to provision Azure Storage Account due to error: " + errhelp.StripErrorIDs(err) + instance.Status.Provisioning = false + instance.Status.Provisioned = false return true, nil } diff --git a/pkg/resourcemanager/vm/client.go b/pkg/resourcemanager/vm/client.go new file mode 100644 index 00000000000..da6b737c393 --- /dev/null +++ b/pkg/resourcemanager/vm/client.go @@ -0,0 +1,191 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +package vm + +import ( + "context" + "fmt" + "strings" + + compute "github.com/Azure/azure-sdk-for-go/services/compute/mgmt/2018-10-01/compute" + azurev1alpha1 "github.com/Azure/azure-service-operator/api/v1alpha1" + "github.com/Azure/azure-service-operator/pkg/helpers" + "github.com/Azure/azure-service-operator/pkg/resourcemanager/config" + "github.com/Azure/azure-service-operator/pkg/resourcemanager/iam" + "github.com/Azure/azure-service-operator/pkg/secrets" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" +) + +type AzureVirtualMachineClient struct { + SecretClient secrets.SecretClient + Scheme *runtime.Scheme +} + +func NewAzureVirtualMachineClient(secretclient secrets.SecretClient, scheme *runtime.Scheme) *AzureVirtualMachineClient { + return &AzureVirtualMachineClient{ + SecretClient: secretclient, + Scheme: scheme, + } +} + +func getVirtualMachineClient() compute.VirtualMachinesClient { + computeClient := compute.NewVirtualMachinesClientWithBaseURI(config.BaseURI(), config.SubscriptionID()) + a, _ := iam.GetResourceManagementAuthorizer() + computeClient.Authorizer = a + computeClient.AddToUserAgent(config.UserAgent()) + return computeClient +} + +func (m *AzureVirtualMachineClient) CreateVirtualMachine(ctx context.Context, location string, resourceGroupName string, resourceName string, vmSize string, osType string, adminUserName string, adminPassword string, sshPublicKeyData string, networkInterfaceName string, platformImageURN string) (future compute.VirtualMachinesCreateOrUpdateFuture, err error) { + + client := getVirtualMachineClient() + + vmSizeInput := compute.VirtualMachineSizeTypes(vmSize) + provisionVMAgent := true + platformImageUrnTokens := strings.Split(platformImageURN, ":") + + adminPasswordInput := "" + adminPasswordBase64Decoded := helpers.FromBase64EncodedString(adminPassword) + if adminPasswordBase64Decoded != "" { + adminPasswordInput = adminPasswordBase64Decoded + } + + addAsPrimaryNic := true + nicIDInput := helpers.MakeResourceID( + client.SubscriptionID, + resourceGroupName, + "Microsoft.Network", + "networkInterfaces", + networkInterfaceName, + "", + "", + ) + + nicsToAdd := []compute.NetworkInterfaceReference{ + compute.NetworkInterfaceReference{ + ID: &nicIDInput, + NetworkInterfaceReferenceProperties: &compute.NetworkInterfaceReferenceProperties{ + Primary: &addAsPrimaryNic, + }, + }, + } + + sshKeyPath := fmt.Sprintf("/home/%s/.ssh/authorized_keys", adminUserName) + sshKeysToAdd := []compute.SSHPublicKey{ + compute.SSHPublicKey{ + Path: &sshKeyPath, + KeyData: &sshPublicKeyData, + }, + } + linuxProfile := compute.OSProfile{ + ComputerName: &resourceName, + AdminUsername: &adminUserName, + AdminPassword: &adminPasswordInput, + LinuxConfiguration: &compute.LinuxConfiguration{ + SSH: &compute.SSHConfiguration{ + PublicKeys: &sshKeysToAdd, + }, + ProvisionVMAgent: &provisionVMAgent, + }, + } + + windowsProfile := compute.OSProfile{ + ComputerName: &resourceName, + AdminUsername: &adminUserName, + AdminPassword: &adminPasswordInput, + WindowsConfiguration: &compute.WindowsConfiguration{ + ProvisionVMAgent: &provisionVMAgent, + }, + } + + osProfile := linuxProfile + if osType == "Windows" { + osProfile = windowsProfile + } + + future, err = client.CreateOrUpdate( + ctx, + resourceGroupName, + resourceName, + compute.VirtualMachine{ + Location: &location, + VirtualMachineProperties: &compute.VirtualMachineProperties{ + HardwareProfile: &compute.HardwareProfile{ + VMSize: vmSizeInput, + }, + StorageProfile: &compute.StorageProfile{ + ImageReference: &compute.ImageReference{ + Publisher: &platformImageUrnTokens[0], + Offer: &platformImageUrnTokens[1], + Sku: &platformImageUrnTokens[2], + Version: &platformImageUrnTokens[3], + }, + }, + OsProfile: &osProfile, + NetworkProfile: &compute.NetworkProfile{ + NetworkInterfaces: &nicsToAdd, + }, + }, + }, + ) + + return future, err +} + +func (m *AzureVirtualMachineClient) DeleteVirtualMachine(ctx context.Context, vmName string, resourcegroup string) (status string, err error) { + + client := getVirtualMachineClient() + + _, err = client.Get(ctx, resourcegroup, vmName, "") + if err == nil { // vm present, so go ahead and delete + future, err := client.Delete(ctx, resourcegroup, vmName) + return future.Status(), err + } + // VM not present so return success anyway + return "VM not present", nil + +} + +func (m *AzureVirtualMachineClient) GetVirtualMachine(ctx context.Context, resourcegroup string, vmName string) (vm compute.VirtualMachine, err error) { + + client := getVirtualMachineClient() + + return client.Get(ctx, resourcegroup, vmName, "") +} + +func (p *AzureVirtualMachineClient) AddVirtualMachineCredsToSecrets(ctx context.Context, secretName string, data map[string][]byte, instance *azurev1alpha1.AzureVirtualMachine) error { + key := types.NamespacedName{ + Name: secretName, + Namespace: instance.Namespace, + } + + err := p.SecretClient.Upsert(ctx, + key, + data, + secrets.WithOwner(instance), + secrets.WithScheme(p.Scheme), + ) + if err != nil { + return err + } + + return nil +} + +func (p *AzureVirtualMachineClient) GetOrPrepareSecret(ctx context.Context, instance *azurev1alpha1.AzureVirtualMachine) (map[string][]byte, error) { + name := instance.Name + + secret := map[string][]byte{} + + key := types.NamespacedName{Name: name, Namespace: instance.Namespace} + if stored, err := p.SecretClient.Get(ctx, key); err == nil { + return stored, nil + } + + randomPassword := helpers.NewPassword() + secret["password"] = []byte(randomPassword) + + return secret, nil +} diff --git a/pkg/resourcemanager/vm/manager.go b/pkg/resourcemanager/vm/manager.go new file mode 100644 index 00000000000..d078d3dd60e --- /dev/null +++ b/pkg/resourcemanager/vm/manager.go @@ -0,0 +1,36 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +package vm + +import ( + "context" + + compute "github.com/Azure/azure-sdk-for-go/services/compute/mgmt/2018-10-01/compute" + "github.com/Azure/azure-service-operator/pkg/resourcemanager" +) + +type VirtualMachineManager interface { + CreateVirtualMachine(ctx context.Context, + location string, + resourceGroupName string, + resourceName string, + vmSize string, + osType string, + adminUserName string, + adminPassword string, + sshPublicKeyData string, + networkInterfaceName string, + platformImageURN string) (compute.VirtualMachine, error) + + DeleteVirtualMachine(ctx context.Context, + resourceName string, + resourceGroupName string) (string, error) + + GetVirtualMachine(ctx context.Context, + resourceGroupName string, + resourceName string) (compute.VirtualMachine, error) + + // also embed async client methods + resourcemanager.ARMClient +} diff --git a/pkg/resourcemanager/vm/reconcile.go b/pkg/resourcemanager/vm/reconcile.go new file mode 100644 index 00000000000..ec9e5153e76 --- /dev/null +++ b/pkg/resourcemanager/vm/reconcile.go @@ -0,0 +1,203 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +package vm + +import ( + "context" + "fmt" + + azurev1alpha1 "github.com/Azure/azure-service-operator/api/v1alpha1" + "github.com/Azure/azure-service-operator/pkg/errhelp" + "github.com/Azure/azure-service-operator/pkg/helpers" + "github.com/Azure/azure-service-operator/pkg/resourcemanager" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" +) + +func (g *AzureVirtualMachineClient) Ensure(ctx context.Context, obj runtime.Object, opts ...resourcemanager.ConfigOption) (bool, error) { + + instance, err := g.convert(obj) + if err != nil { + return true, err + } + + client := getVirtualMachineClient() + + location := instance.Spec.Location + resourceGroup := instance.Spec.ResourceGroup + resourceName := instance.Name + vmSize := instance.Spec.VMSize + osType := instance.Spec.OSType + adminUserName := instance.Spec.AdminUserName + sshPublicKeyData := instance.Spec.SSHPublicKeyData + nicName := instance.Spec.NetworkInterfaceName + imageURN := instance.Spec.PlatformImageURN + + // Check to see if secret exists and if yes retrieve the admin login and password + secret, err := g.GetOrPrepareSecret(ctx, instance) + if err != nil { + return false, err + } + // Update secret + err = g.AddVirtualMachineCredsToSecrets(ctx, instance.Name, secret, instance) + if err != nil { + return false, err + } + + adminPassword := string(secret["password"]) + + instance.Status.Provisioning = true + // Check if this item already exists. This is required + // to overcome the issue with the lack of idempotence of the Create call + item, err := g.GetVirtualMachine(ctx, resourceGroup, resourceName) + if err == nil { + instance.Status.Provisioned = true + instance.Status.Provisioning = false + instance.Status.Message = resourcemanager.SuccessMsg + instance.Status.ResourceId = *item.ID + return true, nil + } + future, err := g.CreateVirtualMachine( + ctx, + location, + resourceGroup, + resourceName, + vmSize, + string(osType), + adminUserName, + adminPassword, + sshPublicKeyData, + nicName, + imageURN, + ) + if err != nil { + // let the user know what happened + instance.Status.Message = err.Error() + instance.Status.Provisioning = false + // errors we expect might happen that we are ok with waiting for + catch := []string{ + errhelp.ResourceGroupNotFoundErrorCode, + errhelp.ParentNotFoundErrorCode, + errhelp.NotFoundErrorCode, + errhelp.AsyncOpIncompleteError, + errhelp.ResourceNotFound, + errhelp.InvalidResourceReference, + } + + azerr := errhelp.NewAzureErrorAzureError(err) + if helpers.ContainsString(catch, azerr.Type) { + // most of these error technically mean the resource is actually not provisioning + switch azerr.Type { + case errhelp.AsyncOpIncompleteError: + instance.Status.Provisioning = true + } + // reconciliation is not done but error is acceptable + return false, nil + } + // reconciliation not done and we don't know what happened + return false, err + } + + _, err = future.Result(client) + if err != nil { + // let the user know what happened + instance.Status.Message = err.Error() + instance.Status.Provisioning = false + // errors we expect might happen that we are ok with waiting for + catch := []string{ + errhelp.ResourceGroupNotFoundErrorCode, + errhelp.ParentNotFoundErrorCode, + errhelp.NotFoundErrorCode, + errhelp.AsyncOpIncompleteError, + errhelp.SubscriptionDoesNotHaveServer, + } + + azerr := errhelp.NewAzureErrorAzureError(err) + if helpers.ContainsString(catch, azerr.Type) { + // most of these error technically mean the resource is actually not provisioning + switch azerr.Type { + case errhelp.AsyncOpIncompleteError: + instance.Status.Provisioning = true + } + // reconciliation is not done but error is acceptable + return false, nil + } + // reconciliation not done and we don't know what happened + return false, err + } + + if instance.Status.Provisioning { + instance.Status.Provisioned = true + instance.Status.Provisioning = false + instance.Status.Message = resourcemanager.SuccessMsg + } else { + instance.Status.Provisioned = false + instance.Status.Provisioning = true + } + return true, nil +} + +func (g *AzureVirtualMachineClient) Delete(ctx context.Context, obj runtime.Object, opts ...resourcemanager.ConfigOption) (bool, error) { + + instance, err := g.convert(obj) + if err != nil { + return true, err + } + + resourceGroup := instance.Spec.ResourceGroup + resourceName := instance.Name + + status, err := g.DeleteVirtualMachine( + ctx, + resourceName, + resourceGroup, + ) + if err != nil { + if !errhelp.IsAsynchronousOperationNotComplete(err) { + return true, err + } + } + + if err == nil { + if status != "InProgress" { + return false, nil + } + } + + return true, nil +} +func (g *AzureVirtualMachineClient) GetParents(obj runtime.Object) ([]resourcemanager.KubeParent, error) { + + instance, err := g.convert(obj) + if err != nil { + return nil, err + } + + return []resourcemanager.KubeParent{ + { + Key: types.NamespacedName{ + Namespace: instance.Namespace, + Name: instance.Spec.ResourceGroup, + }, + Target: &azurev1alpha1.ResourceGroup{}, + }, + }, nil +} + +func (g *AzureVirtualMachineClient) GetStatus(obj runtime.Object) (*azurev1alpha1.ASOStatus, error) { + + instance, err := g.convert(obj) + if err != nil { + return nil, err + } + return &instance.Status, nil +} + +func (g *AzureVirtualMachineClient) convert(obj runtime.Object) (*azurev1alpha1.AzureVirtualMachine, error) { + local, ok := obj.(*azurev1alpha1.AzureVirtualMachine) + if !ok { + return nil, fmt.Errorf("failed type assertion on kind: %s", obj.GetObjectKind().GroupVersionKind().String()) + } + return local, nil +} diff --git a/pkg/resourcemanager/vnet/reconcile.go b/pkg/resourcemanager/vnet/reconcile.go index 81355dd3079..931147dbfd7 100644 --- a/pkg/resourcemanager/vnet/reconcile.go +++ b/pkg/resourcemanager/vnet/reconcile.go @@ -29,18 +29,21 @@ func (g *AzureVNetManager) Ensure(ctx context.Context, obj runtime.Object, opts addressSpace := instance.Spec.AddressSpace subnets := instance.Spec.Subnets - instance.Status.Provisioning = true - instance.Status.Provisioned = false - // check first to see if the VNet exists, if it does, dont create it and // consider the reconcilliation successful - if exists, _ := g.VNetExists(ctx, resourceGroup, resourceName); exists { + vNet, err := g.GetVNet(ctx, resourceGroup, resourceName) + if err == nil { + // succeeded! end reconcilliation successfully instance.Status.Provisioning = false instance.Status.Provisioned = true instance.Status.Message = resourcemanager.SuccessMsg + instance.Status.ResourceId = *vNet.ID return true, nil } + instance.Status.Provisioning = true + instance.Status.Provisioned = false + _, err = g.CreateVNet( ctx, location, @@ -84,13 +87,7 @@ func (g *AzureVNetManager) Ensure(ctx context.Context, obj runtime.Object, opts return false, fmt.Errorf("Error creating VNet: %s, %s - %v", resourceGroup, resourceName, err) } - // success - instance.Status.Message = resourcemanager.SuccessMsg - instance.Status.Provisioning = false - instance.Status.Provisioned = true - instance.Status.Message = resourcemanager.SuccessMsg - - return true, nil + return false, nil } // Delete makes sure that the VNet has been deleted diff --git a/pkg/resourcemanager/vnet/vnet.go b/pkg/resourcemanager/vnet/vnet.go index 7dd91ac934c..00e43f907d4 100644 --- a/pkg/resourcemanager/vnet/vnet.go +++ b/pkg/resourcemanager/vnet/vnet.go @@ -92,23 +92,12 @@ func (_ *AzureVNetManager) DeleteVNet(ctx context.Context, resourceGroupName str return future.Result(client) } -// VNetExists checks to see if a VNet exists -func (_ *AzureVNetManager) VNetExists(ctx context.Context, resourceGroupName string, resourceName string) (bool, error) { +// GetVNet gets a VNet +func (v *AzureVNetManager) GetVNet(ctx context.Context, resourceGroupName string, resourceName string) (vNet vnetwork.VirtualNetwork, err error) { client, err := getVNetClient() if err != nil { - return false, err - } - - result, err := client.Get( - ctx, - resourceGroupName, - resourceName, - "") - if err != nil { - return false, err - } else if result.Name == nil { - return false, nil + return vnetwork.VirtualNetwork{}, err } - return true, nil + return client.Get(ctx, resourceGroupName, resourceName, "") } diff --git a/pkg/resourcemanager/vnet/vnet_manager.go b/pkg/resourcemanager/vnet/vnet_manager.go index dd9bd6b8444..246d4986bfe 100644 --- a/pkg/resourcemanager/vnet/vnet_manager.go +++ b/pkg/resourcemanager/vnet/vnet_manager.go @@ -30,9 +30,9 @@ type VNetManager interface { resourceGroupName string, resourceName string) (autorest.Response, error) - VNetExists(ctx context.Context, + GetVNet(ctx context.Context, resourceGroupName string, - resourceName string) (bool, error) + resourceName string) (vnetwork.VirtualNetwork, error) // also embed async client methods resourcemanager.ARMClient diff --git a/pkg/resourcemanager/vnet/vnet_test.go b/pkg/resourcemanager/vnet/vnet_test.go index df442c02917..320cff05984 100644 --- a/pkg/resourcemanager/vnet/vnet_test.go +++ b/pkg/resourcemanager/vnet/vnet_test.go @@ -42,11 +42,11 @@ var _ = Describe("VNet", func() { // Create vnet instance Eventually(func() bool { time.Sleep(3 * time.Second) - exists, _ := vnetManager.VNetExists(ctx, rgName, vnetName) - if exists { + _, err := vnetManager.GetVNet(ctx, rgName, vnetName) + if err == nil { return true } - _, err := vnetManager.CreateVNet(ctx, location, rgName, vnetName, addressSpace, []azurev1alpha1.VNetSubnets{ + _, err = vnetManager.CreateVNet(ctx, location, rgName, vnetName, addressSpace, []azurev1alpha1.VNetSubnets{ azurev1alpha1.VNetSubnets{ SubnetName: subnetName, SubnetAddressPrefix: subnetPrefix, @@ -68,11 +68,11 @@ var _ = Describe("VNet", func() { // Delete vnet instance Eventually(func() bool { time.Sleep(3 * time.Second) - exists, _ := vnetManager.VNetExists(ctx, rgName, vnetName) - if !exists { + _, err := vnetManager.GetVNet(ctx, rgName, vnetName) + if err != nil { return true } - _, err := vnetManager.DeleteVNet(ctx, rgName, vnetName) + _, err = vnetManager.DeleteVNet(ctx, rgName, vnetName) if err != nil { fmt.Println(err.Error()) if !errhelp.IsAsynchronousOperationNotComplete(err) { diff --git a/pkg/util/util.go b/pkg/util/util.go deleted file mode 100644 index e85ed10f8e8..00000000000 --- a/pkg/util/util.go +++ /dev/null @@ -1,38 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -package util - -import ( - "encoding/json" - "fmt" - "io/ioutil" - "log" -) - -// PrintAndLog writes to stdout and to a logger. -func PrintAndLog(message string) { - log.Println(message) - fmt.Println(message) -} - -func Contains(array []string, element string) bool { - for _, e := range array { - if e == element { - return true - } - } - return false -} - -// ReadJSON reads a json file, and unmashals it. -// Very useful for template deployments. -func ReadJSON(path string) (*map[string]interface{}, error) { - data, err := ioutil.ReadFile(path) - if err != nil { - log.Fatalf("failed to read template file: %v\n", err) - } - contents := make(map[string]interface{}) - json.Unmarshal(data, &contents) - return &contents, nil -}