Skip to content

Commit

Permalink
Custom support for File Share Protected Items (#3809)
Browse files Browse the repository at this point in the history
Resolves #1420

The
[recoveryservices.ProtectedItem](https://www.pulumi.com/registry/packages/azure-native/api-docs/recoveryservices/protecteditem/)
represents a resource that's backed up via Azure Backup Service (which
confusingly has the namespace Microsoft.RecoveryServices). ProtectedItem
is a sort of union type with one member per Azure resource that can be
protected, e.g., AzureVmWorkloadProtectedItem.

One of these types, AzureFileshareProtectedItem, is special in that it
requires a file share name as input that's _not_ the name of the file
share as given by the user and as shown by the portal, but an internal
"system name". Therefore, attempting to use AzureFileshareProtectedItem
in the standard way is not possible, because for file share "Foo",
`"protectedItemName": "Foo"` will not work.

Instead, we need to look up the system name for the user-visible name
"Foo" first, and use it instead.
  • Loading branch information
thomas11 authored Jan 9, 2025
1 parent a8ea0a6 commit 8a269f4
Show file tree
Hide file tree
Showing 16 changed files with 772 additions and 165 deletions.
15 changes: 15 additions & 0 deletions examples/examples_nodejs_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -309,6 +309,21 @@ func TestPortalDashboardTs(t *testing.T) {
integration.ProgramTest(t, &test)
}

func TestRecoveryServicesProtectedItemTs(t *testing.T) {
t.Skip("Skipping due to #3832")

test := getJSBaseOptions(t).
With(integration.ProgramTestOptions{
Dir: filepath.Join(getCwd(t), "recoveryservices-protecteditem"),
// Backing up protected items increases `protectedItemsCount` in policy and container,
// and adds `AzureBackupProtected` to the item.
ExpectRefreshChanges: true,
PreviewCommandlineFlags: []string{"--diff"},
})

integration.ProgramTest(t, &test)
}

func getJSBaseOptions(t *testing.T) integration.ProgramTestOptions {
base := getBaseOptions(t)
baseJS := base.With(integration.ProgramTestOptions{
Expand Down
10 changes: 10 additions & 0 deletions examples/recoveryservices-protecteditem/Pulumi.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
name: recoveryservices-protecteditem
description: A minimal Azure Native TypeScript Pulumi program
runtime:
name: nodejs
options:
packagemanager: yarn
config:
pulumi:tags:
value:
pulumi:template: azure-typescript
114 changes: 114 additions & 0 deletions examples/recoveryservices-protecteditem/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
// Copyright 2024, Pulumi Corporation. All rights reserved.

import * as recoveryservices from '@pulumi/azure-native/recoveryservices/v20240101';
import * as recoveryservicesTypes from '@pulumi/azure-native/types';
import * as resources from '@pulumi/azure-native/resources';
import * as storage from '@pulumi/azure-native/storage';
import * as pulumi from '@pulumi/pulumi';

const resourceGroup = new resources.ResourceGroup('resourceGroup');

const storageAccount = new storage.StorageAccount('recoverysa', {
resourceGroupName: resourceGroup.name,
location: resourceGroup.location,
kind: 'StorageV2',
sku: {
name: 'Standard_LRS',
},
publicNetworkAccess: 'Enabled',
});

const vault = new recoveryservices.Vault('vault', {
identity: {
type: 'SystemAssigned',
},
location: resourceGroup.location,
properties: {
publicNetworkAccess: 'Enabled',
securitySettings: {
softDeleteSettings: {
// Deleting the vault is not allowed with soft delete enabled.
// BUT: setting these to "Disabled" seems to have no effect as of 2025-01, though. Deleted items in
// the vault are still soft-deleted. #3832
softDeleteState: recoveryservices.SoftDeleteState.Disabled,
enhancedSecurityState: recoveryservices.EnhancedSecurityState.Disabled,
}
},
},
resourceGroupName: resourceGroup.name,
sku: {
name: 'Standard',
},
}, {
// This dependency isn't required for creation of the resources but for deletion order. The storage account is
// locked as long as backup protection is active.
dependsOn: [storageAccount],
});

const protectionPolicy = new recoveryservices.ProtectionPolicy('protectionPolicy', {
location: resourceGroup.location,
resourceGroupName: resourceGroup.name,
vaultName: vault.name,
properties: {
backupManagementType: recoveryservices.BackupManagementType.AzureStorage,
schedulePolicy: {
schedulePolicyType: 'SimpleSchedulePolicy',
scheduleRunFrequency: recoveryservices.ScheduleRunType.Daily,
scheduleRunTimes: ['2024-10-08T19:30:00Z'],
},
retentionPolicy: {
retentionPolicyType: 'LongTermRetentionPolicy',
dailySchedule: {
retentionDuration: {
count: 1,
durationType: recoveryservices.RetentionDurationType.Days,
},
retentionTimes: ['2024-10-08T19:30:00Z'],
},
},
timeZone: 'GTB Standard Time',
workLoadType: recoveryservices.WorkloadType.AzureFileShare,
} // as recoveryservicesTypes.input.recoveryservices.AzureFileShareProtectionPolicyArgs,
}, {
// This dependency isn't required for creation of the resources but for deletion order.
// The protection policy must be deleted before the storage account.
dependsOn: [storageAccount],
});

const protectionContainer = new recoveryservices.ProtectionContainer("exampleProtectionContainer", {
resourceGroupName: resourceGroup.name,
vaultName: vault.name,
containerName: pulumi.interpolate`storagecontainer;storage;${resourceGroup.name};${storageAccount.name}`,
fabricName: "Azure",
properties: {
friendlyName: "exampleContainer",
containerType: "StorageContainer",
backupManagementType: recoveryservices.BackupManagementType.AzureStorage,
sourceResourceId: storageAccount.id,
},
});

const fileShare = new storage.FileShare('fileshare', {
accountName: storageAccount.name,
accessTier: 'TransactionOptimized',
resourceGroupName: resourceGroup.name,
}, {
deletedWith: storageAccount,
});

// Enable backup for Azure File Share (Protected Item)
const protectedItem = new recoveryservices.ProtectedItem('protectedItem', {
containerName: protectionContainer.name,
fabricName: 'Azure',
properties: {
policyId: protectionPolicy.id,
protectedItemType: 'AzureFileShareProtectedItem',
softDeleteRetentionPeriodInDays: 1,
sourceResourceId: storageAccount.id,
}, // as recoveryservicesTypes.input.recoveryservices.AzureFileshareProtectedItemArgs,
protectedItemName: fileShare.name,
resourceGroupName: resourceGroup.name,
vaultName: vault.name,
});

export const vaultId = vault.id;
12 changes: 12 additions & 0 deletions examples/recoveryservices-protecteditem/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"name": "recoveryservices-protecteditem",
"main": "index.ts",
"devDependencies": {
"@types/node": "^18",
"typescript": "^5.0.0"
},
"dependencies": {
"@pulumi/azure-native": "^2.0.0",
"@pulumi/pulumi": "^3.113.0"
}
}
18 changes: 18 additions & 0 deletions examples/recoveryservices-protecteditem/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
{
"compilerOptions": {
"strict": true,
"outDir": "bin",
"target": "es2020",
"module": "commonjs",
"moduleResolution": "node",
"sourceMap": true,
"experimentalDecorators": true,
"pretty": true,
"noFallthroughCasesInSwitch": true,
"noImplicitReturns": true,
"forceConsistentCasingInFileNames": true
},
"files": [
"index.ts"
]
}
1 change: 1 addition & 0 deletions provider/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ require (
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.16.0
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.8.0
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/keyvault/armkeyvault v1.4.0
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/recoveryservices/armrecoveryservicesbackup/v4 v4.1.0
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/storage/armstorage v1.6.0
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.1.0
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azsecrets v1.1.0
Expand Down
2 changes: 2 additions & 0 deletions provider/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,8 @@ github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/internal/v3 v3.0.0 h1:Kb8e
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/internal/v3 v3.0.0/go.mod h1:lYq15QkJyEsNegz5EhI/0SXQ6spvGfgwBH/Qyzkoc/s=
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/keyvault/armkeyvault v1.4.0 h1:HlZMUZW8S4P9oob1nCHxCCKrytxyLc+24nUJGssoEto=
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/keyvault/armkeyvault v1.4.0/go.mod h1:StGsLbuJh06Bd8IBfnAlIFV3fLb+gkczONWf15hpX2E=
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/recoveryservices/armrecoveryservicesbackup/v4 v4.1.0 h1:9UWpKYKgpzbMlLzAib1NbqJhfS9vfe0vR6JqbOQghkI=
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/recoveryservices/armrecoveryservicesbackup/v4 v4.1.0/go.mod h1:yCSotfXkHjUuPsFlZkWxi3lgI/wT8x/alEY7k8PJ+FM=
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources v1.2.0 h1:Dd+RhdJn0OTtVGaeDLZpcumkIVCtA/3/Fo42+eoYvVM=
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources v1.2.0/go.mod h1:5kakwfW5CjC9KK+Q4wjXAg+ShuIm2mBMua0ZFj2C8PE=
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/storage/armstorage v1.6.0 h1:PiSrjRPpkQNjrM8H0WwKMnZUdu1RGMtd/LdGKUrOo+c=
Expand Down
45 changes: 45 additions & 0 deletions provider/pkg/azure/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,51 @@ type AzureClient interface {
Put(ctx context.Context, id string, bodyProps map[string]interface{}, queryParameters map[string]interface{}, asyncStyle string) (map[string]interface{}, bool, error)
}

// MockAzureClient implements the AzureClient interface for tests.
type MockAzureClient struct {
// Resource ids that were retrieved via Get, in order
GetIds []string

// If set, this response will be returned for all Get requests; otherwise nil.
GetResponse map[string]any
GetResponseErr error

// Resource ids that were used in Post, in order
PostIds []string
PostBodies []map[string]any

QueryParamsOfLastDelete map[string]any
}

func (m *MockAzureClient) Delete(ctx context.Context, id, apiVersion, asyncStyle string, queryParams map[string]any) error {
m.QueryParamsOfLastDelete = queryParams
return nil
}
func (m *MockAzureClient) CanCreate(ctx context.Context, id, path, apiVersion, readMethod string, isSingletonResource, hasDefaultBody bool, isDefaultResponse func(map[string]any) bool) error {
return nil
}
func (m *MockAzureClient) Get(ctx context.Context, id string, apiVersion string, queryParams map[string]any) (any, error) {
m.GetIds = append(m.GetIds, id)
return m.GetResponse, m.GetResponseErr
}
func (m *MockAzureClient) Head(ctx context.Context, id string, apiVersion string) error {
return nil
}
func (m *MockAzureClient) Patch(ctx context.Context, id string, bodyProps map[string]interface{}, queryParameters map[string]interface{}, asyncStyle string) (map[string]interface{}, bool, error) {
return nil, false, nil
}
func (m *MockAzureClient) Post(ctx context.Context, id string, bodyProps map[string]interface{}, queryParameters map[string]interface{}) (any, error) {
m.PostIds = append(m.PostIds, id)
m.PostBodies = append(m.PostBodies, bodyProps)
return map[string]any{}, nil
}
func (m *MockAzureClient) Put(ctx context.Context, id string, bodyProps map[string]interface{}, queryParameters map[string]interface{}, asyncStyle string) (map[string]interface{}, bool, error) {
return nil, false, nil
}
func (m *MockAzureClient) IsNotFound(err error) bool {
return false
}

type azureClientImpl struct {
environment azure.Environment
client autorest.Client
Expand Down
6 changes: 6 additions & 0 deletions provider/pkg/gen/schema.go
Original file line number Diff line number Diff line change
Expand Up @@ -1182,6 +1182,12 @@ func (g *packageGenerator) getAsyncStyle(op *spec.Operation) string {
return ""
}

// These operations from Microsoft.RecoveryServices/stable/2023-04-01/bms.json are incorrectly not marked as
// long-running. https://github.com/Azure/azure-rest-api-specs/issues/31943
if op.ID == "ProtectedItems_CreateOrUpdate" || op.ID == "ProtectedItems_Delete" {
return extensionLongRunningDefault
}

enabled, ok := op.Extensions.GetBool(extensionLongRunning)
if !ok || !enabled {
return ""
Expand Down
11 changes: 9 additions & 2 deletions provider/pkg/provider/provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -932,9 +932,17 @@ func (k *azureNativeProvider) Create(ctx context.Context, req *rpc.CreateRequest
}, nil
}

customRes, isCustom := k.customResources[res.Path]

crudClient := crud.NewResourceCrudClient(k.azureClient, k.lookupType, k.converter, k.subscriptionID, res)

id, queryParams, err := crudClient.PrepareAzureRESTIdAndQuery(inputs)
var id string
var queryParams map[string]any
if isCustom && customRes.GetIdAndQuery != nil {
id, queryParams, err = customRes.GetIdAndQuery(ctx, inputs, crudClient)
} else {
id, queryParams, err = crudClient.PrepareAzureRESTIdAndQuery(inputs)
}
if err != nil {
return nil, err
}
Expand All @@ -943,7 +951,6 @@ func (k *azureNativeProvider) Create(ctx context.Context, req *rpc.CreateRequest
defer cancel()

var outputs map[string]interface{}
customRes, isCustom := k.customResources[res.Path]
switch {
case isCustom && customRes.Create != nil:
// First check if the resource already exists - we want to try our best to avoid updating instead of creating here.
Expand Down
39 changes: 5 additions & 34 deletions provider/pkg/provider/provider_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"github.com/Azure/azure-sdk-for-go/sdk/azcore/cloud"
"github.com/Azure/azure-sdk-for-go/sdk/azcore/fake"
"github.com/Azure/go-autorest/autorest/azure"
az "github.com/pulumi/pulumi-azure-native/v2/provider/pkg/azure"
"github.com/pulumi/pulumi-azure-native/v2/provider/pkg/convert"
"github.com/pulumi/pulumi-azure-native/v2/provider/pkg/provider/crud"
"github.com/pulumi/pulumi-azure-native/v2/provider/pkg/resources"
Expand Down Expand Up @@ -356,14 +357,14 @@ func TestReader(t *testing.T) {
},
}

azureClient := &mockAzureClient{}
azureClient := &az.MockAzureClient{}
crudClient := crud.NewResourceCrudClient(azureClient, nil, nil, "123", nil)

r := reader(customRes, crudClient)
_, err := r(context.Background(), "id1", nil)
require.NoError(t, err)
assert.Equal(t, []string{"id1"}, customReads)
assert.Empty(t, azureClient.getIds)
assert.Empty(t, azureClient.GetIds)
})

t.Run("no custom Read", func(t *testing.T) {
Expand All @@ -372,13 +373,13 @@ func TestReader(t *testing.T) {
}

for _, otherCustomRes := range []*customresources.CustomResource{nil, {} /* custom resource that doesn't implement Read */} {
azureClient := &mockAzureClient{}
azureClient := &az.MockAzureClient{}
crudClient := crud.NewResourceCrudClient(azureClient, nil, nil, "123", resource)

r := reader(otherCustomRes, crudClient)
_, err := r(context.Background(), "id2", nil)
require.NoError(t, err)
assert.Contains(t, azureClient.getIds, "id2")
assert.Contains(t, azureClient.GetIds, "id2")
}
})
}
Expand Down Expand Up @@ -477,36 +478,6 @@ func TestAutorestAzureClientUsesCorrectCloud(t *testing.T) {
}
}

type mockAzureClient struct {
getIds []string
}

func (m *mockAzureClient) Delete(ctx context.Context, id, apiVersion, asyncStyle string, queryParams map[string]any) error {
return nil
}
func (m *mockAzureClient) CanCreate(ctx context.Context, id, path, apiVersion, readMethod string, isSingletonResource, hasDefaultBody bool, isDefaultResponse func(map[string]any) bool) error {
return nil
}
func (m *mockAzureClient) Get(ctx context.Context, id string, apiVersion string, queryParams map[string]any) (any, error) {
m.getIds = append(m.getIds, id)
return map[string]any{}, nil
}
func (m *mockAzureClient) Head(ctx context.Context, id string, apiVersion string) error {
return nil
}
func (m *mockAzureClient) Patch(ctx context.Context, id string, bodyProps map[string]interface{}, queryParameters map[string]interface{}, asyncStyle string) (map[string]interface{}, bool, error) {
return nil, false, nil
}
func (m *mockAzureClient) Post(ctx context.Context, id string, bodyProps map[string]interface{}, queryParameters map[string]interface{}) (any, error) {
return nil, nil
}
func (m *mockAzureClient) Put(ctx context.Context, id string, bodyProps map[string]interface{}, queryParameters map[string]interface{}, asyncStyle string) (map[string]interface{}, bool, error) {
return nil, false, nil
}
func (m *mockAzureClient) IsNotFound(err error) bool {
return false
}

func TestGetTokenEndpoint(t *testing.T) {
t.Parallel()

Expand Down
Loading

0 comments on commit 8a269f4

Please sign in to comment.