Skip to content

[Az] Day 06: Implement a private CloudPC and DevOps Agent Hub with Pulumi.

Published: at 12:00 PM

Introduction

Creating a secure private VNet for CloudPC (Windows 365 Enterprise) and Azure DevOps agent allows for an isolated network environment, enabling secure access, management, and control of cloud resources.

This guide will walk you through the process of setting up a private VNet using Pulumi, integrating it with the Hub VNet was created in the previous article.


Table of Contents

Open Table of Contents

The project modules

The CloudPcFirewallRules Module

This module defines the firewall policies for:

The VNet.ts Module

This module is responsible for creating a virtual network (VNet) with two subnets. It also establishes peering with the Hub VNet that was set up in the previous project, similar to the VNet component used for AKS in az-03-aks-cluster project.

View code:

export default (
    name: string,
    {
        rsGroup,
        subnets,
        routes,
        peeringVnet,
        securityRules,
    }: {
        rsGroup: resources.ResourceGroup;
        subnets: inputs.network.SubnetArgs[];
        /**The optional additional rules for NetworkSecurityGroup*/
        securityRules?: pulumi.Input<inputs.network.SecurityRuleArgs>[];
        /**The optional of routing rules for RouteTable*/
        routes?: pulumi.Input<inputs.network.RouteArgs>[];
        peeringVnet?: {
            name: pulumi.Input<string>;
            id: pulumi.Input<string>;
            resourceGroupName: pulumi.Input<string>;
        };
    }
) => {
    const sgroup = createSecurityGroup(name, { rsGroup, securityRules });
    const routeTable = routes
        ? createRouteTable(name, { rsGroup, routes })
        : undefined;

    const vnetName = getName(name, 'vnet');
    const vnet = new network.VirtualNetwork(
        vnetName,
        {
            // Resource group name
            resourceGroupName: rsGroup.name,
            //Enable VN protection
            enableVmProtection: true,
            //Enable Vnet encryption
            encryption: {
                enabled: true,
                enforcement:
                    network.VirtualNetworkEncryptionEnforcement
                        .AllowUnencrypted,
            },
            addressSpace: {
                addressPrefixes: subnets.map((s) => s.addressPrefix!),
            },
            subnets: subnets.map((s) => ({
                ...s,
                //Inject NetworkSecurityGroup to all subnets if available
                networkSecurityGroup: sgroup ? { id: sgroup.id } : undefined,
                //Inject RouteTable to all subnets if available
                routeTable: routeTable ? { id: routeTable.id } : undefined,
            })),
        },
        {
            dependsOn: routeTable ? [sgroup, routeTable] : sgroup,
            //Ignore this property as the peering will be manage by instance of VirtualNetworkPeering
            ignoreChanges: ['virtualNetworkPeerings'],
        }
    );

    // Create sync Peering from `az-02-hub-vnet` to `az-03-aks-cluster`
    // The peering need to be happened 2 ways in order to establish the connection
    if (peeringVnet) {
        //from `az-02-hub-vnet` to `az--04-cloudPc`
        new network.VirtualNetworkPeering(
            `hun-to-${vnetName}`,
            {
                resourceGroupName: peeringVnet.resourceGroupName,
                virtualNetworkName: peeringVnet.name,
                allowVirtualNetworkAccess: true,
                allowForwardedTraffic: true,
                syncRemoteAddressSpace: 'true',
                remoteVirtualNetwork: { id: vnet.id },
                peeringSyncLevel: 'FullyInSync',
            },
            { dependsOn: vnet }
        );
        //from `az-04-cloudPc` to `az-02-hub-vnet`
        new network.VirtualNetworkPeering(
            `${vnetName}-to-hub`,
            {
                resourceGroupName: rsGroup.name,
                virtualNetworkName: vnet.name,
                allowVirtualNetworkAccess: true,
                allowForwardedTraffic: true,
                syncRemoteAddressSpace: 'true',
                remoteVirtualNetwork: { id: peeringVnet.id },
                peeringSyncLevel: 'FullyInSync',
            },
            { dependsOn: vnet }
        );
    }

    return vnet;
};

The DiskEncryptionSet.ts Module

This module demonstrates how to encrypt Azure resources using a custom encryption key stored in Azure Key Vault. It includes the following components:

The VM.ts Module

This module facilitates the provisioning of a Linux virtual machine (VM) on Azure with automatically generated login credentials and disk encryption and connects the VM to a subnet within the virtual network.

View code:

import { getName } from '@az-commons';
import * as azure from '@pulumi/azure-native';
import * as pulumi from '@pulumi/pulumi';
import * as random from '@pulumi/random';
import { createEncryptionKey } from './DiskEncryptionSet';

type VaultInfo = {
    id: pulumi.Input<string>;
    resourceGroupName: pulumi.Input<string>;
    vaultName: pulumi.Input<string>;
    readOnlyGroupId: pulumi.Input<string>;
};

/**
 * Generate the username and password for the vm
 * */
const generateLogin = (name: string, vault: VaultInfo) => {
    const usernameKey = getName(name, 'username');
    const username = new random.RandomString(usernameKey, {
        length: 15,
        special: false,
    });
    const passwordKey = getName(name, 'password');
    const password = new random.RandomPassword(passwordKey, {
        length: 50,
    });

    //Store secrets to the vault
    [
        { name: usernameKey, value: username.result },
        { name: passwordKey, value: password.result },
    ].map(
        (item) =>
            new azure.keyvault.Secret(
                item.name,
                {
                    ...vault,
                    secretName: item.name,
                    properties: { value: item.value },
                },
                { dependsOn: [username, password] }
            )
    );

    return { username, password };
};

/**
 * Create a Linux VM:
 * - `username` and `password` will be generated using pulumi random and store in Key Vault.
 * - The AzureDevOps extension will be installed automatically and become a private AzureDevOps agent.
 * - It will be encrypted with a custom key from Key Vault also.
 * - The disk also will be encrypted with a DiskEncryptionSet.
 * */
export default (
    name: string,
    {
        rsGroup,
        vault,
        vmSize = 'Standard_B2s',
        diskEncryptionSet,
        vnet,
    }: {
        vmSize: string;
        rsGroup: azure.resources.ResourceGroup;
        diskEncryptionSet: azure.compute.DiskEncryptionSet;
        vault: VaultInfo;
        vnet: azure.network.VirtualNetwork;
    }
) => {
    const vmName = getName(name, 'vm');
    //Create VM login info
    const loginInfo = generateLogin(vmName, vault);

    //Create VM NIC
    const nic = new azure.network.NetworkInterface(
        vmName,
        {
            resourceGroupName: rsGroup.name,
            ipConfigurations: [
                {
                    name: 'ipconfig',
                    subnet: {
                        id: vnet.subnets.apply(
                            (ss) => ss!.find((s) => s.name === 'devops')!.id!
                        ),
                    },
                    primary: true,
                },
            ],
            nicType: azure.network.NetworkInterfaceNicType.Standard,
        },
        { dependsOn: [rsGroup, vnet] }
    );

    //Create VM
    const vm = new azure.compute.VirtualMachine(
        vmName,
        {
            resourceGroupName: rsGroup.name,
            hardwareProfile: { vmSize },
            licenseType: 'None',
            networkProfile: {
                networkInterfaces: [{ id: nic.id, primary: true }],
            },
            //az feature register --name EncryptionAtHost  --namespace Microsoft.Compute
            securityProfile: { encryptionAtHost: true },
            osProfile: {
                computerName: vmName,
                adminUsername: loginInfo.username.result,
                adminPassword: loginInfo.password.result,
                allowExtensionOperations: true,
                linuxConfiguration: {
                    //ssh: { publicKeys: [{ keyData: linux.sshPublicKey! }] },
                    //TODO: this shall be set as 'true' when ssh is provided
                    disablePasswordAuthentication: false,
                    provisionVMAgent: true,
                    patchSettings: {
                        assessmentMode:
                            azure.compute.LinuxPatchAssessmentMode
                                .AutomaticByPlatform,
                        automaticByPlatformSettings: {
                            bypassPlatformSafetyChecksOnUserSchedule: true,
                            rebootSetting:
                                azure.compute
                                    .LinuxVMGuestPatchAutomaticByPlatformRebootSetting
                                    .Never,
                        },
                        patchMode:
                            azure.compute.LinuxVMGuestPatchMode
                                .AutomaticByPlatform,
                    },
                },
            },
            storageProfile: {
                imageReference: {
                    offer: '0001-com-ubuntu-server-jammy',
                    publisher: 'Canonical',
                    sku: '22_04-lts',
                    version: 'latest',
                },
                osDisk: {
                    diskSizeGB: 256,
                    caching: 'ReadWrite',
                    createOption: 'FromImage',
                    osType: azure.compute.OperatingSystemTypes.Linux,
                    managedDisk: {
                        diskEncryptionSet: { id: diskEncryptionSet.id },
                        storageAccountType:
                            azure.compute.StorageAccountTypes.StandardSSD_LRS,
                    },
                },
                //Sample to create data disk if needed
                // dataDisks: [
                //     {
                //         diskSizeGB: 256,
                //         createOption: azure.compute.DiskCreateOptionTypes.Empty,
                //         lun: 1,
                //         managedDisk: {
                //             diskEncryptionSet: {
                //                 id: diskEncryptionSet.id,
                //             },
                //             storageAccountType:
                //                 azure.compute.StorageAccountTypes
                //                     .StandardSSD_LRS,
                //         },
                //     },
                // ],
            },
            diagnosticsProfile: { bootDiagnostics: { enabled: true } },
        },
        {
            dependsOn: [
                diskEncryptionSet,
                loginInfo.username,
                loginInfo.password,
                nic,
            ],
        }
    );

    return vm;
};

The PrivateDNS.ts Module

To optimize internal network communication, we implement a DNS resolver. This module sets up a private DNS zone that facilitates efficient name resolution within our network infrastructure.

  1. Private DNS Zone: Creates a dedicated DNS zone for internal use.
  2. VNet Links: Establishes connections between the private DNS zone and both the Hub and CloudPC virtual networks.
  3. A Record: Configures A record that points to the private IP address of our NGINX ingress controller.

    In the following topics, We will cover the NGINX ingress controller deployed on AKS as private ingress, and it will be assigned the internal IP address 192.168.31.250. This IP must be within the AKS subnet range.

By linking this private DNS to both the Hub and CloudPC VNets, we ensure that all DNS requests for our internal services are correctly routed to the NGINX ingress controller. This setup enhances security and improves network performance by keeping internal traffic within our private network.

View code:

import * as azure from '@pulumi/azure-native';
import * as pulumi from '@pulumi/pulumi';

export default (
    name: string,
    {
        privateIpAddress,
        vnetIds,
        rsGroup,
    }: {
        privateIpAddress: pulumi.Input<string>;
        rsGroup: azure.resources.ResourceGroup;
        vnetIds: pulumi.Input<string>[];
    }
) => {
    //Create Zone
    const zone = new azure.network.PrivateZone(
        name,
        {
            privateZoneName: name,
            resourceGroupName: rsGroup.name,
            //The location here must be 'global'
            location: 'global',
        },
        { dependsOn: rsGroup }
    );

    //Create Root Records to the private Ip Address
    new azure.network.PrivateRecordSet(
        `${name}-Root`,
        {
            privateZoneName: zone.name,
            resourceGroupName: rsGroup.name,
            relativeRecordSetName: '@',
            recordType: 'A',
            aRecords: [{ ipv4Address: privateIpAddress }],
            ttl: 3600,
        },
        { dependsOn: zone }
    );
    new azure.network.PrivateRecordSet(
        `${name}-Star`,
        {
            privateZoneName: zone.name,
            resourceGroupName: rsGroup.name,
            relativeRecordSetName: '*',
            recordType: 'A',
            aRecords: [{ ipv4Address: privateIpAddress }],
            ttl: 3600,
        },
        { dependsOn: zone }
    );

    //Link to VNET
    vnetIds.map(
        (v, index) =>
            new azure.network.VirtualNetworkLink(
                `${name}-${index}-link`,
                {
                    privateZoneName: zone.name,
                    resourceGroupName: rsGroup.name,
                    //The location here must be 'global'
                    location: 'global',
                    registrationEnabled: false,
                    virtualNetwork: { id: v },
                },
                { dependsOn: zone }
            )
    );

    return zone;
};


Developing the CloudPC Stack

Our objective is to establish a private Virtual Network (VNet) for CloudPC and Azure DevOps agents using Pulumi, enabling us to provision the necessary Azure resources effectively.

  1. Firewall Policy: Implement security policies to manage egress traffic for CloudPC and DevOps agents.
  2. VNet and Peering: Develop the primary network infrastructure, including subnets for CloudPC and the Azure DevOps agent, with necessary VNet peering.
  3. Disk Encryption Set: Integrate a disk encryption set to secure virtual machine data at rest.
  4. Deploy a Linux VM: Provision a Linux virtual machine to host the Azure DevOps agent. The agent installation process will be covered in the following topic.
View code:

import { getGroupName, StackReference } from '@az-commons';
import * as azure from '@pulumi/azure-native';
import * as pulumi from '@pulumi/pulumi';
import * as config from '../config';
import FirewallRule from './CloudPcFirewallRules';
import DiskEncryptionSet from './DiskEncryptionSet';
import PrivateDNS from './PrivateDNS';
import VM from './VM';
import VNet from './VNet';

//Reference to the output of `az-01-shared` and `az-02-hub-vnet`.
const sharedStack = StackReference<config.SharedStackOutput>('az-01-shared');
const hubVnetStack = StackReference<config.HubVnetOutput>('az-02-hub-vnet');

//Apply Firewall Rules
FirewallRule(config.azGroups.cloudPC, {
    resourceGroupName: hubVnetStack.rsGroup.name,
    name: hubVnetStack.firewallPolicy.name,
});

//The vault Info from the shared project
const vault = {
    id: sharedStack.vault.id,
    vaultName: sharedStack.vault.name,
    resourceGroupName: sharedStack.rsGroup.name,
    readOnlyGroupId: sharedStack.vault.readOnlyGroupId,
};

// Create Shared Resource Group
const rsGroup = new azure.resources.ResourceGroup(
    getGroupName(config.azGroups.cloudPC)
);

// Create Virtual Network with Subnets
const vnet = VNet(config.azGroups.cloudPC, {
    rsGroup,
    subnets: [
        {
            name: 'cloudpc',
            addressPrefix: config.subnetSpaces.cloudPC,
        },
        {
            name: 'devops',
            addressPrefix: config.subnetSpaces.devOps,
        },
    ],
    //allows vnet to firewall's private IP
    securityRules: [
        {
            name: `allows-vnet-to-hub-firewall`,
            description: 'Allows Vnet to hub firewall Outbound',
            priority: 300,
            protocol: '*',
            access: 'Allow',
            direction: 'Outbound',
            sourceAddressPrefix: hubVnetStack.firewall.address.apply(
                (ip) => `${ip}/32`
            ),
            sourcePortRange: '*',
            destinationAddressPrefix: 'VirtualNetwork',
            destinationPortRange: '*',
        },
        {
            name: `allows-aks-to-devops`,
            description: 'Allows aks to devops Inbound',
            priority: 301,
            protocol: '*',
            access: 'Allow',
            direction: 'Inbound',
            sourceAddressPrefix: config.subnetSpaces.aks,
            sourcePortRange: '*',
            destinationAddressPrefix: config.subnetSpaces.devOps,
            destinationPortRange: '*',
        },
    ],
    //route all requests to firewall's private IP
    routes: [
        {
            name: 'route-vnet-to-firewall',
            addressPrefix: '0.0.0.0/0',
            nextHopIpAddress: hubVnetStack.firewall.address,
            nextHopType: 'VirtualAppliance',
        },
    ],
    //peering to hub vnet
    peeringVnet: {
        name: hubVnetStack.hubVnet.name,
        id: hubVnetStack.hubVnet.id,
        resourceGroupName: hubVnetStack.rsGroup.name,
    },
});

//Create Disk Encryption. This shall be able to share to multi VMs
const diskEncryptionSet = DiskEncryptionSet(config.azGroups.cloudPC, {
    rsGroup,
    vault,
});

//Create DevOps Agent 01 VM
const vm = VM('devops-agent-01', {
    diskEncryptionSet,
    rsGroup,
    vmSize: 'Standard_B2s',
    vault,
    vnet,
});

//Create Private DNS Zone
const zone = PrivateDNS('drunkcoding.net', {
    rsGroup,
    privateIpAddress: '192.168.31.250',
    vnetIds: [vnet.id, hubVnetStack.hubVnet.id],
});

// Export the information that will be used in the other projects
export default {
    rsGroup: { name: rsGroup.name, id: rsGroup.id },
    cloudPcVnet: { name: vnet.name, id: vnet.id },
    devopsAgent01: { name: vm.name, id: vm.id },
    privateDNS: { name: zone.name, id: zone.id },
};


Deployment and Cleanup

Deploying the Stack

To deploy the stack, run the pnpm run up command. This will provision all the necessary Azure resources, such as the Virtual Network (VNet), subnets, firewall, and private endpoints. Before executing the command, ensure you are logged into your Azure account using the Azure CLI and have configured Pulumi with the correct backend and credentials.

Azure-Resources

The deployed Azure resources

Cleaning Up the Stack

To remove all associated Azure resources and clean up the stack, execute the pnpm run destroy command. This will help avoid unnecessary costs and ensure that all resources are properly deleted after testing or development.


References


Next

Day 07: Setup Windows 365 Enterprise as a private VDI

In the next article, we will explore how to configure a CloudPC with Windows 365 Enterprise to establish a secure and efficient Virtual Desktop Infrastructure (VDI) for accessing a private AKS environment.


Thank You

Thank you for taking the time to read this guide! I hope it has been helpful, feel free to explore further, and happy coding! 🌟✨

Steven | GitHub