diff --git a/.github/labeler-issue-triage.yml b/.github/labeler-issue-triage.yml index 4ed2475cf41c..50a9cc268245 100644 --- a/.github/labeler-issue-triage.yml +++ b/.github/labeler-issue-triage.yml @@ -119,6 +119,9 @@ service/datadog: service/dev-center: - '### (|New or )Affected Resource\(s\)\/Data Source\(s\)((.|\n)*)azurerm_dev_center((.|\n)*)###' +service/deviceregistry: + - '### (|New or )Affected Resource\(s\)\/Data Source\(s\)((.|\n)*)azurerm_device_registry_asset((.|\n)*)###' + service/devtestlabs: - '### (|New or )Affected Resource\(s\)\/Data Source\(s\)((.|\n)*)azurerm_dev_test_((.|\n)*)###' diff --git a/.github/labeler-pull-request-triage.yml b/.github/labeler-pull-request-triage.yml index f2a786bebfa9..67646d289314 100644 --- a/.github/labeler-pull-request-triage.yml +++ b/.github/labeler-pull-request-triage.yml @@ -198,6 +198,11 @@ service/dev-center: - any-glob-to-any-file: - internal/services/devcenter/**/* +service/deviceregistry: +- changed-files: + - any-glob-to-any-file: + - internal/services/deviceregistry/**/* + service/devtestlabs: - changed-files: - any-glob-to-any-file: diff --git a/.teamcity/components/generated/services.kt b/.teamcity/components/generated/services.kt index 9d296b6a355b..3419bfbd85db 100644 --- a/.teamcity/components/generated/services.kt +++ b/.teamcity/components/generated/services.kt @@ -46,6 +46,7 @@ var services = mapOf( "desktopvirtualization" to "Desktop Virtualization", "devcenter" to "Dev Center", "devtestlabs" to "Dev Test", + "deviceregistry" to "Device Registry", "digitaltwins" to "Digital Twins", "domainservices" to "DomainServices", "dynatrace" to "Dynatrace", diff --git a/CODEOWNERS b/CODEOWNERS index aa9fe086b7e1..0b3e01cc6c6f 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1 +1 @@ -* @hashicorp/terraform-azure +* @hashicorp/terraform-azure \ No newline at end of file diff --git a/internal/clients/client.go b/internal/clients/client.go index 1c2f60354183..b9363d621537 100644 --- a/internal/clients/client.go +++ b/internal/clients/client.go @@ -63,6 +63,7 @@ import ( dataprotection "github.com/hashicorp/terraform-provider-azurerm/internal/services/dataprotection/client" datashare "github.com/hashicorp/terraform-provider-azurerm/internal/services/datashare/client" desktopvirtualization "github.com/hashicorp/terraform-provider-azurerm/internal/services/desktopvirtualization/client" + deviceregistry "github.com/hashicorp/terraform-provider-azurerm/internal/services/deviceregistry/client" devtestlabs "github.com/hashicorp/terraform-provider-azurerm/internal/services/devtestlabs/client" digitaltwins "github.com/hashicorp/terraform-provider-azurerm/internal/services/digitaltwins/client" dns "github.com/hashicorp/terraform-provider-azurerm/internal/services/dns/client" @@ -199,6 +200,7 @@ type Client struct { DataProtection *dataprotection.Client DataShare *datashare.Client DesktopVirtualization *desktopvirtualization.Client + DeviceRegistry *deviceregistry.Client DevTestLabs *devtestlabs.Client DigitalTwins *digitaltwins.Client Dns *dns_v2018_05_01.Client @@ -421,6 +423,9 @@ func (client *Client) Build(ctx context.Context, o *common.ClientOptions) error if client.DesktopVirtualization, err = desktopvirtualization.NewClient(o); err != nil { return fmt.Errorf("building clients for DesktopVirtualization: %+v", err) } + if client.DeviceRegistry, err = deviceregistry.NewClient(o); err != nil { + return fmt.Errorf("building clients for DeviceRegistry: %+v", err) + } if client.DevTestLabs, err = devtestlabs.NewClient(o); err != nil { return fmt.Errorf("building clients for DevTestLabs: %+v", err) } diff --git a/internal/provider/services.go b/internal/provider/services.go index 8e5e45fa358f..2c6c99ff1e95 100644 --- a/internal/provider/services.go +++ b/internal/provider/services.go @@ -45,6 +45,7 @@ import ( "github.com/hashicorp/terraform-provider-azurerm/internal/services/dataprotection" "github.com/hashicorp/terraform-provider-azurerm/internal/services/datashare" "github.com/hashicorp/terraform-provider-azurerm/internal/services/desktopvirtualization" + "github.com/hashicorp/terraform-provider-azurerm/internal/services/deviceregistry" "github.com/hashicorp/terraform-provider-azurerm/internal/services/devtestlabs" "github.com/hashicorp/terraform-provider-azurerm/internal/services/digitaltwins" "github.com/hashicorp/terraform-provider-azurerm/internal/services/dns" @@ -167,6 +168,7 @@ func SupportedTypedServices() []sdk.TypedServiceRegistration { datafactory.Registration{}, dataprotection.Registration{}, desktopvirtualization.Registration{}, + deviceregistry.Registration{}, digitaltwins.Registration{}, dns.Registration{}, domainservices.Registration{}, diff --git a/internal/services/deviceregistry/README.md b/internal/services/deviceregistry/README.md new file mode 100644 index 000000000000..74015de7227d --- /dev/null +++ b/internal/services/deviceregistry/README.md @@ -0,0 +1,55 @@ +Overview of Device Registry Acceptance Tests +=== +The Azure Device Registry service has several arc-enabled resources, including Assets and Asset Endpoint Profiles (AEPs). These resources will only create successfully if there is an arc-enabled Kubernetes Cluster in Azure that runs all of Azure IoT Operations' (AIO) service and has a corresponding Custom Location. You can learn more about AIO [here](https://learn.microsoft.com/en-us/azure/iot-operations/). Because of this requirement, this makes acceptance testing Assets and AEP resources more complex since the tests must setup an AIO cluster. + +The solution that we have adapts that from the [Custom Location tests](https://github.com/hashicorp/terraform-provider-azurerm/blob/main/internal/services/extendedlocation/extended_location_custom_location_test.go), but it is a little more complex. Here is an overview of the process the Device Registry tests need to do: +1. First, each of the Device Registry acceptance tests apply a Terraform template to create an Azure Linux VM and all of the VM's infrastructure resources (e.g. public IP address, subnet, the resource group that will hold everything, etc). The VM will host the AIO cluster. The tests will also provision a bash script file to the VM which will execute all the commands needed to setup the AIO cluster. The bash script Terraform template file is [setup_aio_cluster.sh.tftpl](./testdata/setup_aio_cluster.sh.tftpl) and can be found in the `testdata` directory. The tests do not run the bash script yet. + +2. Before the Assets/AEPs resources are created, a `PreConfig` step is run. The tests execute some Go code to fetch the VM's public IP address and then uses the IP address to SSH into the VM and execute the bash script on the VM. The bash script will install Azure CLI and setup a [K3s cluster](https://k3s.io/) on the VM, and then run the AZ CLI commands from this [AIO quickstart](https://learn.microsoft.com/en-us/azure/iot-operations/get-started-end-to-end-sample/quickstart-deploy) to arc-enable the cluster and setup AIO services on it (which will also create the Custom Location). + - We must do it this way because even with the `depends_on` property, the tests do not wait for the VM to finish its `remote-exec` to run the bash script. Thus, the tests will fail as they will try to create Assets/AEPs while the AIO cluster and Custom Location are provisioning, throwing a "Custom Location (or other AIO resource) does not exist" error. This is the only way to sequentially execute the bash script to setup the AIO cluster and block the tests from prematurely creating the Asset/AEPs, as attempts to use `null_resource`, Go's `time.sleep()`, etc ended up not working (and stopped `remote-exec` from completing). Also, setting a time limit to wait for the cluster to finish is not recommended as the time to finish script execution can take anywhere between 2000-3500 seconds or even more. + +3. Once the bash script completes execution, then the rest of the test proceeds as normal; the test creates the Asset/AEP on the AIO cluster. Note: each resource's test scenarios currently creates a separate AIO cluster for each test scenario. So please make sure the Azure subscription has enough resources to concurrently create multiple VMs. + +4. When a test scenario finishes, the cleanup steps will run. The VM, Asset/AEP resource, and other VM infra resources will automatically be destroyed by the test cleanup. However, the AIO cluster and its own resources were created by the VM, not Terraform, so they would not get targeted for deletion by the tests. Fortunately, the tests created the resource group that contains all of these resources. So we specify to the acceptance tests to delete the entire resource group to cleanup the AIO cluster resources, as well. That is why the `prevent_deletion_if_contains_resources` flag is set to false in the tests: +``` +provider "azurerm" { + features { + resource_group { + prevent_deletion_if_contains_resources = false + } + } +} +``` + +How to run Device Registry Acceptance Tests +=== +1. On your own machine, login to Azure CLI as a user with ownership permissions of the Azure subscription the acceptance tests will run on. + +2. Run the following commands to enable the providers in your Azure subscription so that the AIO Cluster setup steps will not fail. You only have to do this once for your subscription and after that you can skip this step. +```bash +az provider register -n "Microsoft.ExtendedLocation" +az provider register -n "Microsoft.Kubernetes" +az provider register -n "Microsoft.KubernetesConfiguration" +az provider register -n "Microsoft.IoTOperations" +az provider register -n "Microsoft.DeviceRegistry" +az provider register -n "Microsoft.SecretSyncController" +``` + +3. Run `az ad sp show --id bc313c14-388c-4e7d-a58e-70017303ee3b --query id -o tsv` to get the Custom Location RP's Entra App Object ID. Store it in an environment variable `ARM_ENTRA_APP_OBJECT_ID` (`export ARM_ENTRA_APP_OBJECT_ID=`). In theory, you only need to run the `az ad sp show` command once because once you have the object ID, you can reuse that object ID in the acceptance test pipeline for future test runs. + +4. The following environment variables need to be set to run the Acceptance Tests. Make sure that the Service Principal running the tests has ownership permissions of the subscription so that the Azure CLI commands in the setup script do not fail. +```bash +# ID of the Azure subscription that the acceptance tests will run on +export ARM_SUBSCRIPTION_ID= + +# The Client ID of the Service Principal that will run the acceptance tests. +export ARM_CLIENT_ID= + +# The password of the Service Principal that will run the acceptance tests. +export ARM_CLIENT_SECRET= + +# The Object ID of the Custom Locations RP's Entra App, as mentioned in previous step. +export ARM_ENTRA_APP_OBJECT_ID= +``` + +5. Run the acceptance tests as normal. diff --git a/internal/services/deviceregistry/client/client.go b/internal/services/deviceregistry/client/client.go new file mode 100644 index 000000000000..dcc26a813c53 --- /dev/null +++ b/internal/services/deviceregistry/client/client.go @@ -0,0 +1,33 @@ +package client + +import ( + "fmt" + + "github.com/hashicorp/go-azure-sdk/resource-manager/deviceregistry/2024-11-01/assetendpointprofiles" + "github.com/hashicorp/go-azure-sdk/resource-manager/deviceregistry/2024-11-01/assets" + "github.com/hashicorp/terraform-provider-azurerm/internal/common" +) + +type Client struct { + AssetsClient *assets.AssetsClient + AssetEndpointProfilesClient *assetendpointprofiles.AssetEndpointProfilesClient +} + +func NewClient(o *common.ClientOptions) (*Client, error) { + assetsClient, err := assets.NewAssetsClientWithBaseURI(o.Environment.ResourceManager) + if err != nil { + return nil, fmt.Errorf("building Assets Client: %+v", err) + } + o.Configure(assetsClient.Client, o.Authorizers.ResourceManager) + + assetEndpointProfilesClient, err := assetendpointprofiles.NewAssetEndpointProfilesClientWithBaseURI(o.Environment.ResourceManager) + if err != nil { + return nil, fmt.Errorf("building Asset Endpoint Profiles Client: %+v", err) + } + o.Configure(assetEndpointProfilesClient.Client, o.Authorizers.ResourceManager) + + return &Client{ + AssetsClient: assetsClient, + AssetEndpointProfilesClient: assetEndpointProfilesClient, + }, nil +} diff --git a/internal/services/deviceregistry/device_registry_asset_endpoint_profile_resource.go b/internal/services/deviceregistry/device_registry_asset_endpoint_profile_resource.go new file mode 100644 index 000000000000..5f8a06496632 --- /dev/null +++ b/internal/services/deviceregistry/device_registry_asset_endpoint_profile_resource.go @@ -0,0 +1,376 @@ +package deviceregistry + +import ( + "context" + "fmt" + "time" + + "github.com/hashicorp/go-azure-helpers/lang/pointer" + "github.com/hashicorp/go-azure-helpers/lang/response" + "github.com/hashicorp/go-azure-helpers/resourcemanager/commonschema" + "github.com/hashicorp/go-azure-helpers/resourcemanager/location" + "github.com/hashicorp/go-azure-sdk/resource-manager/deviceregistry/2024-11-01/assetendpointprofiles" + "github.com/hashicorp/go-azure-sdk/resource-manager/extendedlocation/2021-08-15/customlocations" + "github.com/hashicorp/terraform-provider-azurerm/internal/sdk" + resourceParse "github.com/hashicorp/terraform-provider-azurerm/internal/services/resource/parse" + resourceValidate "github.com/hashicorp/terraform-provider-azurerm/internal/services/resource/validate" + "github.com/hashicorp/terraform-provider-azurerm/internal/tf/pluginsdk" + "github.com/hashicorp/terraform-provider-azurerm/internal/tf/validation" +) + +const ( + AssetEndpointProfileExtendedLocationTypeCustomLocation = "CustomLocation" +) + +var _ sdk.Resource = AssetEndpointProfileResource{} + +type AssetEndpointProfileResource struct{} + +type AssetEndpointProfileResourceModel struct { + Name string `tfschema:"name"` + ResourceGroupId string `tfschema:"resource_group_id"` + Location string `tfschema:"location"` + Tags map[string]string `tfschema:"tags"` + ExtendedLocationId string `tfschema:"extended_location_id"` + TargetAddress string `tfschema:"target_address"` + EndpointProfileType string `tfschema:"endpoint_profile_type"` + DiscoveredAssetEndpointProfileReference string `tfschema:"discovered_asset_endpoint_profile_reference"` + AdditionalConfiguration string `tfschema:"additional_configuration"` + Authentication []AuthenticationModel `tfschema:"authentication"` +} + +type AuthenticationModel struct { + Method string `tfschema:"method"` + UsernamePasswordCredentialsUsernameSecretName string `tfschema:"username_password_credential_username_secret_name"` + UsernamePasswordCredentialsPasswordSecretName string `tfschema:"username_password_credential_password_secret_name"` + X509CredentialsCertificateSecretName string `tfschema:"x509_credential_certificate_secret_name"` +} + +func (AssetEndpointProfileResource) Arguments() map[string]*pluginsdk.Schema { + return map[string]*pluginsdk.Schema{ + "name": { + Type: pluginsdk.TypeString, + Required: true, + ForceNew: true, + ValidateFunc: validation.StringIsNotEmpty, + }, + "resource_group_id": { + Type: pluginsdk.TypeString, + Required: true, + ValidateFunc: resourceValidate.ResourceGroupID, + }, + "extended_location_id": { + Type: pluginsdk.TypeString, + Required: true, + ValidateFunc: customlocations.ValidateCustomLocationID, + }, + "target_address": { + Type: pluginsdk.TypeString, + Required: true, + ValidateFunc: validation.StringIsNotEmpty, + }, + "endpoint_profile_type": { + Type: pluginsdk.TypeString, + Required: true, + ValidateFunc: validation.StringIsNotEmpty, + }, + "discovered_asset_endpoint_profile_reference": { + Type: pluginsdk.TypeString, + Optional: true, + }, + "additional_configuration": { + Type: pluginsdk.TypeString, + Optional: true, + }, + "authentication": { + Type: pluginsdk.TypeList, + Optional: true, + MaxItems: 1, + Elem: &pluginsdk.Resource{ + Schema: map[string]*pluginsdk.Schema{ + "method": { + Type: pluginsdk.TypeString, + Required: true, + ValidateFunc: validation.StringInSlice(assetendpointprofiles.PossibleValuesForAuthenticationMethod(), false), + }, + "username_password_credential_username_secret_name": { + Type: pluginsdk.TypeString, + Optional: true, + }, + "username_password_credential_password_secret_name": { + Type: pluginsdk.TypeString, + Optional: true, + }, + "x509_credential_certificate_secret_name": { + Type: pluginsdk.TypeString, + Optional: true, + }, + }, + }, + }, + "location": commonschema.Location(), + "tags": commonschema.Tags(), + } +} + +func (AssetEndpointProfileResource) Attributes() map[string]*pluginsdk.Schema { + return map[string]*pluginsdk.Schema{} +} + +func (AssetEndpointProfileResource) ModelObject() interface{} { + return &AssetEndpointProfileResourceModel{} +} + +func (AssetEndpointProfileResource) ResourceType() string { + return "azurerm_device_registry_asset_endpoint_profile" +} + +func (r AssetEndpointProfileResource) Create() sdk.ResourceFunc { + return sdk.ResourceFunc{ + Timeout: 30 * time.Minute, + Func: func(ctx context.Context, metadata sdk.ResourceMetaData) error { + client := metadata.Client.DeviceRegistry.AssetEndpointProfilesClient + + var config AssetEndpointProfileResourceModel + if err := metadata.Decode(&config); err != nil { + return fmt.Errorf("decoding: %+v", err) + } + + resourceGroupId, err := resourceParse.ResourceGroupID(config.ResourceGroupId) + if err != nil { + return err + } + + id := assetendpointprofiles.NewAssetEndpointProfileID(resourceGroupId.SubscriptionId, resourceGroupId.ResourceGroup, config.Name) + + existing, err := client.Get(ctx, id) + if err != nil && !response.WasNotFound(existing.HttpResponse) { + return fmt.Errorf("checking for presence of existing %s: %+v", id, err) + } + if !response.WasNotFound(existing.HttpResponse) { + return metadata.ResourceRequiresImport(r.ResourceType(), id) + } + + // Convert the TF model to the ARM model + // Optional ARM resource properties are pointers. + param := assetendpointprofiles.AssetEndpointProfile{ + Location: location.Normalize(config.Location), + Tags: pointer.To(config.Tags), + ExtendedLocation: assetendpointprofiles.ExtendedLocation{ + Name: config.ExtendedLocationId, + Type: AssetEndpointProfileExtendedLocationTypeCustomLocation, + }, + Properties: &assetendpointprofiles.AssetEndpointProfileProperties{ + TargetAddress: config.TargetAddress, + EndpointProfileType: config.EndpointProfileType, + }, + } + + if config.DiscoveredAssetEndpointProfileReference != "" { + param.Properties.DiscoveredAssetEndpointProfileRef = pointer.To(config.DiscoveredAssetEndpointProfileReference) + } + + if config.AdditionalConfiguration != "" { + param.Properties.AdditionalConfiguration = pointer.To(config.AdditionalConfiguration) + } + + param.Properties.Authentication = expandAuthentication(config.Authentication) + + if err := client.CreateOrReplaceThenPoll(ctx, id, param); err != nil { + return fmt.Errorf("creating %s: %+v", id, err) + } + + metadata.SetID(id) + return nil + }, + } +} + +func (r AssetEndpointProfileResource) Update() sdk.ResourceFunc { + return sdk.ResourceFunc{ + Timeout: 30 * time.Minute, + Func: func(ctx context.Context, metadata sdk.ResourceMetaData) error { + client := metadata.Client.DeviceRegistry.AssetEndpointProfilesClient + + id, err := assetendpointprofiles.ParseAssetEndpointProfileID(metadata.ResourceData.Id()) + if err != nil { + return err + } + + var config AssetEndpointProfileResourceModel + if err := metadata.Decode(&config); err != nil { + return fmt.Errorf("decoding: %+v", err) + } + + // Convert the TF model to the ARM model + param := assetendpointprofiles.AssetEndpointProfileUpdate{ + Properties: &assetendpointprofiles.AssetEndpointProfileUpdateProperties{}, + } + + if metadata.ResourceData.HasChange("tags") { + param.Tags = pointer.To(config.Tags) + } + + if metadata.ResourceData.HasChange("target_address") { + param.Properties.TargetAddress = pointer.To(config.TargetAddress) + } + + if metadata.ResourceData.HasChange("endpoint_profile_type") { + param.Properties.EndpointProfileType = pointer.To(config.EndpointProfileType) + } + + if metadata.ResourceData.HasChange("additional_configuration") { + param.Properties.AdditionalConfiguration = pointer.To(config.AdditionalConfiguration) + } + + if metadata.ResourceData.HasChange("authentication") { + authenticationModel := config.Authentication[0] + authentication := &assetendpointprofiles.AuthenticationUpdate{} + param.Properties.Authentication = authentication + + if metadata.ResourceData.HasChange("authentication.0.method") { + authentication.Method = pointer.To(assetendpointprofiles.AuthenticationMethod(authenticationModel.Method)) + } + + usernameSecretChanged := metadata.ResourceData.HasChange("authentication.0.username_password_credential_username_secret_name") + passwordSecretChanged := metadata.ResourceData.HasChange("authentication.0.username_password_credential_password_secret_name") + if usernameSecretChanged || passwordSecretChanged { + usernamePasswordCreds := assetendpointprofiles.UsernamePasswordCredentialsUpdate{} + authentication.UsernamePasswordCredentials = &usernamePasswordCreds + + if usernameSecretChanged { + usernamePasswordCreds.UsernameSecretName = pointer.To(authenticationModel.UsernamePasswordCredentialsUsernameSecretName) + } + + if passwordSecretChanged { + usernamePasswordCreds.PasswordSecretName = pointer.To(authenticationModel.UsernamePasswordCredentialsPasswordSecretName) + } + } + + if metadata.ResourceData.HasChange("authentication.0.x509_credential_certificate_secret_name") { + authentication.X509Credentials = &assetendpointprofiles.X509CredentialsUpdate{} + authentication.X509Credentials.CertificateSecretName = pointer.To(authenticationModel.X509CredentialsCertificateSecretName) + } + } + + if err := client.UpdateThenPoll(ctx, *id, param); err != nil { + return fmt.Errorf("updating %s: %+v", id, err) + } + return nil + }, + } +} + +func (AssetEndpointProfileResource) Read() sdk.ResourceFunc { + return sdk.ResourceFunc{ + Timeout: 5 * time.Minute, + Func: func(ctx context.Context, metadata sdk.ResourceMetaData) error { + client := metadata.Client.DeviceRegistry.AssetEndpointProfilesClient + + id, err := assetendpointprofiles.ParseAssetEndpointProfileID(metadata.ResourceData.Id()) + if err != nil { + return err + } + + resp, err := client.Get(ctx, *id) + if err != nil { + if response.WasNotFound(resp.HttpResponse) { + return metadata.MarkAsGone(id) + } + + return fmt.Errorf("retrieving %s: %+v", id, err) + } + + resourceGroupId := resourceParse.NewResourceGroupID(id.SubscriptionId, id.ResourceGroupName) + + // Convert the ARM model to the TF model + state := AssetEndpointProfileResourceModel{ + Name: id.AssetEndpointProfileName, + ResourceGroupId: resourceGroupId.ID(), + } + + if model := resp.Model; model != nil { + state.Location = location.Normalize(model.Location) + state.Tags = pointer.From(model.Tags) + state.ExtendedLocationId = model.ExtendedLocation.Name + + if props := model.Properties; props != nil { + state.TargetAddress = props.TargetAddress + state.EndpointProfileType = props.EndpointProfileType + state.DiscoveredAssetEndpointProfileReference = pointer.From(props.DiscoveredAssetEndpointProfileRef) + state.AdditionalConfiguration = pointer.From(props.AdditionalConfiguration) + + if auth := props.Authentication; auth != nil { + authenticationModel := AuthenticationModel{ + Method: string(auth.Method), + } + + if usernamePassword := auth.UsernamePasswordCredentials; usernamePassword != nil { + authenticationModel.UsernamePasswordCredentialsUsernameSecretName = usernamePassword.UsernameSecretName + authenticationModel.UsernamePasswordCredentialsPasswordSecretName = usernamePassword.PasswordSecretName + } + + if x509 := auth.X509Credentials; x509 != nil { + authenticationModel.X509CredentialsCertificateSecretName = x509.CertificateSecretName + } + + state.Authentication = []AuthenticationModel{ + authenticationModel, + } + } + } + } + return metadata.Encode(&state) + }, + } +} + +func (AssetEndpointProfileResource) Delete() sdk.ResourceFunc { + return sdk.ResourceFunc{ + Timeout: 30 * time.Minute, + Func: func(ctx context.Context, metadata sdk.ResourceMetaData) error { + client := metadata.Client.DeviceRegistry.AssetEndpointProfilesClient + + id, err := assetendpointprofiles.ParseAssetEndpointProfileID(metadata.ResourceData.Id()) + if err != nil { + return err + } + + if err := client.DeleteThenPoll(ctx, *id); err != nil { + return fmt.Errorf("deleting %s: %+v", *id, err) + } + + return nil + }, + } +} + +func (AssetEndpointProfileResource) IDValidationFunc() pluginsdk.SchemaValidateFunc { + return assetendpointprofiles.ValidateAssetEndpointProfileID +} + +func expandAuthentication(authenticationModels []AuthenticationModel) *assetendpointprofiles.Authentication { + if len(authenticationModels) == 0 { + return nil + } + + authenticationModel := authenticationModels[0] + authentication := &assetendpointprofiles.Authentication{ + Method: assetendpointprofiles.AuthenticationMethod(authenticationModel.Method), + } + + if authenticationModel.UsernamePasswordCredentialsUsernameSecretName != "" || authenticationModel.UsernamePasswordCredentialsPasswordSecretName != "" { + authentication.UsernamePasswordCredentials = &assetendpointprofiles.UsernamePasswordCredentials{ + UsernameSecretName: authenticationModel.UsernamePasswordCredentialsUsernameSecretName, + PasswordSecretName: authenticationModel.UsernamePasswordCredentialsPasswordSecretName, + } + } + + if authenticationModel.X509CredentialsCertificateSecretName != "" { + authentication.X509Credentials = &assetendpointprofiles.X509Credentials{ + CertificateSecretName: authenticationModel.X509CredentialsCertificateSecretName, + } + } + + return authentication +} diff --git a/internal/services/deviceregistry/device_registry_asset_endpoint_profile_resource_test.go b/internal/services/deviceregistry/device_registry_asset_endpoint_profile_resource_test.go new file mode 100644 index 000000000000..ba28af57a05f --- /dev/null +++ b/internal/services/deviceregistry/device_registry_asset_endpoint_profile_resource_test.go @@ -0,0 +1,711 @@ +package deviceregistry_test + +import ( + "context" + "fmt" + "os" + "strings" + "testing" + "time" + + "golang.org/x/crypto/ssh" + + "github.com/hashicorp/go-azure-helpers/lang/response" + "github.com/hashicorp/go-azure-helpers/resourcemanager/commonids" + "github.com/hashicorp/go-azure-sdk/resource-manager/deviceregistry/2024-11-01/assetendpointprofiles" + "github.com/hashicorp/go-azure-sdk/resource-manager/network/2024-05-01/publicipaddresses" + "github.com/hashicorp/terraform-provider-azurerm/internal/acceptance" + "github.com/hashicorp/terraform-provider-azurerm/internal/acceptance/check" + "github.com/hashicorp/terraform-provider-azurerm/internal/acceptance/testclient" + "github.com/hashicorp/terraform-provider-azurerm/internal/clients" + "github.com/hashicorp/terraform-provider-azurerm/internal/tf/pluginsdk" + "github.com/hashicorp/terraform-provider-azurerm/utils" +) + +const ( + ASSET_ENDPOINT_PROFILE_ARM_SUBSCRIPTION_ID = "ARM_SUBSCRIPTION_ID" + ASSET_ENDPOINT_PROFILE_ARM_CLIENT_ID = "ARM_CLIENT_ID" + ASSET_ENDPOINT_PROFILE_ARM_CLIENT_SECRET = "ARM_CLIENT_SECRET" + ASSET_ENDPOINT_PROFILE_ARM_ENTRA_APP_OBJECT_ID = "ARM_ENTRA_APP_OBJECT_ID" +) + +type AssetEndpointProfileTestResource struct{} + +func TestAccAssetEndpointProfile_basic(t *testing.T) { + data := acceptance.BuildTestData(t, "azurerm_device_registry_asset_endpoint_profile", "test") + r := AssetEndpointProfileTestResource{} + + r.checkEnvironmentVariables(t) + + data.ResourceTest(t, r, []acceptance.TestStep{ + { + // Apply the template to create the VM and its infra resources. + // The VM will setup the AIO cluster in the next step. + Config: r.template(data), + }, + { + // Run the setup bash script on the VM to create the AIO cluster. + // It must be a PreConfig step to ensure AIO cluster is finished setting up + // before the Asset Endpoint Profile resource is created on the cluster. + PreConfig: r.setupAIOClusterOnVM(t, data), + // Then create the Asset Endpoint Profile resource once the AIO cluster is done provisioning. + Config: r.basic(data), + Check: acceptance.ComposeTestCheckFunc( + check.That(data.ResourceName).ExistsInAzure(r), + check.That(data.ResourceName).Key("target_address").HasValue("opc.tcp://foo"), + check.That(data.ResourceName).Key("endpoint_profile_type").HasValue("OpcUa"), + check.That(data.ResourceName).Key("discovered_asset_endpoint_profile_reference").HasValue("discoveredAssetEndpointProfile123"), + check.That(data.ResourceName).Key("additional_configuration").HasValue(""), + check.That(data.ResourceName).Key("authentication.#").HasValue("0"), + ), + }, + data.ImportStep(), + }) +} + +func TestAccAssetEndpointProfile_complete_certificate(t *testing.T) { + data := acceptance.BuildTestData(t, "azurerm_device_registry_asset_endpoint_profile", "test") + r := AssetEndpointProfileTestResource{} + + r.checkEnvironmentVariables(t) + + data.ResourceTest(t, r, []acceptance.TestStep{ + { + // Apply the template to create the VM and its infra resources. + // The VM will setup the AIO cluster in the next step. + Config: r.template(data), + }, + { + // Run the setup bash script on the VM to create the AIO cluster. + // It must be a PreConfig step to ensure AIO cluster is finished setting up + // before the Asset Endpoint Profile resource is created on the cluster. + PreConfig: r.setupAIOClusterOnVM(t, data), + // Then create the Asset Endpoint Profile resource once the AIO cluster is done provisioning. + Config: r.completeCertificate(data), + Check: acceptance.ComposeTestCheckFunc( + check.That(data.ResourceName).ExistsInAzure(r), + check.That(data.ResourceName).Key("target_address").HasValue("opc.tcp://foo"), + check.That(data.ResourceName).Key("endpoint_profile_type").HasValue("OpcUa"), + check.That(data.ResourceName).Key("discovered_asset_endpoint_profile_reference").HasValue("discoveredAssetEndpointProfile123"), + check.That(data.ResourceName).Key("additional_configuration").HasValue("{\"foo\": \"bar\"}"), + check.That(data.ResourceName).Key("authentication.0.method").HasValue("Certificate"), + check.That(data.ResourceName).Key("authentication.0.x509_credential_certificate_secret_name").HasValue("myCertificateRef"), + check.That(data.ResourceName).Key("authentication.0.username_password_credential_username_secret_name").HasValue(""), + check.That(data.ResourceName).Key("authentication.0.username_password_credential_password_secret_name").HasValue(""), + check.That(data.ResourceName).Key("tags.sensor").HasValue("temperature,humidity"), + ), + }, + data.ImportStep(), + }) +} + +func TestAccAssetEndpointProfile_complete_usernamePassword(t *testing.T) { + data := acceptance.BuildTestData(t, "azurerm_device_registry_asset_endpoint_profile", "test") + r := AssetEndpointProfileTestResource{} + + r.checkEnvironmentVariables(t) + + data.ResourceTest(t, r, []acceptance.TestStep{ + { + // Apply the template to create the VM and its infra resources. + // The VM will setup the AIO cluster in the next step. + Config: r.template(data), + }, + { + // Run the setup bash script on the VM to create the AIO cluster. + // It must be a PreConfig step to ensure AIO cluster is finished setting up + // before the Asset Endpoint Profile resource is created on the cluster. + PreConfig: r.setupAIOClusterOnVM(t, data), + // Then create the Asset Endpoint Profile resource once the AIO cluster is done provisioning. + Config: r.completeUsernamePassword(data), + Check: acceptance.ComposeTestCheckFunc( + check.That(data.ResourceName).ExistsInAzure(r), + check.That(data.ResourceName).Key("target_address").HasValue("opc.tcp://foo"), + check.That(data.ResourceName).Key("endpoint_profile_type").HasValue("OpcUa"), + check.That(data.ResourceName).Key("discovered_asset_endpoint_profile_reference").HasValue("discoveredAssetEndpointProfile123"), + check.That(data.ResourceName).Key("additional_configuration").HasValue("{\"foo\": \"bar\"}"), + check.That(data.ResourceName).Key("authentication.0.method").HasValue("UsernamePassword"), + check.That(data.ResourceName).Key("authentication.0.x509_credential_certificate_secret_name").HasValue(""), + check.That(data.ResourceName).Key("authentication.0.username_password_credential_username_secret_name").HasValue("myUsernameRef"), + check.That(data.ResourceName).Key("authentication.0.username_password_credential_password_secret_name").HasValue("myPasswordRef"), + check.That(data.ResourceName).Key("tags.sensor").HasValue("temperature,humidity"), + ), + }, + data.ImportStep(), + }) +} + +func TestAccAssetEndpointProfile_complete_anonymous(t *testing.T) { + data := acceptance.BuildTestData(t, "azurerm_device_registry_asset_endpoint_profile", "test") + r := AssetEndpointProfileTestResource{} + + r.checkEnvironmentVariables(t) + + data.ResourceTest(t, r, []acceptance.TestStep{ + { + // Apply the template to create the VM and its infra resources. + // The VM will setup the AIO cluster in the next step. + Config: r.template(data), + }, + { + // Run the setup bash script on the VM to create the AIO cluster. + // It must be a PreConfig step to ensure AIO cluster is finished setting up + // before the Asset Endpoint Profile resource is created on the cluster. + PreConfig: r.setupAIOClusterOnVM(t, data), + // Then create the Asset Endpoint Profile resource once the AIO cluster is done provisioning. + Config: r.completeAnonymous(data), + Check: acceptance.ComposeTestCheckFunc( + check.That(data.ResourceName).ExistsInAzure(r), + check.That(data.ResourceName).Key("target_address").HasValue("opc.tcp://foo"), + check.That(data.ResourceName).Key("endpoint_profile_type").HasValue("OpcUa"), + check.That(data.ResourceName).Key("discovered_asset_endpoint_profile_reference").HasValue("discoveredAssetEndpointProfile123"), + check.That(data.ResourceName).Key("additional_configuration").HasValue("{\"foo\": \"bar\"}"), + check.That(data.ResourceName).Key("authentication.0.method").HasValue("Anonymous"), + check.That(data.ResourceName).Key("authentication.0.x509_credential_certificate_secret_name").HasValue(""), + check.That(data.ResourceName).Key("authentication.0.username_password_credential_username_secret_name").HasValue(""), + check.That(data.ResourceName).Key("authentication.0.username_password_credential_password_secret_name").HasValue(""), + check.That(data.ResourceName).Key("tags.sensor").HasValue("temperature,humidity"), + ), + }, + data.ImportStep(), + }) +} + +func TestAccAssetEndpointProfile_requiresImport(t *testing.T) { + data := acceptance.BuildTestData(t, "azurerm_device_registry_asset_endpoint_profile", "test") + r := AssetEndpointProfileTestResource{} + + r.checkEnvironmentVariables(t) + + data.ResourceTest(t, r, []acceptance.TestStep{ + { + // Apply the template to create the VM and its infra resources. + // The VM will setup the AIO cluster in the next step. + Config: r.template(data), + }, + { + // Run the setup bash script on the VM to create the AIO cluster. + // It must be a PreConfig step to ensure AIO cluster is finished setting up + // before the Asset Endpoint Profile resource is created on the cluster. + PreConfig: r.setupAIOClusterOnVM(t, data), + // Then create the Asset Endpoint Profile resource once the AIO cluster is done provisioning. + Config: r.basic(data), + Check: acceptance.ComposeTestCheckFunc( + check.That(data.ResourceName).ExistsInAzure(r), + ), + }, + data.RequiresImportErrorStep(r.requiresImport), + }) +} + +func TestAccAssetEndpointProfile_update(t *testing.T) { + data := acceptance.BuildTestData(t, "azurerm_device_registry_asset_endpoint_profile", "test") + r := AssetEndpointProfileTestResource{} + + r.checkEnvironmentVariables(t) + + data.ResourceTest(t, r, []acceptance.TestStep{ + { + // Apply the template to create the VM and its infra resources. + // The VM will setup the AIO cluster in the next step. + Config: r.template(data), + }, + { + // Run the setup bash script on the VM to create the AIO cluster. + // It must be a PreConfig step to ensure AIO cluster is finished setting up + // before the Asset Endpoint Profile resource is created on the cluster. + PreConfig: r.setupAIOClusterOnVM(t, data), + // Then create the Asset Endpoint Profile resource once the AIO cluster is done provisioning. + Config: r.basic(data), + Check: acceptance.ComposeTestCheckFunc( + check.That(data.ResourceName).ExistsInAzure(r), + ), + }, + data.ImportStep(), + { // update the authentication method to certificate + Config: r.completeCertificate(data), + Check: acceptance.ComposeTestCheckFunc( + check.That(data.ResourceName).ExistsInAzure(r), + check.That(data.ResourceName).Key("target_address").HasValue("opc.tcp://foo"), + check.That(data.ResourceName).Key("endpoint_profile_type").HasValue("OpcUa"), + check.That(data.ResourceName).Key("discovered_asset_endpoint_profile_reference").HasValue("discoveredAssetEndpointProfile123"), + check.That(data.ResourceName).Key("additional_configuration").HasValue("{\"foo\": \"bar\"}"), + check.That(data.ResourceName).Key("authentication.0.method").HasValue("Certificate"), + check.That(data.ResourceName).Key("authentication.0.x509_credential_certificate_secret_name").HasValue("myCertificateRef"), + check.That(data.ResourceName).Key("authentication.0.username_password_credential_username_secret_name").HasValue(""), + check.That(data.ResourceName).Key("authentication.0.username_password_credential_password_secret_name").HasValue(""), + check.That(data.ResourceName).Key("tags.sensor").HasValue("temperature,humidity"), + ), + }, + data.ImportStep(), + { // update the authentication method to username/password + Config: r.completeUsernamePassword(data), + Check: acceptance.ComposeTestCheckFunc( + check.That(data.ResourceName).ExistsInAzure(r), + check.That(data.ResourceName).Key("target_address").HasValue("opc.tcp://foo"), + check.That(data.ResourceName).Key("endpoint_profile_type").HasValue("OpcUa"), + check.That(data.ResourceName).Key("discovered_asset_endpoint_profile_reference").HasValue("discoveredAssetEndpointProfile123"), + check.That(data.ResourceName).Key("additional_configuration").HasValue("{\"foo\": \"bar\"}"), + check.That(data.ResourceName).Key("authentication.0.method").HasValue("UsernamePassword"), + check.That(data.ResourceName).Key("authentication.0.x509_credential_certificate_secret_name").HasValue(""), + check.That(data.ResourceName).Key("authentication.0.username_password_credential_username_secret_name").HasValue("myUsernameRef"), + check.That(data.ResourceName).Key("authentication.0.username_password_credential_password_secret_name").HasValue("myPasswordRef"), + check.That(data.ResourceName).Key("tags.sensor").HasValue("temperature,humidity"), + ), + }, + data.ImportStep(), + { // update the authentication method to anonymous + Config: r.completeAnonymous(data), + Check: acceptance.ComposeTestCheckFunc( + check.That(data.ResourceName).ExistsInAzure(r), + check.That(data.ResourceName).Key("target_address").HasValue("opc.tcp://foo"), + check.That(data.ResourceName).Key("endpoint_profile_type").HasValue("OpcUa"), + check.That(data.ResourceName).Key("discovered_asset_endpoint_profile_reference").HasValue("discoveredAssetEndpointProfile123"), + check.That(data.ResourceName).Key("additional_configuration").HasValue("{\"foo\": \"bar\"}"), + check.That(data.ResourceName).Key("authentication.0.method").HasValue("Anonymous"), + check.That(data.ResourceName).Key("authentication.0.x509_credential_certificate_secret_name").HasValue(""), + check.That(data.ResourceName).Key("authentication.0.username_password_credential_username_secret_name").HasValue(""), + check.That(data.ResourceName).Key("authentication.0.username_password_credential_password_secret_name").HasValue(""), + check.That(data.ResourceName).Key("tags.sensor").HasValue("temperature,humidity"), + ), + }, + data.ImportStep(), + }) +} + +func (AssetEndpointProfileTestResource) Exists(ctx context.Context, client *clients.Client, state *pluginsdk.InstanceState) (*bool, error) { + id, err := assetendpointprofiles.ParseAssetEndpointProfileID(state.ID) + if err != nil { + return nil, err + } + resp, err := client.DeviceRegistry.AssetEndpointProfilesClient.Get(ctx, *id) + if err != nil { + if response.WasNotFound(resp.HttpResponse) { + return utils.Bool(false), nil + } + return nil, fmt.Errorf("retrieving %s: %+v", *id, err) + } + return utils.Bool(true), nil +} + +func (r AssetEndpointProfileTestResource) basic(data acceptance.TestData) string { + template := r.template(data) + + return fmt.Sprintf(` +%s + +resource "azurerm_device_registry_asset_endpoint_profile" "test" { + name = "acctest-assetendpointprofile-%[2]d" + resource_group_id = azurerm_resource_group.test.id + extended_location_id = "/subscriptions/${data.azurerm_client_config.current.subscription_id}/resourceGroups/${azurerm_resource_group.test.name}/providers/Microsoft.ExtendedLocation/customLocations/${local.custom_location}" + target_address = "opc.tcp://foo" + endpoint_profile_type = "OpcUa" + discovered_asset_endpoint_profile_reference = "discoveredAssetEndpointProfile123" + location = "%[3]s" + depends_on = [ + azurerm_linux_virtual_machine.test + ] +} +`, template, data.RandomInteger, data.Locations.Primary) +} + +func (r AssetEndpointProfileTestResource) completeCertificate(data acceptance.TestData) string { + template := r.template(data) + + return fmt.Sprintf(` +%s + +resource "azurerm_device_registry_asset_endpoint_profile" "test" { + name = "acctest-assetendpointprofile-%[2]d" + resource_group_id = azurerm_resource_group.test.id + extended_location_id = "/subscriptions/${data.azurerm_client_config.current.subscription_id}/resourceGroups/${azurerm_resource_group.test.name}/providers/Microsoft.ExtendedLocation/customLocations/${local.custom_location}" + target_address = "opc.tcp://foo" + endpoint_profile_type = "OpcUa" + discovered_asset_endpoint_profile_reference = "discoveredAssetEndpointProfile123" + additional_configuration = "{\"foo\": \"bar\"}" + authentication { + method = "Certificate" + x509_credential_certificate_secret_name = "myCertificateRef" + } + tags = { + "sensor" = "temperature,humidity" + } + location = "%[3]s" + depends_on = [ + azurerm_linux_virtual_machine.test + ] +} +`, template, data.RandomInteger, data.Locations.Primary) +} + +func (r AssetEndpointProfileTestResource) completeUsernamePassword(data acceptance.TestData) string { + template := r.template(data) + + return fmt.Sprintf(` +%s + +resource "azurerm_device_registry_asset_endpoint_profile" "test" { + name = "acctest-assetendpointprofile-%[2]d" + resource_group_id = azurerm_resource_group.test.id + extended_location_id = "/subscriptions/${data.azurerm_client_config.current.subscription_id}/resourceGroups/${azurerm_resource_group.test.name}/providers/Microsoft.ExtendedLocation/customLocations/${local.custom_location}" + target_address = "opc.tcp://foo" + endpoint_profile_type = "OpcUa" + discovered_asset_endpoint_profile_reference = "discoveredAssetEndpointProfile123" + additional_configuration = "{\"foo\": \"bar\"}" + authentication { + method = "UsernamePassword" + username_password_credential_username_secret_name = "myUsernameRef" + username_password_credential_password_secret_name = "myPasswordRef" + } + tags = { + "sensor" = "temperature,humidity" + } + location = "%[3]s" + depends_on = [ + azurerm_linux_virtual_machine.test + ] +} +`, template, data.RandomInteger, data.Locations.Primary) +} + +func (r AssetEndpointProfileTestResource) completeAnonymous(data acceptance.TestData) string { + template := r.template(data) + + return fmt.Sprintf(` +%s + +resource "azurerm_device_registry_asset_endpoint_profile" "test" { + name = "acctest-assetendpointprofile-%[2]d" + resource_group_id = azurerm_resource_group.test.id + extended_location_id = "/subscriptions/${data.azurerm_client_config.current.subscription_id}/resourceGroups/${azurerm_resource_group.test.name}/providers/Microsoft.ExtendedLocation/customLocations/${local.custom_location}" + target_address = "opc.tcp://foo" + endpoint_profile_type = "OpcUa" + discovered_asset_endpoint_profile_reference = "discoveredAssetEndpointProfile123" + authentication { + method = "Anonymous" + } + additional_configuration = "{\"foo\": \"bar\"}" + tags = { + "sensor" = "temperature,humidity" + } + location = "%[3]s" + depends_on = [ + azurerm_linux_virtual_machine.test + ] +} +`, template, data.RandomInteger, data.Locations.Primary) +} + +func (r AssetEndpointProfileTestResource) requiresImport(data acceptance.TestData) string { + template := r.basic(data) + return fmt.Sprintf(` +%s + +resource "azurerm_device_registry_asset_endpoint_profile" "import" { + name = azurerm_device_registry_asset_endpoint_profile.test.name + resource_group_id = azurerm_device_registry_asset_endpoint_profile.test.resource_group_id + extended_location_id = azurerm_device_registry_asset_endpoint_profile.test.extended_location_id + target_address = azurerm_device_registry_asset_endpoint_profile.test.target_address + endpoint_profile_type = azurerm_device_registry_asset_endpoint_profile.test.endpoint_profile_type + discovered_asset_endpoint_profile_reference = "discoveredAssetEndpointProfile123" + location = azurerm_device_registry_asset_endpoint_profile.test.location + depends_on = [ + azurerm_linux_virtual_machine.test + ] +} +`, template) +} + +/* +The terraform template for all the resources needed to create an AIO cluster on a VM +which the acceptance tests' AssetEndpointProfile resources will be provisioned to. +*/ +func (r AssetEndpointProfileTestResource) template(data acceptance.TestData) string { + credential := r.getCredentials(data) + provisionTemplate := r.provisionTemplate(data, credential) + + return fmt.Sprintf(` +locals { + custom_location = "acctest-cl%[1]d" +} + +provider "azurerm" { + features { + resource_group { + // RG will contain AIO resources created from VM. So, we don't want to prevent RG deletion which will clean these up. + prevent_deletion_if_contains_resources = false + } + } +} + +data "azurerm_client_config" "current" {} + +resource "azurerm_resource_group" "test" { + name = "acctest-rg-%[1]d" + location = "%[2]s" +} + +resource "azurerm_virtual_network" "test" { + name = "acctestnw-%[1]d" + address_space = ["10.0.0.0/16"] + location = "%[2]s" + resource_group_name = azurerm_resource_group.test.name +} + +resource "azurerm_subnet" "test" { + name = "internal" + resource_group_name = azurerm_resource_group.test.name + virtual_network_name = azurerm_virtual_network.test.name + address_prefixes = ["10.0.2.0/24"] +} + +resource "azurerm_public_ip" "test" { + name = "acctestpip-%[1]d" + location = "%[2]s" + resource_group_name = azurerm_resource_group.test.name + allocation_method = "Static" +} + +resource "azurerm_network_interface" "test" { + name = "acctestnic-%[1]d" + location = "%[2]s" + resource_group_name = azurerm_resource_group.test.name + ip_configuration { + name = "internal" + subnet_id = azurerm_subnet.test.id + private_ip_address_allocation = "Dynamic" + public_ip_address_id = azurerm_public_ip.test.id + } +} + +resource "azurerm_network_security_group" "my_terraform_nsg" { + name = "myNetworkSG-%[1]d" + location = "%[2]s" + resource_group_name = azurerm_resource_group.test.name + security_rule { + name = "SSH" + priority = 1001 + direction = "Inbound" + access = "Allow" + protocol = "Tcp" + source_port_range = "*" + destination_port_range = "22" + source_address_prefix = "*" + destination_address_prefix = "*" + } + + lifecycle { + ignore_changes = [ + security_rule, + ] + } +} + +resource "azurerm_network_interface_security_group_association" "test" { + network_interface_id = azurerm_network_interface.test.id + network_security_group_id = azurerm_network_security_group.my_terraform_nsg.id +} + +resource "azurerm_linux_virtual_machine" "test" { + name = "acctestVM-%[1]d" + resource_group_name = azurerm_resource_group.test.name + location = "%[2]s" + size = "Standard_F8s_v2" + admin_username = "adminuser" + admin_password = "%[3]s" + provision_vm_agent = false + allow_extension_operations = false + disable_password_authentication = false + network_interface_ids = [ + azurerm_network_interface.test.id, + ] + os_disk { + caching = "ReadWrite" + storage_account_type = "Standard_LRS" + } + source_image_reference { + publisher = "Canonical" + offer = "0001-com-ubuntu-server-jammy" + sku = "22_04-lts" + version = "latest" + } + + identity { + type = "SystemAssigned" + } + + %[4]s + + depends_on = [ + azurerm_network_interface_security_group_association.test + ] +} +`, data.RandomInteger, data.Locations.Primary, credential, provisionTemplate) +} + +/* +Copies the script needed to create and provision the AIO cluster on the VM via SSH. +It does NOT run the script. That is done in the setupAIOClusterOnVM function. +*/ +func (r AssetEndpointProfileTestResource) provisionTemplate(data acceptance.TestData, credential string) string { + // Get client secrets from env vars because we need them + // to remote execute az cli commands on the VM. + clientId := os.Getenv(ASSET_ENDPOINT_PROFILE_ARM_CLIENT_ID) + clientSecret := os.Getenv(ASSET_ENDPOINT_PROFILE_ARM_CLIENT_SECRET) + objectId := os.Getenv(ASSET_ENDPOINT_PROFILE_ARM_ENTRA_APP_OBJECT_ID) + + // Trim the random value (from acceptance.RandTimeInt which is 18 digits) to 10 digits + // to avoid exceeding the maximum length of the storage account name (24 chars max). + trimmedRandomInteger := data.RandomInteger % 10000000000 + return fmt.Sprintf(` +connection { + type = "ssh" + host = azurerm_public_ip.test.ip_address + user = "adminuser" + password = "%[1]s" +} + +provisioner "file" { + content = templatefile("testdata/setup_aio_cluster.sh.tftpl", { + subscription_id = data.azurerm_client_config.current.subscription_id + resource_group_name = azurerm_resource_group.test.name + cluster_name = "acctest-akcc-%[2]d" + location = azurerm_resource_group.test.location + custom_location = local.custom_location + storage_account = "acctestsa%[3]d" + schema_registry = "acctest-sr-%[2]d" + schema_registry_namespace = "acctest-rn-%[2]d" + aio_cluster_resource_name = "acctest-aio%[2]d" + tenant_id = data.azurerm_client_config.current.tenant_id + client_id = "%[5]s" + client_secret = "%[6]s" + object_id = "%[7]s" + managed_identity_name = "acctest-mi%[2]d" + keyvault_name = "acctest-kv%[2]d" + }) + destination = "%[4]s/setup_aio_cluster.sh" +} +`, credential, data.RandomInteger, trimmedRandomInteger, "/home/adminuser", clientId, clientSecret, objectId) +} + +/* +This function should be called for the PreConfig step after the template() is made to ensure +that the Asset Endpoint Profile resource create is blocked and does not occur until the AIO +cluster is set up on the VM. This is needed because the Asset Endpoint Profile resource +is an arc-enabled resource and requires the VM, AIO cluster, and custom location to be set up +before it can be created, and all of those resources it's dependent on are created by the setup +script run by the VM's `remote-exec` provisioner. However, `remote-exec` is not waited for by +the subsequent Asset Endpoint Profile resources even if `depends_on` is used, and will attempt +to start creating the resource once the VM is created but the AIO cluster is not set up yet. This way, +the setup script runs synchronously so the tests are forced to wait for it to finish before +creating the Asset Endpoint Profile resource. + +This function will grab the VM's public IP address to SSH into the VM and run the setup script +(the file was already provisioned on the VM) to create the AIO cluster. +*/ +func (r AssetEndpointProfileTestResource) setupAIOClusterOnVM(t *testing.T, data acceptance.TestData) func() { + return func() { + // Set up the test client so we can fetch the public IP address. + clientManager, err := testclient.Build() + if err != nil { + t.Fatalf("failed to build client: %+v", err) + } + + ctx, cancel := context.WithDeadline(clientManager.StopContext, time.Now().Add(15*time.Minute)) + defer cancel() + + // Public IP Address metadata + publicIpClient := clientManager.Network.PublicIPAddresses + subscriptionId := os.Getenv(ASSET_ENDPOINT_PROFILE_ARM_SUBSCRIPTION_ID) + // Resource group name and public IP address name will be the same as template because we are using the same random integer + resourceGroupName := fmt.Sprintf("acctest-rg-%d", data.RandomInteger) + publicIpAddressName := fmt.Sprintf("acctestpip-%d", data.RandomInteger) + publicIpAddressId := commonids.NewPublicIPAddressID(subscriptionId, resourceGroupName, publicIpAddressName) + + // Get the public IP address + publicIpAddress, err := publicIpClient.Get(ctx, publicIpAddressId, publicipaddresses.DefaultGetOperationOptions()) + if err != nil { + t.Fatalf("failed to get public ip address: %+v", err) + } + + if publicIpAddress.Model == nil || publicIpAddress.Model.Properties.IPAddress == nil { + t.Fatalf("public ip address not found '%s'", publicIpAddressName) + } + + // SSH connection details + ipAddress := *publicIpAddress.Model.Properties.IPAddress + username := "adminuser" + password := r.getCredentials(data) + + // SSH client configuration + sshConfig := &ssh.ClientConfig{ + User: username, + Auth: []ssh.AuthMethod{ + ssh.Password(password), + }, + HostKeyCallback: ssh.InsecureIgnoreHostKey(), + } + + // Connect to the VM + conn, err := ssh.Dial("tcp", fmt.Sprintf("%s:22", ipAddress), sshConfig) + if err != nil { + t.Fatalf("failed to dial ssh: %s", err) + } + defer conn.Close() + + // Create a new session + stripSession, err := conn.NewSession() + if err != nil { + t.Fatalf("failed to create session for stripping carriage return: %s", err) + } + defer stripSession.Close() + // Strip carriage return from the setup script + if err := stripSession.Run("sudo sed -i 's/\r$//' /home/adminuser/setup_aio_cluster.sh"); err != nil { + t.Fatalf("failed to run command for stripping carriage return. Error: %s", err) + } + + // Create a new session + chmodSession, err := conn.NewSession() + if err != nil { + t.Fatalf("failed to create session for enabling execution for setup script: %s", err) + } + defer chmodSession.Close() + // Enable execution for the setup script + if err := chmodSession.Run("sudo chmod +x /home/adminuser/setup_aio_cluster.sh"); err != nil { + t.Fatalf("failed to run command for enabling execution. Error: %s", err) + } + + // Create a new session + runSession, err := conn.NewSession() + if err != nil { + t.Fatalf("failed to create session for running setup script: %s", err) + } + defer runSession.Close() + // Run the setup script + if err := runSession.Run("sudo bash /home/adminuser/setup_aio_cluster.sh &> /home/adminuser/agent_log"); err != nil { + t.Fatalf("failed to run command for running setup script. Error: %s", err) + } + } +} + +// Generates a random password for the VM. +func (AssetEndpointProfileTestResource) getCredentials(data acceptance.TestData) string { + return fmt.Sprintf("P@$$w0rd%d!", data.RandomInteger) +} + +// Checks if the required environment variables are set before running the tests. +// If any of the required variables are not set, the test will be skipped. +func (AssetEndpointProfileTestResource) checkEnvironmentVariables(t *testing.T) { + envVars := []string{ + ASSET_ENDPOINT_PROFILE_ARM_CLIENT_ID, + ASSET_ENDPOINT_PROFILE_ARM_CLIENT_SECRET, + ASSET_ENDPOINT_PROFILE_ARM_SUBSCRIPTION_ID, + ASSET_ENDPOINT_PROFILE_ARM_ENTRA_APP_OBJECT_ID, + } + for _, envVar := range envVars { + if os.Getenv(envVar) == "" { + envVarsString := strings.Join(envVars, ", ") + t.Skipf("Skipping test due to environment variable %s not set. Required variables: %s", envVar, envVarsString) + } + } +} diff --git a/internal/services/deviceregistry/device_registry_asset_resource.go b/internal/services/deviceregistry/device_registry_asset_resource.go new file mode 100644 index 000000000000..bad5b148a960 --- /dev/null +++ b/internal/services/deviceregistry/device_registry_asset_resource.go @@ -0,0 +1,780 @@ +package deviceregistry + +import ( + "context" + "fmt" + "time" + + "github.com/hashicorp/go-azure-helpers/lang/pointer" + "github.com/hashicorp/go-azure-helpers/lang/response" + "github.com/hashicorp/go-azure-helpers/resourcemanager/commonschema" + "github.com/hashicorp/go-azure-helpers/resourcemanager/location" + "github.com/hashicorp/go-azure-sdk/resource-manager/deviceregistry/2024-11-01/assets" + "github.com/hashicorp/go-azure-sdk/resource-manager/extendedlocation/2021-08-15/customlocations" + "github.com/hashicorp/terraform-provider-azurerm/internal/sdk" + resourceParse "github.com/hashicorp/terraform-provider-azurerm/internal/services/resource/parse" + resourceValidate "github.com/hashicorp/terraform-provider-azurerm/internal/services/resource/validate" + "github.com/hashicorp/terraform-provider-azurerm/internal/tf/pluginsdk" + "github.com/hashicorp/terraform-provider-azurerm/internal/tf/validation" +) + +const ( + AssetExtendedLocationTypeCustomLocation = "CustomLocation" +) + +var _ sdk.Resource = AssetResource{} + +type AssetResource struct{} + +type AssetResourceModel struct { + Name string `tfschema:"name"` + ResourceGroupId string `tfschema:"resource_group_id"` + Location string `tfschema:"location"` + Tags map[string]string `tfschema:"tags"` + ExtendedLocationId string `tfschema:"extended_location_id"` + Enabled bool `tfschema:"enabled"` + ExternalAssetId string `tfschema:"external_asset_id"` + DisplayName string `tfschema:"display_name"` + Description string `tfschema:"description"` + AssetEndpointProfileReference string `tfschema:"asset_endpoint_profile_reference"` + Manufacturer string `tfschema:"manufacturer"` + ManufacturerUri string `tfschema:"manufacturer_uri"` + Model string `tfschema:"model"` + ProductCode string `tfschema:"product_code"` + HardwareRevision string `tfschema:"hardware_revision"` + SoftwareRevision string `tfschema:"software_revision"` + DocumentationUri string `tfschema:"documentation_uri"` + SerialNumber string `tfschema:"serial_number"` + Attributes map[string]interface{} `tfschema:"attributes"` + DiscoveredAssetReferences []string `tfschema:"discovered_asset_references"` + DefaultDatasetsConfiguration string `tfschema:"default_datasets_configuration"` + DefaultEventsConfiguration string `tfschema:"default_events_configuration"` + DefaultTopic []TopicModel `tfschema:"default_topic"` + Datasets []Dataset `tfschema:"dataset"` + Events []Event `tfschema:"event"` +} + +type Dataset struct { + Name string `tfschema:"name"` + DatasetConfiguration string `tfschema:"dataset_configuration"` + Topic []TopicModel `tfschema:"topic"` + DataPoints []DataPoint `tfschema:"data_point"` +} + +type DataPoint struct { + Name string `tfschema:"name"` + DataSource string `tfschema:"data_source"` + ObservabilityMode string `tfschema:"observability_mode"` + DataPointConfiguration string `tfschema:"data_point_configuration"` +} + +type Event struct { + Name string `tfschema:"name"` + EventNotifier string `tfschema:"event_notifier"` + ObservabilityMode string `tfschema:"observability_mode"` + EventConfiguration string `tfschema:"event_configuration"` + Topic []TopicModel `tfschema:"topic"` +} + +type TopicModel struct { + Path string `tfschema:"path"` + Retain string `tfschema:"retain"` +} + +func (AssetResource) Arguments() map[string]*pluginsdk.Schema { + return map[string]*pluginsdk.Schema{ + "name": { + Type: pluginsdk.TypeString, + Required: true, + ValidateFunc: validation.StringIsNotEmpty, + }, + "resource_group_id": { + Type: pluginsdk.TypeString, + Required: true, + ValidateFunc: resourceValidate.ResourceGroupID, + }, + "location": commonschema.Location(), + "tags": commonschema.Tags(), + "extended_location_id": { + Type: pluginsdk.TypeString, + Required: true, + ValidateFunc: customlocations.ValidateCustomLocationID, + }, + "enabled": { + Type: pluginsdk.TypeBool, + Optional: true, + }, + "external_asset_id": { + Type: pluginsdk.TypeString, + Optional: true, + }, + "display_name": { + Type: pluginsdk.TypeString, + Optional: true, + }, + "description": { + Type: pluginsdk.TypeString, + Optional: true, + }, + "asset_endpoint_profile_reference": { + Type: pluginsdk.TypeString, + Required: true, + ValidateFunc: validation.StringIsNotEmpty, + }, + "manufacturer": { + Type: pluginsdk.TypeString, + Optional: true, + }, + "manufacturer_uri": { + Type: pluginsdk.TypeString, + Optional: true, + }, + "model": { + Type: pluginsdk.TypeString, + Optional: true, + }, + "product_code": { + Type: pluginsdk.TypeString, + Optional: true, + }, + "hardware_revision": { + Type: pluginsdk.TypeString, + Optional: true, + }, + "software_revision": { + Type: pluginsdk.TypeString, + Optional: true, + }, + "documentation_uri": { + Type: pluginsdk.TypeString, + Optional: true, + }, + "serial_number": { + Type: pluginsdk.TypeString, + Optional: true, + }, + "attributes": { + Type: pluginsdk.TypeMap, + Optional: true, + Elem: &pluginsdk.Schema{Type: pluginsdk.TypeString}, + }, + "discovered_asset_references": { + Type: pluginsdk.TypeList, + Optional: true, + Elem: &pluginsdk.Schema{ + Type: pluginsdk.TypeString, + }, + }, + "default_datasets_configuration": { + Type: pluginsdk.TypeString, + Optional: true, + }, + "default_events_configuration": { + Type: pluginsdk.TypeString, + Optional: true, + }, + "default_topic": { + Type: pluginsdk.TypeList, + Optional: true, + MaxItems: 1, + Elem: &pluginsdk.Resource{ + Schema: map[string]*pluginsdk.Schema{ + "path": { + Type: pluginsdk.TypeString, + Required: true, + }, + "retain": { + Type: pluginsdk.TypeString, + Optional: true, + ValidateFunc: validation.StringInSlice(assets.PossibleValuesForTopicRetainType(), false), + Default: string(assets.TopicRetainTypeNever), + }, + }, + }, + }, + "dataset": { + Type: pluginsdk.TypeList, + Optional: true, + Elem: &pluginsdk.Resource{ + Schema: map[string]*pluginsdk.Schema{ + "name": { + Type: pluginsdk.TypeString, + Required: true, + }, + "dataset_configuration": { + Type: pluginsdk.TypeString, + Optional: true, + }, + "topic": { + Type: pluginsdk.TypeList, + Optional: true, + MaxItems: 1, + Elem: &pluginsdk.Resource{ + Schema: map[string]*pluginsdk.Schema{ + "path": { + Type: pluginsdk.TypeString, + Required: true, + }, + "retain": { + Type: pluginsdk.TypeString, + Optional: true, + ValidateFunc: validation.StringInSlice(assets.PossibleValuesForTopicRetainType(), false), + Default: string(assets.TopicRetainTypeNever), + }, + }, + }, + }, + "data_point": { + Type: pluginsdk.TypeList, + Optional: true, + Elem: &pluginsdk.Resource{ + Schema: map[string]*pluginsdk.Schema{ + "name": { + Type: pluginsdk.TypeString, + Required: true, + }, + "data_source": { + Type: pluginsdk.TypeString, + Required: true, + }, + "observability_mode": { + Type: pluginsdk.TypeString, + Optional: true, + Default: string(assets.DataPointObservabilityModeNone), + ValidateFunc: validation.StringInSlice(assets.PossibleValuesForDataPointObservabilityMode(), false), + }, + "data_point_configuration": { + Type: pluginsdk.TypeString, + Optional: true, + }, + }, + }, + }, + }, + }, + }, + "event": { + Type: pluginsdk.TypeList, + Optional: true, + Elem: &pluginsdk.Resource{ + Schema: map[string]*pluginsdk.Schema{ + "name": { + Type: pluginsdk.TypeString, + Required: true, + }, + "event_notifier": { + Type: pluginsdk.TypeString, + Required: true, + }, + "observability_mode": { + Type: pluginsdk.TypeString, + Optional: true, + Default: string(assets.EventObservabilityModeNone), + ValidateFunc: validation.StringInSlice(assets.PossibleValuesForEventObservabilityMode(), false), + }, + "event_configuration": { + Type: pluginsdk.TypeString, + Optional: true, + }, + "topic": { + Type: pluginsdk.TypeList, + Optional: true, + MaxItems: 1, + Elem: &pluginsdk.Resource{ + Schema: map[string]*pluginsdk.Schema{ + "path": { + Type: pluginsdk.TypeString, + Required: true, + }, + "retain": { + Type: pluginsdk.TypeString, + Optional: true, + ValidateFunc: validation.StringInSlice(assets.PossibleValuesForTopicRetainType(), false), + Default: string(assets.TopicRetainTypeNever), + }, + }, + }, + }, + }, + }, + }, + } +} + +func (AssetResource) Attributes() map[string]*pluginsdk.Schema { + return map[string]*pluginsdk.Schema{} +} + +func (AssetResource) ModelObject() interface{} { + return &AssetResourceModel{} +} + +func (AssetResource) ResourceType() string { + return "azurerm_device_registry_asset" +} + +func (r AssetResource) Create() sdk.ResourceFunc { + return sdk.ResourceFunc{ + Timeout: 30 * time.Minute, + Func: func(ctx context.Context, metadata sdk.ResourceMetaData) error { + client := metadata.Client.DeviceRegistry.AssetsClient + + var config AssetResourceModel + if err := metadata.Decode(&config); err != nil { + return fmt.Errorf("decoding: %+v", err) + } + + resourceGroupId, err := resourceParse.ResourceGroupID(config.ResourceGroupId) + if err != nil { + return fmt.Errorf("parsing resource group id: %+v", err) + } + + id := assets.NewAssetID(resourceGroupId.SubscriptionId, resourceGroupId.ResourceGroup, config.Name) + + existing, err := client.Get(ctx, id) + if err != nil && !response.WasNotFound(existing.HttpResponse) { + return fmt.Errorf("checking for presence of existing %s: %+v", id, err) + } + if !response.WasNotFound(existing.HttpResponse) { + return metadata.ResourceRequiresImport(r.ResourceType(), id) + } + + // Convert the TF model to the ARM model + // Optional ARM resource properties are pointers. + param := assets.Asset{ + Location: location.Normalize(config.Location), + Tags: pointer.To(config.Tags), + ExtendedLocation: assets.ExtendedLocation{ + Name: config.ExtendedLocationId, + Type: AssetExtendedLocationTypeCustomLocation, + }, + Properties: &assets.AssetProperties{ + AssetEndpointProfileRef: config.AssetEndpointProfileReference, + }, + } + + // Enabled in config will be default set to false if not explicitly set in Terraform. + // We must check the raw value if it is null so we don't just send false if user didn't specify + if !pluginsdk.IsExplicitlyNullInConfig(metadata.ResourceData, "enabled") { + param.Properties.Enabled = pointer.To(config.Enabled) + } + + if config.ExternalAssetId != "" { + param.Properties.ExternalAssetId = pointer.To(config.ExternalAssetId) + } + + if config.DisplayName != "" { + param.Properties.DisplayName = pointer.To(config.DisplayName) + } + + if config.Description != "" { + param.Properties.Description = pointer.To(config.Description) + } + + if config.Manufacturer != "" { + param.Properties.Manufacturer = pointer.To(config.Manufacturer) + } + + if config.ManufacturerUri != "" { + param.Properties.ManufacturerUri = pointer.To(config.ManufacturerUri) + } + + if config.Model != "" { + param.Properties.Model = pointer.To(config.Model) + } + + if config.ProductCode != "" { + param.Properties.ProductCode = pointer.To(config.ProductCode) + } + + if config.HardwareRevision != "" { + param.Properties.HardwareRevision = pointer.To(config.HardwareRevision) + } + + if config.SoftwareRevision != "" { + param.Properties.SoftwareRevision = pointer.To(config.SoftwareRevision) + } + + if config.DocumentationUri != "" { + param.Properties.DocumentationUri = pointer.To(config.DocumentationUri) + } + + if config.SerialNumber != "" { + param.Properties.SerialNumber = pointer.To(config.SerialNumber) + } + + if config.Attributes != nil { + param.Properties.Attributes = pointer.To(config.Attributes) + } + + if config.DiscoveredAssetReferences != nil { + param.Properties.DiscoveredAssetRefs = pointer.To(config.DiscoveredAssetReferences) + } + + if config.DefaultDatasetsConfiguration != "" { + param.Properties.DefaultDatasetsConfiguration = pointer.To(config.DefaultDatasetsConfiguration) + } + + if config.DefaultEventsConfiguration != "" { + param.Properties.DefaultEventsConfiguration = pointer.To(config.DefaultEventsConfiguration) + } + + param.Properties.DefaultTopic = expandTopic(config.DefaultTopic) + + param.Properties.Datasets = expandDatasets(config.Datasets) + + param.Properties.Events = expandEvents(config.Events) + + if err := client.CreateOrReplaceThenPoll(ctx, id, param); err != nil { + return fmt.Errorf("creating %s: %+v", id, err) + } + + metadata.SetID(id) + return nil + }, + } +} + +func (r AssetResource) Update() sdk.ResourceFunc { + return sdk.ResourceFunc{ + Timeout: 30 * time.Minute, + Func: func(ctx context.Context, metadata sdk.ResourceMetaData) error { + client := metadata.Client.DeviceRegistry.AssetsClient + + id, err := assets.ParseAssetID(metadata.ResourceData.Id()) + if err != nil { + return err + } + + var config AssetResourceModel + if err := metadata.Decode(&config); err != nil { + return fmt.Errorf("decoding: %+v", err) + } + + // Change the properties that can be updated + param := assets.AssetUpdate{ + Properties: &assets.AssetUpdateProperties{}, + } + + if metadata.ResourceData.HasChange("tags") { + param.Tags = pointer.To(config.Tags) + } + + if metadata.ResourceData.HasChange("attributes") { + param.Properties.Attributes = pointer.To(config.Attributes) + } + + if metadata.ResourceData.HasChange("dataset") { + param.Properties.Datasets = expandDatasets(config.Datasets) + } + + if metadata.ResourceData.HasChange("default_datasets_configuration") { + param.Properties.DefaultDatasetsConfiguration = pointer.To(config.DefaultDatasetsConfiguration) + } + + if metadata.ResourceData.HasChange("default_events_configuration") { + param.Properties.DefaultEventsConfiguration = pointer.To(config.DefaultEventsConfiguration) + } + + if metadata.ResourceData.HasChange("default_topic") { + topic := &assets.TopicUpdate{} + param.Properties.DefaultTopic = topic + if len(config.DefaultTopic) > 0 { + topic.Path = pointer.To(config.DefaultTopic[0].Path) + // Bug with `go-azure-sdk` library: you can't set retain to null because empty string will cause + // ARM to throw validation error (retain must be one of the property's possible enum values), + // and go-azure-sdk library will ignore the retain field if it's set to nil, even if explicitly set. + if config.DefaultTopic[0].Retain != "" { + topic.Retain = pointer.To(assets.TopicRetainType(config.DefaultTopic[0].Retain)) + } + } + } + + if metadata.ResourceData.HasChange("description") { + param.Properties.Description = pointer.To(config.Description) + } + + if metadata.ResourceData.HasChange("display_name") { + param.Properties.DisplayName = pointer.To(config.DisplayName) + } + + if metadata.ResourceData.HasChange("documentation_uri") { + param.Properties.DocumentationUri = pointer.To(config.DocumentationUri) + } + + if metadata.ResourceData.HasChange("enabled") { + param.Properties.Enabled = pointer.To(config.Enabled) + } + + if metadata.ResourceData.HasChange("event") { + param.Properties.Events = expandEvents(config.Events) + } + + if metadata.ResourceData.HasChange("hardware_revision") { + param.Properties.HardwareRevision = pointer.To(config.HardwareRevision) + } + + if metadata.ResourceData.HasChange("manufacturer") { + param.Properties.Manufacturer = pointer.To(config.Manufacturer) + } + + if metadata.ResourceData.HasChange("manufacturer_uri") { + param.Properties.ManufacturerUri = pointer.To(config.ManufacturerUri) + } + + if metadata.ResourceData.HasChange("model") { + param.Properties.Model = pointer.To(config.Model) + } + + if metadata.ResourceData.HasChange("product_code") { + param.Properties.ProductCode = pointer.To(config.ProductCode) + } + + if metadata.ResourceData.HasChange("serial_number") { + param.Properties.SerialNumber = pointer.To(config.SerialNumber) + } + + if metadata.ResourceData.HasChange("software_revision") { + param.Properties.SoftwareRevision = pointer.To(config.SoftwareRevision) + } + + if err := client.UpdateThenPoll(ctx, *id, param); err != nil { + return fmt.Errorf("updating %s: %+v", id, err) + } + return nil + }, + } +} + +func (AssetResource) Read() sdk.ResourceFunc { + return sdk.ResourceFunc{ + Timeout: 5 * time.Minute, + Func: func(ctx context.Context, metadata sdk.ResourceMetaData) error { + client := metadata.Client.DeviceRegistry.AssetsClient + + id, err := assets.ParseAssetID(metadata.ResourceData.Id()) + if err != nil { + return err + } + + resp, err := client.Get(ctx, *id) + if err != nil { + if response.WasNotFound(resp.HttpResponse) { + return metadata.MarkAsGone(id) + } + + return fmt.Errorf("retrieving %s: %+v", id, err) + } + + resourceGroupId := resourceParse.NewResourceGroupID(id.SubscriptionId, id.ResourceGroupName) + + // Convert the ARM model to the TF model + state := AssetResourceModel{ + Name: id.AssetName, + ResourceGroupId: resourceGroupId.ID(), + } + + if model := resp.Model; model != nil { + state.Location = location.Normalize(model.Location) + state.Tags = pointer.From(model.Tags) + state.ExtendedLocationId = model.ExtendedLocation.Name + if props := model.Properties; props != nil { + state.AssetEndpointProfileReference = props.AssetEndpointProfileRef + state.Enabled = pointer.From(props.Enabled) + state.ExternalAssetId = pointer.From(props.ExternalAssetId) + state.DisplayName = pointer.From(props.DisplayName) + state.Description = pointer.From(props.Description) + state.Manufacturer = pointer.From(props.Manufacturer) + state.ManufacturerUri = pointer.From(props.ManufacturerUri) + state.Model = pointer.From(props.Model) + state.ProductCode = pointer.From(props.ProductCode) + state.HardwareRevision = pointer.From(props.HardwareRevision) + state.SoftwareRevision = pointer.From(props.SoftwareRevision) + state.DocumentationUri = pointer.From(props.DocumentationUri) + state.SerialNumber = pointer.From(props.SerialNumber) + state.Attributes = pointer.From(props.Attributes) + state.DiscoveredAssetReferences = pointer.From(props.DiscoveredAssetRefs) + state.DefaultDatasetsConfiguration = pointer.From(props.DefaultDatasetsConfiguration) + state.DefaultEventsConfiguration = pointer.From(props.DefaultEventsConfiguration) + + if defaultTopic := props.DefaultTopic; defaultTopic != nil { + state.DefaultTopic = flattenTopic(props.DefaultTopic) + } + + if datasets := props.Datasets; datasets != nil { + state.Datasets = flattenDatasets(datasets) + } + + if events := props.Events; events != nil { + state.Events = flattenEvents(events) + } + } + } + return metadata.Encode(&state) + }, + } +} + +func (AssetResource) Delete() sdk.ResourceFunc { + return sdk.ResourceFunc{ + Timeout: 30 * time.Minute, + Func: func(ctx context.Context, metadata sdk.ResourceMetaData) error { + client := metadata.Client.DeviceRegistry.AssetsClient + + id, err := assets.ParseAssetID(metadata.ResourceData.Id()) + if err != nil { + return err + } + + if err := client.DeleteThenPoll(ctx, *id); err != nil { + return fmt.Errorf("deleting %s: %+v", *id, err) + } + + return nil + }, + } +} + +func (AssetResource) IDValidationFunc() pluginsdk.SchemaValidateFunc { + return assets.ValidateAssetID +} + +func expandDatasets(datasets []Dataset) *[]assets.Dataset { + if datasets == nil { + return nil + } + + azureDatasets := make([]assets.Dataset, len(datasets)) + for i, dataset := range datasets { + azureDatasets[i] = assets.Dataset{ + Name: dataset.Name, + DatasetConfiguration: pointer.To(dataset.DatasetConfiguration), + Topic: expandTopic(dataset.Topic), + DataPoints: expandDataPoints(dataset.DataPoints), + } + } + + return &azureDatasets +} + +func expandDataPoints(dataPoints []DataPoint) *[]assets.DataPoint { + if dataPoints == nil { + return nil + } + + azureDataPoints := make([]assets.DataPoint, len(dataPoints)) + for i, dataPoint := range dataPoints { + azureDataPoints[i] = assets.DataPoint{ + Name: dataPoint.Name, + DataSource: dataPoint.DataSource, + ObservabilityMode: pointer.To(assets.DataPointObservabilityMode(dataPoint.ObservabilityMode)), + DataPointConfiguration: pointer.To(dataPoint.DataPointConfiguration), + } + } + + return &azureDataPoints +} + +func expandEvents(events []Event) *[]assets.Event { + if events == nil { + return nil + } + + azureEvents := make([]assets.Event, len(events)) + for i, event := range events { + azureEvents[i] = assets.Event{ + Name: event.Name, + EventNotifier: event.EventNotifier, + EventConfiguration: pointer.To(event.EventConfiguration), + ObservabilityMode: pointer.To(assets.EventObservabilityMode(event.ObservabilityMode)), + Topic: expandTopic(event.Topic), + } + } + + return &azureEvents +} + +func expandTopic(topic []TopicModel) *assets.Topic { + if len(topic) == 0 { + return nil + } + + azureTopic := assets.Topic{ + Path: topic[0].Path, + } + + // Topic retain is optional, but if it's set, it must be one of the possible values + if topic[0].Retain != "" { + azureTopic.Retain = pointer.To(assets.TopicRetainType(topic[0].Retain)) + } + + return &azureTopic +} + +func flattenDatasets(datasets *[]assets.Dataset) []Dataset { + if datasets == nil { + return nil + } + + tfDatasets := make([]Dataset, len(*datasets)) + for i, dataset := range *datasets { + tfDatasets[i] = Dataset{ + Name: dataset.Name, + DatasetConfiguration: pointer.From(dataset.DatasetConfiguration), + DataPoints: flattenDataPoints(dataset.DataPoints), + Topic: flattenTopic(dataset.Topic), + } + } + + return tfDatasets +} + +func flattenDataPoints(dataPoints *[]assets.DataPoint) []DataPoint { + if dataPoints == nil { + return nil + } + + tfDataPoints := make([]DataPoint, len(*dataPoints)) + for i, dataPoint := range *dataPoints { + tfDataPoints[i] = DataPoint{ + Name: dataPoint.Name, + DataSource: dataPoint.DataSource, + ObservabilityMode: string(pointer.From(dataPoint.ObservabilityMode)), + DataPointConfiguration: pointer.From(dataPoint.DataPointConfiguration), + } + } + + return tfDataPoints +} + +func flattenEvents(events *[]assets.Event) []Event { + if events == nil { + return nil + } + + tfEvents := make([]Event, len(*events)) + for i, event := range *events { + tfEvents[i] = Event{ + Name: event.Name, + EventNotifier: event.EventNotifier, + ObservabilityMode: string(pointer.From(event.ObservabilityMode)), + EventConfiguration: pointer.From(event.EventConfiguration), + Topic: flattenTopic(event.Topic), + } + } + + return tfEvents +} + +func flattenTopic(topic *assets.Topic) []TopicModel { + if topic == nil { + return nil + } + + return []TopicModel{ + { + Path: topic.Path, + Retain: string(pointer.From(topic.Retain)), + }, + } +} diff --git a/internal/services/deviceregistry/device_registry_asset_resource_test.go b/internal/services/deviceregistry/device_registry_asset_resource_test.go new file mode 100644 index 000000000000..8f097e397759 --- /dev/null +++ b/internal/services/deviceregistry/device_registry_asset_resource_test.go @@ -0,0 +1,747 @@ +package deviceregistry_test + +import ( + "context" + "fmt" + "os" + "strings" + "testing" + "time" + + "golang.org/x/crypto/ssh" + + "github.com/hashicorp/go-azure-helpers/lang/response" + "github.com/hashicorp/go-azure-helpers/resourcemanager/commonids" + "github.com/hashicorp/go-azure-sdk/resource-manager/deviceregistry/2024-11-01/assets" + "github.com/hashicorp/go-azure-sdk/resource-manager/network/2024-05-01/publicipaddresses" + "github.com/hashicorp/terraform-provider-azurerm/internal/acceptance" + "github.com/hashicorp/terraform-provider-azurerm/internal/acceptance/check" + "github.com/hashicorp/terraform-provider-azurerm/internal/acceptance/testclient" + "github.com/hashicorp/terraform-provider-azurerm/internal/clients" + "github.com/hashicorp/terraform-provider-azurerm/internal/tf/pluginsdk" + "github.com/hashicorp/terraform-provider-azurerm/utils" +) + +const ( + ASSET_ARM_CLIENT_ID = "ARM_CLIENT_ID" + ASSET_ARM_CLIENT_SECRET = "ARM_CLIENT_SECRET" + ASSET_ARM_SUBSCRIPTION_ID = "ARM_SUBSCRIPTION_ID" + ASSET_ARM_ENTRA_APP_OBJECT_ID = "ARM_ENTRA_APP_OBJECT_ID" +) + +type AssetTestResource struct{} + +func TestAccAsset_basic(t *testing.T) { + data := acceptance.BuildTestData(t, "azurerm_device_registry_asset", "test") + r := AssetTestResource{} + + r.checkEnvironmentVariables(t) + + data.ResourceTest(t, r, []acceptance.TestStep{ + { + // Apply the template to create the VM and its infra resources. + // The VM will setup the AIO cluster in the next step. + Config: r.template(data), + }, + { + // Run the setup bash script on the VM to create the AIO cluster. + // It must be a PreConfig step to ensure AIO cluster is finished setting up + // before the Asset resource is created on the cluster. + PreConfig: r.setupAIOClusterOnVM(t, data), + // Then create the Asset resource once the AIO cluster is done provisioning. + Config: r.basic(data), + Check: acceptance.ComposeTestCheckFunc( + check.That(data.ResourceName).ExistsInAzure(r), + check.That(data.ResourceName).Key("asset_endpoint_profile_reference").HasValue("myAssetEndpointProfile"), + check.That(data.ResourceName).Key("discovered_asset_references.#").HasValue("3"), + check.That(data.ResourceName).Key("discovered_asset_references.0").HasValue("foo"), + check.That(data.ResourceName).Key("discovered_asset_references.1").HasValue("bar"), + check.That(data.ResourceName).Key("discovered_asset_references.2").HasValue("baz"), + check.That(data.ResourceName).Key("display_name").HasValue("my asset"), + check.That(data.ResourceName).Key("enabled").HasValue("false"), + check.That(data.ResourceName).Key("external_asset_id").HasValue("8ZBA6LRHU0A458969"), + ), + }, + data.ImportStep(), + }) +} + +func TestAccAsset_complete(t *testing.T) { + data := acceptance.BuildTestData(t, "azurerm_device_registry_asset", "test") + r := AssetTestResource{} + + r.checkEnvironmentVariables(t) + + data.ResourceTest(t, r, []acceptance.TestStep{ + { + // Apply the template to create the VM and its infra resources. + // The VM will setup the AIO cluster in the next step. + Config: r.template(data), + }, + { + // Run the setup bash script on the VM to create the AIO cluster. + // It must be a PreConfig step to ensure AIO cluster is finished setting up + // before the Asset resource is created on the cluster. + PreConfig: r.setupAIOClusterOnVM(t, data), + // Then create the Asset resource once the AIO cluster is done provisioning. + Config: r.complete(data), + Check: acceptance.ComposeTestCheckFunc( + check.That(data.ResourceName).ExistsInAzure(r), + check.That(data.ResourceName).Key("asset_endpoint_profile_reference").HasValue("myAssetEndpointProfile"), + check.That(data.ResourceName).Key("display_name").HasValue("my asset"), + check.That(data.ResourceName).Key("enabled").HasValue("true"), + check.That(data.ResourceName).Key("external_asset_id").HasValue("8ZBA6LRHU0A458969"), + check.That(data.ResourceName).Key("attributes.foo").HasValue("bar"), + check.That(data.ResourceName).Key("attributes.x").HasValue("y"), + check.That(data.ResourceName).Key("default_datasets_configuration").HasValue("{\"defaultPublishingInterval\":200,\"defaultQueueSize\":10,\"defaultSamplingInterval\":500}"), + check.That(data.ResourceName).Key("default_events_configuration").HasValue("{\"defaultPublishingInterval\":200,\"defaultQueueSize\":10,\"defaultSamplingInterval\":500}"), + check.That(data.ResourceName).Key("default_topic.0.path").HasValue("/path/defaultTopic"), + check.That(data.ResourceName).Key("default_topic.0.retain").HasValue("Keep"), + check.That(data.ResourceName).Key("description").HasValue("this is my asset"), + check.That(data.ResourceName).Key("discovered_asset_references.#").HasValue("3"), + check.That(data.ResourceName).Key("discovered_asset_references.0").HasValue("foo"), + check.That(data.ResourceName).Key("discovered_asset_references.1").HasValue("bar"), + check.That(data.ResourceName).Key("discovered_asset_references.2").HasValue("baz"), + check.That(data.ResourceName).Key("documentation_uri").HasValue("https://example.com/about"), + check.That(data.ResourceName).Key("hardware_revision").HasValue("1.0"), + check.That(data.ResourceName).Key("manufacturer").HasValue("Contoso"), + check.That(data.ResourceName).Key("manufacturer_uri").HasValue("https://www.contoso.com/manufacturerUri"), + check.That(data.ResourceName).Key("model").HasValue("ContosoModel"), + check.That(data.ResourceName).Key("product_code").HasValue("SA34VDG"), + check.That(data.ResourceName).Key("serial_number").HasValue("64-103816-519918-8"), + check.That(data.ResourceName).Key("software_revision").HasValue("2.0"), + check.That(data.ResourceName).Key("tags.site").HasValue("building-1"), + check.That(data.ResourceName).Key("dataset.#").HasValue("1"), + check.That(data.ResourceName).Key("dataset.0.dataset_configuration").HasValue("{\"publishingInterval\":7,\"queueSize\":8,\"samplingInterval\":1000}"), + check.That(data.ResourceName).Key("dataset.0.name").HasValue("dataset1"), + check.That(data.ResourceName).Key("dataset.0.topic.0.path").HasValue("/path/dataset1"), + check.That(data.ResourceName).Key("dataset.0.topic.0.retain").HasValue("Keep"), + check.That(data.ResourceName).Key("dataset.0.data_point.#").HasValue("2"), + check.That(data.ResourceName).Key("dataset.0.data_point.0.data_point_configuration").HasValue("{\"publishingInterval\":7,\"queueSize\":8,\"samplingInterval\":1000}"), + check.That(data.ResourceName).Key("dataset.0.data_point.0.data_source").HasValue("nsu=http://microsoft.com/Opc/OpcPlc/;s=FastUInt1"), + check.That(data.ResourceName).Key("dataset.0.data_point.0.name").HasValue("datapoint1"), + check.That(data.ResourceName).Key("dataset.0.data_point.0.observability_mode").HasValue("Counter"), + check.That(data.ResourceName).Key("dataset.0.data_point.1.data_point_configuration").HasValue("{\"publishingInterval\":7,\"queueSize\":8,\"samplingInterval\":1000}"), + check.That(data.ResourceName).Key("dataset.0.data_point.1.data_source").HasValue("nsu=http://microsoft.com/Opc/OpcPlc/;s=FastUInt2"), + check.That(data.ResourceName).Key("dataset.0.data_point.1.name").HasValue("datapoint2"), + check.That(data.ResourceName).Key("dataset.0.data_point.1.observability_mode").HasValue("None"), + check.That(data.ResourceName).Key("event.#").HasValue("2"), + check.That(data.ResourceName).Key("event.0.event_configuration").HasValue("{\"publishingInterval\":7,\"queueSize\":8,\"samplingInterval\":1000}"), + check.That(data.ResourceName).Key("event.0.event_notifier").HasValue("nsu=http://microsoft.com/Opc/OpcPlc/;s=FastUInt3"), + check.That(data.ResourceName).Key("event.0.name").HasValue("event1"), + check.That(data.ResourceName).Key("event.0.observability_mode").HasValue("Log"), + check.That(data.ResourceName).Key("event.0.topic.0.path").HasValue("/path/event1"), + check.That(data.ResourceName).Key("event.0.topic.0.retain").HasValue("Never"), + check.That(data.ResourceName).Key("event.1.event_configuration").HasValue("{\"publishingInterval\":7,\"queueSize\":8,\"samplingInterval\":1000}"), + check.That(data.ResourceName).Key("event.1.event_notifier").HasValue("nsu=http://microsoft.com/Opc/OpcPlc/;s=FastUInt4"), + check.That(data.ResourceName).Key("event.1.name").HasValue("event2"), + check.That(data.ResourceName).Key("event.1.observability_mode").HasValue("None"), + check.That(data.ResourceName).Key("event.1.topic.0.path").HasValue("/path/event2"), + check.That(data.ResourceName).Key("event.1.topic.0.retain").HasValue("Keep"), + ), + }, + data.ImportStep(), + }) +} + +func TestAccAsset_requiresImport(t *testing.T) { + data := acceptance.BuildTestData(t, "azurerm_device_registry_asset", "test") + r := AssetTestResource{} + + r.checkEnvironmentVariables(t) + + data.ResourceTest(t, r, []acceptance.TestStep{ + { + // Apply the template to create the VM and its infra resources. + // The VM will setup the AIO cluster in the next step. + Config: r.template(data), + }, + { + // Run the setup bash script on the VM to create the AIO cluster. + // It must be a PreConfig step to ensure AIO cluster is finished setting up + // before the Asset resource is created on the cluster. + PreConfig: r.setupAIOClusterOnVM(t, data), + // Then create the Asset resource once the AIO cluster is done provisioning. + Config: r.basic(data), + Check: acceptance.ComposeTestCheckFunc( + check.That(data.ResourceName).ExistsInAzure(r), + ), + }, + data.RequiresImportErrorStep(r.requiresImport), + }) +} + +func TestAccAsset_update(t *testing.T) { + data := acceptance.BuildTestData(t, "azurerm_device_registry_asset", "test") + r := AssetTestResource{} + + r.checkEnvironmentVariables(t) + + data.ResourceTest(t, r, []acceptance.TestStep{ + { + // Apply the template to create the VM and its infra resources. + // The VM will setup the AIO cluster in the next step. + Config: r.template(data), + }, + { + // Run the setup bash script on the VM to create the AIO cluster. + // It must be a PreConfig step to ensure AIO cluster is finished setting up + // before the Asset resource is created on the cluster. + PreConfig: r.setupAIOClusterOnVM(t, data), + // Then create the Asset resource once the AIO cluster is done provisioning. + Config: r.basic(data), + Check: acceptance.ComposeTestCheckFunc( + check.That(data.ResourceName).ExistsInAzure(r), + ), + }, + data.ImportStep(), + { + Config: r.complete(data), + Check: acceptance.ComposeTestCheckFunc( + check.That(data.ResourceName).ExistsInAzure(r), + check.That(data.ResourceName).Key("asset_endpoint_profile_reference").HasValue("myAssetEndpointProfile"), + check.That(data.ResourceName).Key("display_name").HasValue("my asset"), + check.That(data.ResourceName).Key("enabled").HasValue("true"), + check.That(data.ResourceName).Key("external_asset_id").HasValue("8ZBA6LRHU0A458969"), + check.That(data.ResourceName).Key("attributes.foo").HasValue("bar"), + check.That(data.ResourceName).Key("attributes.x").HasValue("y"), + check.That(data.ResourceName).Key("default_datasets_configuration").HasValue("{\"defaultPublishingInterval\":200,\"defaultQueueSize\":10,\"defaultSamplingInterval\":500}"), + check.That(data.ResourceName).Key("default_events_configuration").HasValue("{\"defaultPublishingInterval\":200,\"defaultQueueSize\":10,\"defaultSamplingInterval\":500}"), + check.That(data.ResourceName).Key("default_topic.0.path").HasValue("/path/defaultTopic"), + check.That(data.ResourceName).Key("default_topic.0.retain").HasValue("Keep"), + check.That(data.ResourceName).Key("description").HasValue("this is my asset"), + check.That(data.ResourceName).Key("discovered_asset_references.#").HasValue("3"), + check.That(data.ResourceName).Key("discovered_asset_references.0").HasValue("foo"), + check.That(data.ResourceName).Key("discovered_asset_references.1").HasValue("bar"), + check.That(data.ResourceName).Key("discovered_asset_references.2").HasValue("baz"), + check.That(data.ResourceName).Key("documentation_uri").HasValue("https://example.com/about"), + check.That(data.ResourceName).Key("hardware_revision").HasValue("1.0"), + check.That(data.ResourceName).Key("manufacturer").HasValue("Contoso"), + check.That(data.ResourceName).Key("manufacturer_uri").HasValue("https://www.contoso.com/manufacturerUri"), + check.That(data.ResourceName).Key("model").HasValue("ContosoModel"), + check.That(data.ResourceName).Key("product_code").HasValue("SA34VDG"), + check.That(data.ResourceName).Key("serial_number").HasValue("64-103816-519918-8"), + check.That(data.ResourceName).Key("software_revision").HasValue("2.0"), + check.That(data.ResourceName).Key("tags.site").HasValue("building-1"), + check.That(data.ResourceName).Key("dataset.#").HasValue("1"), + check.That(data.ResourceName).Key("dataset.0.dataset_configuration").HasValue("{\"publishingInterval\":7,\"queueSize\":8,\"samplingInterval\":1000}"), + check.That(data.ResourceName).Key("dataset.0.name").HasValue("dataset1"), + check.That(data.ResourceName).Key("dataset.0.topic.0.path").HasValue("/path/dataset1"), + check.That(data.ResourceName).Key("dataset.0.topic.0.retain").HasValue("Keep"), + check.That(data.ResourceName).Key("dataset.0.data_point.#").HasValue("2"), + check.That(data.ResourceName).Key("dataset.0.data_point.0.data_point_configuration").HasValue("{\"publishingInterval\":7,\"queueSize\":8,\"samplingInterval\":1000}"), + check.That(data.ResourceName).Key("dataset.0.data_point.0.data_source").HasValue("nsu=http://microsoft.com/Opc/OpcPlc/;s=FastUInt1"), + check.That(data.ResourceName).Key("dataset.0.data_point.0.name").HasValue("datapoint1"), + check.That(data.ResourceName).Key("dataset.0.data_point.0.observability_mode").HasValue("Counter"), + check.That(data.ResourceName).Key("dataset.0.data_point.1.data_point_configuration").HasValue("{\"publishingInterval\":7,\"queueSize\":8,\"samplingInterval\":1000}"), + check.That(data.ResourceName).Key("dataset.0.data_point.1.data_source").HasValue("nsu=http://microsoft.com/Opc/OpcPlc/;s=FastUInt2"), + check.That(data.ResourceName).Key("dataset.0.data_point.1.name").HasValue("datapoint2"), + check.That(data.ResourceName).Key("dataset.0.data_point.1.observability_mode").HasValue("None"), + check.That(data.ResourceName).Key("event.#").HasValue("2"), + check.That(data.ResourceName).Key("event.0.event_configuration").HasValue("{\"publishingInterval\":7,\"queueSize\":8,\"samplingInterval\":1000}"), + check.That(data.ResourceName).Key("event.0.event_notifier").HasValue("nsu=http://microsoft.com/Opc/OpcPlc/;s=FastUInt3"), + check.That(data.ResourceName).Key("event.0.name").HasValue("event1"), + check.That(data.ResourceName).Key("event.0.observability_mode").HasValue("Log"), + check.That(data.ResourceName).Key("event.0.topic.0.path").HasValue("/path/event1"), + check.That(data.ResourceName).Key("event.0.topic.0.retain").HasValue("Never"), + check.That(data.ResourceName).Key("event.1.event_configuration").HasValue("{\"publishingInterval\":7,\"queueSize\":8,\"samplingInterval\":1000}"), + check.That(data.ResourceName).Key("event.1.event_notifier").HasValue("nsu=http://microsoft.com/Opc/OpcPlc/;s=FastUInt4"), + check.That(data.ResourceName).Key("event.1.name").HasValue("event2"), + check.That(data.ResourceName).Key("event.1.observability_mode").HasValue("None"), + check.That(data.ResourceName).Key("event.1.topic.0.path").HasValue("/path/event2"), + check.That(data.ResourceName).Key("event.1.topic.0.retain").HasValue("Keep"), + ), + }, + data.ImportStep(), + }) +} + +func (AssetTestResource) Exists(ctx context.Context, client *clients.Client, state *pluginsdk.InstanceState) (*bool, error) { + id, err := assets.ParseAssetID(state.ID) + if err != nil { + return nil, err + } + resp, err := client.DeviceRegistry.AssetsClient.Get(ctx, *id) + if err != nil { + if response.WasNotFound(resp.HttpResponse) { + return utils.Bool(false), nil + } + return nil, fmt.Errorf("retrieving %s: %+v", *id, err) + } + return utils.Bool(true), nil +} + +func (r AssetTestResource) basic(data acceptance.TestData) string { + template := r.template(data) + + return fmt.Sprintf(` +%s + +resource "azurerm_device_registry_asset" "test" { + name = "acctest-asset-%[2]d" + resource_group_id = azurerm_resource_group.test.id + extended_location_id = "/subscriptions/${data.azurerm_client_config.current.subscription_id}/resourceGroups/${azurerm_resource_group.test.name}/providers/Microsoft.ExtendedLocation/customLocations/${local.custom_location}" + asset_endpoint_profile_reference = "myAssetEndpointProfile" + discovered_asset_references = [ + "foo", + "bar", + "baz", + ] + display_name = "my asset" + enabled = false + external_asset_id = "8ZBA6LRHU0A458969" + location = "%[3]s" + depends_on = [ + azurerm_linux_virtual_machine.test + ] +} +`, template, data.RandomInteger, data.Locations.Primary) +} + +func (r AssetTestResource) complete(data acceptance.TestData) string { + template := r.template(data) + return fmt.Sprintf(` +%s + +resource "azurerm_device_registry_asset" "test" { + name = "acctest-asset-%[2]d" + resource_group_id = azurerm_resource_group.test.id + extended_location_id = "/subscriptions/${data.azurerm_client_config.current.subscription_id}/resourceGroups/${azurerm_resource_group.test.name}/providers/Microsoft.ExtendedLocation/customLocations/${local.custom_location}" + location = "%[3]s" + asset_endpoint_profile_reference = "myAssetEndpointProfile" + display_name = "my asset" + enabled = true + external_asset_id = "8ZBA6LRHU0A458969" + attributes = { + "foo" = "bar" + "x" = "y" + } + default_datasets_configuration = jsonencode( + { + defaultPublishingInterval = 200 + defaultQueueSize = 10 + defaultSamplingInterval = 500 + } + ) + default_events_configuration = jsonencode( + { + defaultPublishingInterval = 200 + defaultQueueSize = 10 + defaultSamplingInterval = 500 + } + ) + default_topic { + path = "/path/defaultTopic" + retain = "Keep" + } + description = "this is my asset" + discovered_asset_references = [ + "foo", + "bar", + "baz", + ] + documentation_uri = "https://example.com/about" + hardware_revision = "1.0" + manufacturer = "Contoso" + manufacturer_uri = "https://www.contoso.com/manufacturerUri" + model = "ContosoModel" + product_code = "SA34VDG" + serial_number = "64-103816-519918-8" + software_revision = "2.0" + tags = { + "site" = "building-1" + } + + dataset { + dataset_configuration = jsonencode( + { + publishingInterval = 7 + queueSize = 8 + samplingInterval = 1000 + } + ) + name = "dataset1" + topic { + path = "/path/dataset1" + retain = "Keep" + } + + data_point { + data_point_configuration = jsonencode( + { + publishingInterval = 7 + queueSize = 8 + samplingInterval = 1000 + } + ) + data_source = "nsu=http://microsoft.com/Opc/OpcPlc/;s=FastUInt1" + name = "datapoint1" + observability_mode = "Counter" + } + data_point { + data_point_configuration = jsonencode( + { + publishingInterval = 7 + queueSize = 8 + samplingInterval = 1000 + } + ) + data_source = "nsu=http://microsoft.com/Opc/OpcPlc/;s=FastUInt2" + name = "datapoint2" + observability_mode = "None" + } + } + + event { + event_configuration = jsonencode( + { + publishingInterval = 7 + queueSize = 8 + samplingInterval = 1000 + } + ) + event_notifier = "nsu=http://microsoft.com/Opc/OpcPlc/;s=FastUInt3" + name = "event1" + observability_mode = "Log" + topic { + path = "/path/event1" + retain = "Never" + } + } + event { + event_configuration = jsonencode( + { + publishingInterval = 7 + queueSize = 8 + samplingInterval = 1000 + } + ) + event_notifier = "nsu=http://microsoft.com/Opc/OpcPlc/;s=FastUInt4" + name = "event2" + observability_mode = "None" + topic { + path = "/path/event2" + retain = "Keep" + } + } + depends_on = [ + azurerm_linux_virtual_machine.test + ] +} +`, template, data.RandomInteger, data.Locations.Primary) +} + +func (r AssetTestResource) requiresImport(data acceptance.TestData) string { + template := r.basic(data) + return fmt.Sprintf(` +%s + +resource "azurerm_device_registry_asset" "import" { + name = azurerm_device_registry_asset.test.name + resource_group_id = azurerm_device_registry_asset.test.resource_group_id + extended_location_id = azurerm_device_registry_asset.test.extended_location_id + asset_endpoint_profile_reference = azurerm_device_registry_asset.test.asset_endpoint_profile_reference + display_name = azurerm_device_registry_asset.test.display_name + enabled = azurerm_device_registry_asset.test.enabled + external_asset_id = azurerm_device_registry_asset.test.external_asset_id + location = azurerm_device_registry_asset.test.location + depends_on = [ + azurerm_linux_virtual_machine.test + ] +} +`, template) +} + +/* +The terraform template for all the resources needed to create an AIO cluster on a VM +which the acceptance tests' AssetEndpointProfile resources will be provisioned to. +*/ +func (r AssetTestResource) template(data acceptance.TestData) string { + credential := r.getCredentials(data) + provisionTemplate := r.provisionTemplate(data, credential) + + return fmt.Sprintf(` +locals { + custom_location = "acctest-cl%[1]d" +} + +provider "azurerm" { + features { + resource_group { + // RG will contain AIO resources created from VM. So, we don't want to prevent RG deletion which will clean these up. + prevent_deletion_if_contains_resources = false + } + } +} + +data "azurerm_client_config" "current" {} + +resource "azurerm_resource_group" "test" { + name = "acctest-rg-%[1]d" + location = "%[2]s" +} + +resource "azurerm_virtual_network" "test" { + name = "acctestnw-%[1]d" + address_space = ["10.0.0.0/16"] + location = "%[2]s" + resource_group_name = azurerm_resource_group.test.name +} + +resource "azurerm_subnet" "test" { + name = "internal" + resource_group_name = azurerm_resource_group.test.name + virtual_network_name = azurerm_virtual_network.test.name + address_prefixes = ["10.0.2.0/24"] +} + +resource "azurerm_public_ip" "test" { + name = "acctestpip-%[1]d" + location = "%[2]s" + resource_group_name = azurerm_resource_group.test.name + allocation_method = "Static" +} + +resource "azurerm_network_interface" "test" { + name = "acctestnic-%[1]d" + location = "%[2]s" + resource_group_name = azurerm_resource_group.test.name + ip_configuration { + name = "internal" + subnet_id = azurerm_subnet.test.id + private_ip_address_allocation = "Dynamic" + public_ip_address_id = azurerm_public_ip.test.id + } +} + +resource "azurerm_network_security_group" "my_terraform_nsg" { + name = "myNetworkSG-%[1]d" + location = "%[2]s" + resource_group_name = azurerm_resource_group.test.name + security_rule { + name = "SSH" + priority = 1001 + direction = "Inbound" + access = "Allow" + protocol = "Tcp" + source_port_range = "*" + destination_port_range = "22" + source_address_prefix = "*" + destination_address_prefix = "*" + } + + lifecycle { + ignore_changes = [ + security_rule, + ] + } +} + +resource "azurerm_network_interface_security_group_association" "test" { + network_interface_id = azurerm_network_interface.test.id + network_security_group_id = azurerm_network_security_group.my_terraform_nsg.id +} + +resource "azurerm_linux_virtual_machine" "test" { + name = "acctestVM-%[1]d" + resource_group_name = azurerm_resource_group.test.name + location = "%[2]s" + size = "Standard_F8s_v2" + admin_username = "adminuser" + admin_password = "%[3]s" + provision_vm_agent = false + allow_extension_operations = false + disable_password_authentication = false + network_interface_ids = [ + azurerm_network_interface.test.id, + ] + os_disk { + caching = "ReadWrite" + storage_account_type = "Standard_LRS" + } + source_image_reference { + publisher = "Canonical" + offer = "0001-com-ubuntu-server-jammy" + sku = "22_04-lts" + version = "latest" + } + + identity { + type = "SystemAssigned" + } + + %[4]s + + depends_on = [ + azurerm_network_interface_security_group_association.test + ] +} +`, data.RandomInteger, data.Locations.Primary, credential, provisionTemplate) +} + +/* +Copies the script needed to create and provision the AIO cluster on the VM via SSH. +It does NOT run the script. That is done in the setupAIOClusterOnVM function. +*/ +func (r AssetTestResource) provisionTemplate(data acceptance.TestData, credential string) string { + // Get client secrets from env vars because we need them + // to remote execute az cli commands on the VM. + clientId := os.Getenv(ASSET_ARM_CLIENT_ID) + clientSecret := os.Getenv(ASSET_ARM_CLIENT_SECRET) + objectId := os.Getenv(ASSET_ARM_ENTRA_APP_OBJECT_ID) + + // Trim the random value (from acceptance.RandTimeInt which is 18 digits) to 10 digits + // to avoid exceeding the maximum length of the storage account name (24 chars max). + trimmedRandomInteger := data.RandomInteger % 10000000000 + return fmt.Sprintf(` +connection { + type = "ssh" + host = azurerm_public_ip.test.ip_address + user = "adminuser" + password = "%[1]s" +} + +provisioner "file" { + content = templatefile("testdata/setup_aio_cluster.sh.tftpl", { + subscription_id = data.azurerm_client_config.current.subscription_id + resource_group_name = azurerm_resource_group.test.name + cluster_name = "acctest-akcc-%[2]d" + location = azurerm_resource_group.test.location + custom_location = local.custom_location + storage_account = "acctestsa%[3]d" + schema_registry = "acctest-sr-%[2]d" + schema_registry_namespace = "acctest-rn-%[2]d" + aio_cluster_resource_name = "acctest-aio%[2]d" + tenant_id = data.azurerm_client_config.current.tenant_id + client_id = "%[5]s" + client_secret = "%[6]s" + object_id = "%[7]s" + managed_identity_name = "acctest-mi%[2]d" + keyvault_name = "acctest-kv%[2]d" + }) + destination = "%[4]s/setup_aio_cluster.sh" +} +`, credential, data.RandomInteger, trimmedRandomInteger, "/home/adminuser", clientId, clientSecret, objectId) +} + +/* +This function should be called for the PreConfig step after the template() is made to ensure +that the Asset resource create is blocked and does not occur until the AIO +cluster is set up on the VM. This is needed because the Asset resource +is an arc-enabled resource and requires the VM, AIO cluster, and custom location to be set up +before it can be created, and all of those resources it's dependent on are created by the setup +script run by the VM's `remote-exec` provisioner. However, `remote-exec` is not waited for by +the subsequent Asset resources even if `depends_on` is used, and will attempt +to start creating the resource once the VM is created but the AIO cluster is not set up yet. This way, +the setup script runs synchronously so the tests are forced to wait for it to finish before +creating the Asset resource. + +This function will grab the VM's public IP address to SSH into the VM and run the setup script +(the file was already provisioned on the VM) to create the AIO cluster. +*/ +func (r AssetTestResource) setupAIOClusterOnVM(t *testing.T, data acceptance.TestData) func() { + return func() { + // Set up the test client so we can fetch the public IP address. + clientManager, err := testclient.Build() + if err != nil { + t.Fatalf("failed to build client: %+v", err) + } + + ctx, cancel := context.WithDeadline(clientManager.StopContext, time.Now().Add(15*time.Minute)) + defer cancel() + + // Public IP Address metadata + publicIpClient := clientManager.Network.PublicIPAddresses + subscriptionId := os.Getenv(ASSET_ARM_SUBSCRIPTION_ID) + // Resource group name and public IP address name will be the same as template because we are using the same random integer + resourceGroupName := fmt.Sprintf("acctest-rg-%d", data.RandomInteger) + publicIpAddressName := fmt.Sprintf("acctestpip-%d", data.RandomInteger) + publicIpAddressId := commonids.NewPublicIPAddressID(subscriptionId, resourceGroupName, publicIpAddressName) + + // Get the public IP address + publicIpAddress, err := publicIpClient.Get(ctx, publicIpAddressId, publicipaddresses.DefaultGetOperationOptions()) + if err != nil { + t.Fatalf("failed to get public ip address: %+v", err) + } + + if publicIpAddress.Model == nil || publicIpAddress.Model.Properties.IPAddress == nil { + t.Fatalf("public ip address not found '%s'", publicIpAddressName) + } + + // SSH connection details + ipAddress := *publicIpAddress.Model.Properties.IPAddress + username := "adminuser" + password := r.getCredentials(data) + + // SSH client configuration + sshConfig := &ssh.ClientConfig{ + User: username, + Auth: []ssh.AuthMethod{ + ssh.Password(password), + }, + HostKeyCallback: ssh.InsecureIgnoreHostKey(), + } + + // Connect to the VM + conn, err := ssh.Dial("tcp", fmt.Sprintf("%s:22", ipAddress), sshConfig) + if err != nil { + t.Fatalf("failed to dial ssh: %s", err) + } + defer conn.Close() + + // Create a new session + stripSession, err := conn.NewSession() + if err != nil { + t.Fatalf("failed to create session for stripping carriage return: %s", err) + } + defer stripSession.Close() + // Strip carriage return from the setup script + if err := stripSession.Run("sudo sed -i 's/\r$//' /home/adminuser/setup_aio_cluster.sh"); err != nil { + t.Fatalf("failed to run command for stripping carriage return. Error: %s", err) + } + + // Create a new session + chmodSession, err := conn.NewSession() + if err != nil { + t.Fatalf("failed to create session for enabling execution for setup script: %s", err) + } + defer chmodSession.Close() + // Enable execution for the setup script + if err := chmodSession.Run("sudo chmod +x /home/adminuser/setup_aio_cluster.sh"); err != nil { + t.Fatalf("failed to run command for enabling execution. Error: %s", err) + } + + // Create a new session + runSession, err := conn.NewSession() + if err != nil { + t.Fatalf("failed to create session for running setup script: %s", err) + } + defer runSession.Close() + // Run the setup script + if err := runSession.Run("sudo bash /home/adminuser/setup_aio_cluster.sh &> /home/adminuser/agent_log"); err != nil { + t.Fatalf("failed to run command for running setup script. Error: %s", err) + } + } +} + +// Generates a random password for the VM. +func (AssetTestResource) getCredentials(data acceptance.TestData) string { + return fmt.Sprintf("P@$$w0rd%d!", data.RandomInteger) +} + +// Checks if the required environment variables are set before running the tests. +// If any of the required variables are not set, the test will be skipped. +func (AssetTestResource) checkEnvironmentVariables(t *testing.T) { + envVars := []string{ + ASSET_ARM_CLIENT_ID, + ASSET_ARM_CLIENT_SECRET, + ASSET_ARM_SUBSCRIPTION_ID, + ASSET_ARM_ENTRA_APP_OBJECT_ID, + } + for _, envVar := range envVars { + if os.Getenv(envVar) == "" { + envVarsString := strings.Join(envVars, ", ") + t.Skipf("Skipping test due to environment variable %s not set. Required variables: %s", envVar, envVarsString) + } + } +} diff --git a/internal/services/deviceregistry/registration.go b/internal/services/deviceregistry/registration.go new file mode 100644 index 000000000000..bc3fdbbe42f0 --- /dev/null +++ b/internal/services/deviceregistry/registration.go @@ -0,0 +1,36 @@ +package deviceregistry + +import ( + "github.com/hashicorp/terraform-provider-azurerm/internal/sdk" +) + +type Registration struct{} + +var _ sdk.TypedServiceRegistrationWithAGitHubLabel = Registration{} + +func (r Registration) AssociatedGitHubLabel() string { + return "service/deviceregistry" +} + +func (r Registration) DataSources() []sdk.DataSource { + return []sdk.DataSource{} +} + +func (r Registration) Resources() []sdk.Resource { + return []sdk.Resource{ + AssetResource{}, + AssetEndpointProfileResource{}, + } +} + +// Name is the name of this Service +func (r Registration) Name() string { + return "Device Registry" +} + +// WebsiteCategories returns a list of categories which can be used for the sidebar +func (r Registration) WebsiteCategories() []string { + return []string{ + "Device Registry", + } +} diff --git a/internal/services/deviceregistry/testdata/setup_aio_cluster.sh.tftpl b/internal/services/deviceregistry/testdata/setup_aio_cluster.sh.tftpl new file mode 100644 index 000000000000..99160937e023 --- /dev/null +++ b/internal/services/deviceregistry/testdata/setup_aio_cluster.sh.tftpl @@ -0,0 +1,102 @@ +#!/bin/bash + +export KUBECONFIG=/etc/rancher/k3s/k3s.yaml +export SUBSCRIPTION_ID=${subscription_id} +export RESOURCE_GROUP=${resource_group_name} +export CLUSTER_NAME=${cluster_name} +export LOCATION=${location} +export AIO_CLUSTER_NAME=${aio_cluster_resource_name} +export AIO_CLUSTER_CUSTOM_LOCATION_NAME=${custom_location} +export STORAGE_ACCOUNT_NAME=${storage_account} +export SCHEMA_REGISTRY_NAME=${schema_registry} +export SCHEMA_REGISTRY_NAMESPACE=${schema_registry_namespace} +export TENANT_ID=${tenant_id} +export USER_ASSIGNED_MI_NAME=${managed_identity_name} +export KEYVAULT_NAME=${keyvault_name} +export OBJECT_ID=${object_id} +export AZURE_CLIENT_ID=${client_id} # managed identity or service principal client id +export AZURE_CLIENT_SECRET=${client_secret} # service principal client secret + +printf "Subscription ID: $SUBSCRIPTION_ID\n" +printf "Resource Group: $RESOURCE_GROUP\n" +printf "Cluster Name: $CLUSTER_NAME\n" +printf "Location: $LOCATION\n" +printf "AIO Cluster Name: $AIO_CLUSTER_NAME\n" +printf "AIO Cluster Custom Location Name: $AIO_CLUSTER_CUSTOM_LOCATION_NAME\n" +printf "Storage Account Name: $STORAGE_ACCOUNT_NAME\n" +printf "Schema Registry Name: $SCHEMA_REGISTRY_NAME\n" +printf "Schema Registry Namespace: $SCHEMA_REGISTRY_NAMESPACE\n" +printf "Tenant ID: $TENANT_ID\n" +printf "Managed Identity Name: $USER_ASSIGNED_MI_NAME\n" +printf "Key Vault Name: $KEYVAULT_NAME\n" +printf "Azure Client ID: $AZURE_CLIENT_ID\n" +printf "Entra App Object ID for Custom Location RP: $OBJECT_ID\n" + +echo "Installing k3s..." +curl -sfL https://get.k3s.io | sh - + +echo "Installing Helm..." +curl https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3 | bash + +echo "Installing Azure CLI..." +curl -sL https://aka.ms/InstallAzureCLIDeb | sudo bash + +echo "Configuring k3s..." +sudo chmod 777 /etc/rancher/k3s/k3s.yaml + +echo "Logging in to Azure CLI..." +az login --username $AZURE_CLIENT_ID --password $AZURE_CLIENT_SECRET --service-principal --tenant $TENANT_ID # Service Principal login + +echo "Setting Azure subscription..." +az account set --subscription "$SUBSCRIPTION_ID" + +echo "Installing Azure CLI extensions..." +az extension add --name connectedk8s +az extension add --name azure-iot-ops + +echo "Connecting K3s Cluster to Azure resources..." +az connectedk8s connect --name $CLUSTER_NAME -l $LOCATION --resource-group $RESOURCE_GROUP --enable-oidc-issuer --enable-workload-identity + +echo "Configuring k3s to use Azure AD for service account tokens..." +SERVICE_ACCOUNT_ISSUER=$(az connectedk8s show --resource-group $RESOURCE_GROUP --name $CLUSTER_NAME --query oidcIssuerProfile.issuerUrl --output tsv) +sudo tee /etc/rancher/k3s/config.yaml > /dev/null <