diff --git a/Pulumi.yaml b/Pulumi.yaml index 512c59c..495c633 100644 --- a/Pulumi.yaml +++ b/Pulumi.yaml @@ -8,10 +8,11 @@ config: github:owner: kevholmes # web project configs elyclover.com-infra:ghAppSrcRepo: "elyclover.com" - elyclover.com-infra:AzSubScriptionIdWeb: df058eb7-0193-43d2-b1ff-937439336e86 + #elyclover.com-infra:AzSubScriptionIdWeb: df058eb7-0193-43d2-b1ff-937439336e86 elyclover.com-infra:AzTenantId: 6088cb1f-43c7-4299-9378-4946a45e93d1 elyclover.com-infra:siteKey: elyclover elyclover.com-infra:siteName: elyclover.com # dns configs from external Resource Group elyclover.com-infra:dnsResourceGroup: root elyclover.com-infra:dnsZoneName: elyclover.com + elyclover.com-infra:dnsRecordTTL: 300 diff --git a/cdn.go b/cdn.go index fc9fbcc..b412645 100644 --- a/cdn.go +++ b/cdn.go @@ -9,7 +9,6 @@ import ( legacycdn "github.com/pulumi/pulumi-azure/sdk/v5/go/azure/cdn" "github.com/pulumi/pulumi-azuread/sdk/v5/go/azuread" "github.com/pulumi/pulumi/sdk/v3/go/pulumi" - "github.com/pulumi/pulumi/sdk/v3/go/pulumi/config" ) type epCfgs struct { @@ -18,28 +17,29 @@ type epCfgs struct { domainArgs legacycdn.EndpointCustomDomainArgs } -func createCdnProfile(ctx *pulumi.Context, cdnName string, azureRg pulumi.StringOutput) (profile *nativecdn.Profile, err error) { +func (pr *projectResources) createCdnProfile() (err error) { var cdnProfileArgs = nativecdn.ProfileArgs{ Location: pulumi.String("global"), - ResourceGroupName: azureRg, + ResourceGroupName: pr.webResourceGrp.Name, Sku: &nativecdn.SkuArgs{ Name: pulumi.String("Standard_Microsoft"), }, } - profile, err = nativecdn.NewProfile(ctx, cdnName, &cdnProfileArgs) + cdnName := pr.cfgKeys.siteKey + pr.cfgKeys.envKey + pr.webCdnProfile, err = nativecdn.NewProfile(pr.pulumiCtx, cdnName, &cdnProfileArgs) if err != nil { fmt.Printf("ERROR: creating cdnProfile %s failed\n", cdnName) - return nil, err + return err } return } -func createCdnEndpoint(ctx *pulumi.Context, epName string, cdnProfile *nativecdn.Profile, azureRg pulumi.StringOutput, origin pulumi.StringOutput) (ep *nativecdn.Endpoint, err error) { +func (pr *projectResources) createCdnEndpoint() (err error) { // Create CDN Endpoint using newly created CDN Profile originsArgs := nativecdn.DeepCreatedOriginArray{ nativecdn.DeepCreatedOriginArgs{ Enabled: pulumi.Bool(true), - HostName: origin, + HostName: pr.webStaticEp, Name: pulumi.String("origin1"), }} // set up single delivery rule which forwards all HTTP traffic to HTTPS on CDN endpoint @@ -74,9 +74,9 @@ func createCdnEndpoint(ctx *pulumi.Context, epName string, cdnProfile *nativecdn } cdnEndPointArgs := nativecdn.EndpointArgs{ Origins: originsArgs, - ProfileName: cdnProfile.Name, - ResourceGroupName: azureRg, - OriginHostHeader: origin, + ProfileName: pr.webCdnProfile.Name, + ResourceGroupName: pr.webResourceGrp.Name, + OriginHostHeader: pr.webStaticEp, IsHttpAllowed: pulumi.Bool(true), IsHttpsAllowed: pulumi.Bool(true), DeliveryPolicy: deliveryPolicy, @@ -93,16 +93,16 @@ func createCdnEndpoint(ctx *pulumi.Context, epName string, cdnProfile *nativecdn pulumi.String("image/svg+xml"), }, } - ep, err = nativecdn.NewEndpoint(ctx, epName, &cdnEndPointArgs) + epName := pr.cfgKeys.siteKey + pr.cfgKeys.envKey + pr.webCdnEp, err = nativecdn.NewEndpoint(pr.pulumiCtx, epName, &cdnEndPointArgs) if err != nil { fmt.Printf("ERROR: creating endpoint %s failed\n", epName) - return ep, err + return err } return } -func newEndpointCustomDomain(ctx *pulumi.Context, epdName string, endpoint *nativecdn.Endpoint, domain pulumi.StringOutput, - cfg *config.Config) (epd *legacycdn.EndpointCustomDomain, err error) { +func (pr *projectResources) newEndpointCustomDomain() (err error) { // Utilize the azure legacy provider since it supports setting up auto-TLS for CDN custom domains // azure-native provider strangely lacks support for CDN-managed TLS on custom domains... epCfg := epCfgs{ @@ -115,17 +115,19 @@ func newEndpointCustomDomain(ctx *pulumi.Context, epdName string, endpoint *nati TlsVersion: pulumi.String("TLS12"), }, domainArgs: legacycdn.EndpointCustomDomainArgs{ - CdnEndpointId: endpoint.ID(), - HostName: domain, + CdnEndpointId: pr.webCdnEp.ID(), + HostName: pr.webFqdn, }, } - switch ctx.Stack() { + + switch pr.cfgKeys.envKey { // prod is byo certificate (self-managed) - case "prod": + // all subdomains are ACME and managed by Azure (mostly) + case PROD: // import pfx certificate stored at rest in source control to Azure Key Vault - certSec, err := importPfxToKeyVault(ctx, cfg) + certSec, err := importPfxToKeyVault(pr.pulumiCtx, pr.cfg) if err != nil { - return epd, err + return err } epCfg.userManaged.KeyVaultSecretId = certSec.Properties.SecretUri() epCfg.domainArgs.UserManagedHttps = epCfg.userManaged @@ -139,10 +141,13 @@ func newEndpointCustomDomain(ctx *pulumi.Context, epdName string, endpoint *nati // a feeling it's due to mix/match of azure and azure-native provider for our CDN work. By adding it we // avoid a constant cycle of Pulumi trying to destroy and re-create the Custom Domain which causes other // issues due to the reliance on the CNAME record which the provider does not (appear?) pick up on, unfortunately. - epd, err = legacycdn.NewEndpointCustomDomain(ctx, epdName, &epCfg.domainArgs, pulumi.IgnoreChanges([]string{"cdnEndpointId"})) + // https://github.com/kevholmes/elyclover.com-infra/issues/76 + _, err = legacycdn.NewEndpointCustomDomain( + pr.pulumiCtx, pr.cfgKeys.siteKey+pr.cfgKeys.envKey, &epCfg.domainArgs, + pulumi.IgnoreChanges([]string{"cdnEndpointId"})) if err != nil { fmt.Println("ERROR: creating custom domain for CDN endpoint failed") - return epd, err + return err } return @@ -152,24 +157,25 @@ func newEndpointCustomDomain(ctx *pulumi.Context, epdName string, endpoint *nati // Production uses an apex domain (eg tld.com) which Azure doesn't support free TLS certs + rotation on (:shrug:) // so we'll need to set up a Service Principal registered under the Azure CDN App profile and give it RBAC access to // an external Azure KeyVault resource that contains our pfx certificate needed for the prod tld.com domain. -func setupTlsTermination(ctx *pulumi.Context, cfg *config.Config, ep *nativecdn.Endpoint, fqdn pulumi.StringOutput) (err error) { - if ctx.Stack() == "prod" { +func (pr *projectResources) setupTlsTermination() (err error) { + if pr.cfgKeys.envKey == PROD { // Register Azure CDN Application as Service Principal in AD/Entra tenant so it can fetch TLS pfx data in external Keystore - cdnId := cfg.Require("Microsoft.AzureFrontDoor-Cdn") - nsp, err := azuread.NewServicePrincipal(ctx, cdnId, &azuread.ServicePrincipalArgs{ - ApplicationId: pulumi.String(cdnId), + nsp, err := azuread.NewServicePrincipal(pr.pulumiCtx, pr.cfgKeys.cdnAzureId, &azuread.ServicePrincipalArgs{ + ApplicationId: pulumi.String(pr.cfgKeys.cdnAzureId), UseExisting: pulumi.Bool(false), Description: pulumi.String("Service Principal tied to built-in Azure CDN/FD Application ID/product"), }) if err != nil { return err } + // assign predefined "Key Vault Secret User" RoleDefinitionId to Service Principal we just created // https://learn.microsoft.com/en-us/azure/role-based-access-control/built-in-roles#key-vault-secrets-user // This allows Azure CDN to access the pfx keys we purchase/generate external to pulumi (quite a bit cheaper than asking Azure to do it.) keyVaultScope := pulumi.Sprintf("/subscriptions/%s/resourceGroups/%s/providers/Microsoft.KeyVault/vaults/%s", - cfg.Require("keyVaultAzureSubscription"), cfg.Require("keyvaultResourceGroup"), cfg.Require("keyVaultName")) - _, err = authorization.NewRoleAssignment(ctx, "AzureFDCDNreadKVCerts", &authorization.RoleAssignmentArgs{ + pr.cfgKeys.kvAzureSubscription, pr.cfgKeys.kvAzureResourceGrp, pr.cfgKeys.kvAzureName) + + _, err = authorization.NewRoleAssignment(pr.pulumiCtx, "AzureFDCDNreadKVCerts", &authorization.RoleAssignmentArgs{ PrincipalId: nsp.ID(), PrincipalType: pulumi.String("ServicePrincipal"), RoleDefinitionId: pulumi.String("/providers/Microsoft.Authorization/roleDefinitions/4633458b-17de-408a-b874-0445c86b69e6"), @@ -181,7 +187,7 @@ func setupTlsTermination(ctx *pulumi.Context, cfg *config.Config, ep *nativecdn. } // Add Custom Domain to CDN for prod or non-prod - _, err = newEndpointCustomDomain(ctx, cfg.Require("siteKey")+ctx.Stack(), ep, fqdn, cfg) + err = pr.newEndpointCustomDomain() if err != nil { return err } diff --git a/dns.go b/dns.go index 33aa1e8..5971b10 100644 --- a/dns.go +++ b/dns.go @@ -2,47 +2,47 @@ package main import ( "fmt" + "strconv" "strings" - "github.com/pulumi/pulumi-azure-native-sdk/cdn/v2" "github.com/pulumi/pulumi-azure/sdk/v5/go/azure/dns" "github.com/pulumi/pulumi/sdk/v3/go/pulumi" ) -func lookupDnsZone(ctx *pulumi.Context, rg string, lz string) (lzResult *dns.LookupZoneResult, err error) { +func (pr *projectResources) lookupDnsZone() (err error) { dnsLookupZoneArgs := dns.LookupZoneArgs{ - Name: lz, - ResourceGroupName: &rg, + Name: pr.cfgKeys.dnsLookupZone, + ResourceGroupName: &pr.cfgKeys.dnsResourceGrp, } - lzResult, err = dns.LookupZone(ctx, &dnsLookupZoneArgs) + pr.webDnsZone, err = dns.LookupZone(pr.pulumiCtx, &dnsLookupZoneArgs) if err != nil { - fmt.Printf("ERROR: looking up dnsZone in RG %s failed\n", rg) - return lzResult, err + fmt.Printf("ERROR: looking up dnsZone in RG %s failed\n", pr.cfgKeys.dnsResourceGrp) + return err } return } -func createDnsRecordByEnv(ctx *pulumi.Context, dnsRG string, dz *dns.LookupZoneResult, ep *cdn.Endpoint, envKey string, siteKey string) (d pulumi.StringOutput, err error) { +func (pr *projectResources) createDnsRecordByEnv() (err error) { fqdnErr := fmt.Errorf("passed FQDN string didn't include trailing '.' did the Azure API change?") - switch envKey { - case "prod": // apex domain for prod eg tld.com uses A record referencing Azure resource + switch pr.cfgKeys.envKey { + case PROD: // apex domain for prod eg tld.com uses A record referencing Azure resource // create A record pointing at CDN Endpoint resource ID - dnsRecord, err := createARecordPointingAtCdnResourceID(ctx, dnsRG, dz, pulumi.StringOutput(ep.ID()), envKey, siteKey) + err = pr.createApexRecordPointingAtCdnResourceID() if err != nil { - return d, err + return err } // create CNAME 'cdnverify.tld.com' record cdnVerify := "cdnverify" - cdnVerifyHostname := ep.HostName.ApplyT(func(h string) (r string) { + cdnVerifyHostname := pr.webCdnEp.HostName.ApplyT(func(h string) (r string) { r = cdnVerify + "." + h return }).(pulumi.StringOutput) - _, err = createCNAMERecordPointingAtCdnEndpoint(ctx, dnsRG, dz, cdnVerifyHostname, cdnVerify, siteKey) + err = pr.createCNAMERecordPointingAtCdnEndpoint(cdnVerifyHostname, pr.cfgKeys.siteKey+cdnVerify) if err != nil { - return d, err + return err } // strip out trailing '.' from A record returned FQDN string within Azure DNS API - d = dnsRecord.Fqdn.ApplyT(func(fqdn string) (string, error) { + pr.webFqdn = pr.dnsRecords.a.Fqdn.ApplyT(func(fqdn string) (string, error) { h, found := strings.CutSuffix(fqdn, ".") if !found { return h, fqdnErr @@ -51,12 +51,12 @@ func createDnsRecordByEnv(ctx *pulumi.Context, dnsRG string, dz *dns.LookupZoneR }).(pulumi.StringOutput) default: // everything that's not prod and has a sub-domain eg dev.tld.com // create CNAME DNS record to point at CDN endpoint - dnsRecord, err := createCNAMERecordPointingAtCdnEndpoint(ctx, dnsRG, dz, ep.HostName, envKey, siteKey) + err = pr.createCNAMERecordPointingAtCdnEndpoint(pr.webCdnEp.HostName, pr.cfgKeys.envKey) if err != nil { - return d, err + return err } // strip out trailing '.' from CNAME's returned FQDN string within Azure DNS API - d = dnsRecord.Fqdn.ApplyT(func(fqdn string) (string, error) { + pr.webFqdn = pr.dnsRecords.cname.Fqdn.ApplyT(func(fqdn string) (string, error) { h, found := strings.CutSuffix(fqdn, ".") if !found { return h, fqdnErr @@ -67,37 +67,50 @@ func createDnsRecordByEnv(ctx *pulumi.Context, dnsRG string, dz *dns.LookupZoneR return } -func createCNAMERecordPointingAtCdnEndpoint(ctx *pulumi.Context, dnsRG string, dz *dns.LookupZoneResult, ep pulumi.StringOutput, envKey string, siteKey string) (record *dns.CNameRecord, err error) { +func (pr *projectResources) createCNAMERecordPointingAtCdnEndpoint(ep pulumi.StringOutput, name string) (err error) { + ttl, err := strconv.Atoi(pr.cfgKeys.dnsRecordTTL) + if err != nil { + fmt.Printf("ERROR: dnsRecordTTL provided cannot be converted from string to int\n") + return err + } // create new CNAME record in zone for non-prod env that will be used by CDN endpoint dnsRecordArgs := dns.CNameRecordArgs{ - ZoneName: pulumi.String(dz.Name), - ResourceGroupName: pulumi.String(dnsRG), - Ttl: pulumi.Int(300), // 5 minutes - Name: pulumi.String(envKey), + ZoneName: pulumi.String(pr.webDnsZone.Name), + ResourceGroupName: pulumi.String(pr.cfgKeys.dnsResourceGrp), + Ttl: pulumi.Int(ttl), + Name: pulumi.String(name), Record: ep, } - record, err = dns.NewCNameRecord(ctx, siteKey+envKey, &dnsRecordArgs) + + pr.dnsRecords.cname, err = dns.NewCNameRecord(pr.pulumiCtx, name, &dnsRecordArgs) if err != nil { fmt.Printf("ERROR: creating CNAME record in RG %s failed\n", - dnsRG) - return record, err + pr.cfgKeys.dnsResourceGrp) + return err } return } -func createARecordPointingAtCdnResourceID(ctx *pulumi.Context, dnsRG string, dz *dns.LookupZoneResult, tg pulumi.StringOutput, envKey string, siteKey string) (record *dns.ARecord, err error) { +func (pr *projectResources) createApexRecordPointingAtCdnResourceID() (err error) { + ttl, err := strconv.Atoi(pr.cfgKeys.dnsRecordTTL) + if err != nil { + fmt.Printf("ERROR: dnsRecordTTL provided cannot be converted from string to int\n") + return err + } + rootRecordName := "@" dnsRecordArgs := dns.ARecordArgs{ - Name: pulumi.String("@"), - ZoneName: pulumi.String(dz.Name), - ResourceGroupName: pulumi.String(dnsRG), - Ttl: pulumi.Int(300), // 5 minutes - TargetResourceId: tg, + Name: pulumi.String(rootRecordName), + ZoneName: pulumi.String(pr.webDnsZone.Name), + ResourceGroupName: pulumi.String(pr.cfgKeys.dnsResourceGrp), + Ttl: pulumi.Int(ttl), + TargetResourceId: pulumi.StringOutput(pr.webCdnEp.ID()), } - record, err = dns.NewARecord(ctx, siteKey+envKey, &dnsRecordArgs) + name := pr.cfgKeys.siteKey + pr.cfgKeys.envKey + pr.dnsRecords.a, err = dns.NewARecord(pr.pulumiCtx, name, &dnsRecordArgs) if err != nil { fmt.Printf("ERROR: creating A record in RG %s failed\n", - dnsRG) - return record, err + pr.cfgKeys.dnsResourceGrp) + return err } return } diff --git a/github.go b/github.go index 635e4c5..e0ee54a 100644 --- a/github.go +++ b/github.go @@ -3,19 +3,16 @@ package main import ( "fmt" - "github.com/pulumi/pulumi-azure-native-sdk/cdn/v2" - "github.com/pulumi/pulumi-azure-native-sdk/resources/v2" - "github.com/pulumi/pulumi-azure-native-sdk/storage/v2" "github.com/pulumi/pulumi-github/sdk/v5/go/github" "github.com/pulumi/pulumi/sdk/v3/go/pulumi" "github.com/pulumi/pulumi/sdk/v3/go/pulumi/config" ) -func exportDeployEnvDataToGitHubRepo(ctx *pulumi.Context, cfg *config.Config, sp ServicePrincipalEnvelope, rg *resources.ResourceGroup, sa *storage.StorageAccount, cdnprof *cdn.Profile, ep *cdn.Endpoint) (err error) { +func (pr *projectResources) exportDeployEnvDataToGitHubRepo() (err error) { // Validate repo - githubConfig := config.New(ctx, "github") - repoPath := fmt.Sprintf("%s/%s", githubConfig.Require("owner"), cfg.Require("ghAppSrcRepo")) - repo, err := github.LookupRepository(ctx, &github.LookupRepositoryArgs{ + githubConfig := config.New(pr.pulumiCtx, "github") + repoPath := fmt.Sprintf("%s/%s", githubConfig.Require("owner"), pr.cfgKeys.ghAppSrcRepo) + repo, err := github.LookupRepository(pr.pulumiCtx, &github.LookupRepositoryArgs{ FullName: &repoPath, }) if err != nil { @@ -23,8 +20,8 @@ func exportDeployEnvDataToGitHubRepo(ctx *pulumi.Context, cfg *config.Config, sp } // Create deployment environment using stack name (env) - env := ctx.Stack() - repoEnv, err := github.NewRepositoryEnvironment(ctx, env, &github.RepositoryEnvironmentArgs{ + env := pr.cfgKeys.envKey + repoEnv, err := github.NewRepositoryEnvironment(pr.pulumiCtx, env, &github.RepositoryEnvironmentArgs{ Repository: pulumi.String(repo.Name), Environment: pulumi.String(env), }) @@ -35,10 +32,10 @@ func exportDeployEnvDataToGitHubRepo(ctx *pulumi.Context, cfg *config.Config, sp // Create Actions Deployment Environment Secret for Azure SP that will be deploying via Actions workflows // https://github.com/Azure/login#configure-a-service-principal-with-a-secret secretsVars := map[string]pulumi.StringInput{ - "CLIENT_SECRET": sp.ServicePrincipalPass.Value, + "CLIENT_SECRET": pr.svcPrincipals.cicd.ServicePrincipalPass.Value, } for k, v := range secretsVars { - _, err = github.NewActionsEnvironmentSecret(ctx, k, &github.ActionsEnvironmentSecretArgs{ + _, err = github.NewActionsEnvironmentSecret(pr.pulumiCtx, k, &github.ActionsEnvironmentSecretArgs{ Repository: pulumi.String(repo.Name), SecretName: pulumi.String(k), Environment: repoEnv.Environment, @@ -51,8 +48,8 @@ func exportDeployEnvDataToGitHubRepo(ctx *pulumi.Context, cfg *config.Config, sp // to have access to the dev stack environment's Azure SP client secret token. // This allows the Dependabot PR to be treated just like a user-submitted (chore-like) PR to bump the dependency // The outcome here is that the Dependabot submitted PR gets built and deployed just like any other. - if ctx.Stack() == "dev" { - _, err = github.NewDependabotSecret(ctx, k, &github.DependabotSecretArgs{ + if pr.cfgKeys.envKey == DEV { + _, err = github.NewDependabotSecret(pr.pulumiCtx, k, &github.DependabotSecretArgs{ Repository: pulumi.String(repo.Name), SecretName: pulumi.String(k), PlaintextValue: v, @@ -61,16 +58,16 @@ func exportDeployEnvDataToGitHubRepo(ctx *pulumi.Context, cfg *config.Config, sp } // Create Actions Deployment Environment Variables to be used in Actions CI/CD workflows actionsVars := map[string]pulumi.StringInput{ - "AZ_CDN_ENDPOINT": ep.Name, - "AZ_CDN_PROFILE_NAME": cdnprof.Name, - "AZ_RESOURCE_GROUP": rg.Name, - "AZ_STORAGE_ACCT": sa.Name, - "CLIENT_ID": sp.ServicePrincipal.ClientId, - "SUBSCRIPTION_ID": pulumi.String(cfg.Require("AzSubScriptionIdWeb")), - "TENANT_ID": pulumi.String(cfg.Require("AzTenantId")), + "AZ_CDN_ENDPOINT": pr.webCdnEp.Name, + "AZ_CDN_PROFILE_NAME": pr.webCdnProfile.Name, + "AZ_RESOURCE_GROUP": pr.webResourceGrp.Name, + "AZ_STORAGE_ACCT": pr.webStorageAccount.Name, + "CLIENT_ID": pr.svcPrincipals.cicd.ServicePrincipal.ClientId, + "SUBSCRIPTION_ID": pulumi.String(pr.thisAzureSubscription.SubscriptionId), + "TENANT_ID": pulumi.String(pr.cfgKeys.thisAzureTenantId), } for k, v := range actionsVars { - _, err = github.NewActionsEnvironmentVariable(ctx, k, &github.ActionsEnvironmentVariableArgs{ + _, err = github.NewActionsEnvironmentVariable(pr.pulumiCtx, k, &github.ActionsEnvironmentVariableArgs{ Environment: repoEnv.Environment, Repository: pulumi.String(repo.Name), VariableName: pulumi.String(k), diff --git a/iam.go b/iam.go index c0609af..eb3291c 100644 --- a/iam.go +++ b/iam.go @@ -2,8 +2,6 @@ package main import ( "github.com/pulumi/pulumi-azure-native-sdk/authorization/v2" - nativecdn "github.com/pulumi/pulumi-azure-native-sdk/cdn/v2" - "github.com/pulumi/pulumi-azure-native-sdk/storage/v2" "github.com/pulumi/pulumi-azuread/sdk/v5/go/azuread" "github.com/pulumi/pulumi/sdk/v3/go/pulumi" ) @@ -23,50 +21,50 @@ const StorageContributor string = "/providers/Microsoft.Authorization/roleDefini const CdnContributor string = "/providers/Microsoft.Authorization/roleDefinitions/426e0c7f-0c7e-4658-b36f-ff54d6c29b45" // create an Azure Service Principal to be used by CI/CD for deploying built code, expiring CDN content -func generateCICDServicePrincipal(ctx *pulumi.Context, sa *storage.StorageAccount, ep *nativecdn.Endpoint) (nsp ServicePrincipalEnvelope, err error) { - cicd := "cicd-actions" + "-" + ctx.Project() + "-" + ctx.Stack() +func (pr *projectResources) generateCICDServicePrincipal() (err error) { + cicd := "cicd-actions" + "-" + pr.cfgKeys.projectKey + "-" + pr.cfgKeys.envKey - app, err := azuread.NewApplication(ctx, cicd, &azuread.ApplicationArgs{ + app, err := azuread.NewApplication(pr.pulumiCtx, cicd, &azuread.ApplicationArgs{ DisplayName: pulumi.String(cicd), }) if err != nil { - return nsp, err + return err } - spDesc := pulumi.Sprintf("Service Principal used for CI/CD purposes within %s-%s", ctx.Project(), ctx.Stack()) + spDesc := pulumi.Sprintf("Service Principal used for CI/CD purposes within %s-%s", pr.cfgKeys.projectKey, pr.cfgKeys.envKey) nspArgs := azuread.ServicePrincipalArgs{ ClientId: app.ClientId, UseExisting: pulumi.Bool(false), Description: spDesc, } - nsp.ServicePrincipal, err = azuread.NewServicePrincipal(ctx, cicd+"-serviceprincipal", &nspArgs) + pr.svcPrincipals.cicd.ServicePrincipal, err = azuread.NewServicePrincipal(pr.pulumiCtx, cicd+"-serviceprincipal", &nspArgs) if err != nil { - return nsp, err + return err } // authorize new SP to modify any resources required to deploy code to this project ra := []RoleAssignments{ - {cicd + "-storagerole", StorageContributor, sa.ID()}, - {cicd + "-cdnrole", CdnContributor, ep.ID()}, + {cicd + "-storagerole", StorageContributor, pr.webStorageAccount.ID()}, + {cicd + "-cdnrole", CdnContributor, pr.webCdnProfile.ID()}, } for _, v := range ra { - _, err = authorization.NewRoleAssignment(ctx, v.Name, &authorization.RoleAssignmentArgs{ - PrincipalId: nsp.ServicePrincipal.ID(), + _, err = authorization.NewRoleAssignment(pr.pulumiCtx, v.Name, &authorization.RoleAssignmentArgs{ + PrincipalId: pr.svcPrincipals.cicd.ServicePrincipal.ID(), PrincipalType: pulumi.String("ServicePrincipal"), RoleDefinitionId: pulumi.String(v.Definition), Scope: v.Scope, }) if err != nil { - return nsp, err + return err } } // generate password / client secret for Service Principal - nsp.ServicePrincipalPass, err = azuread.NewServicePrincipalPassword(ctx, cicd+"-secret", &azuread.ServicePrincipalPasswordArgs{ - ServicePrincipalId: nsp.ServicePrincipal.ID(), + pr.svcPrincipals.cicd.ServicePrincipalPass, err = azuread.NewServicePrincipalPassword(pr.pulumiCtx, cicd+"-secret", &azuread.ServicePrincipalPasswordArgs{ + ServicePrincipalId: pr.svcPrincipals.cicd.ServicePrincipal.ID(), }) if err != nil { - return nsp, err + return err } return diff --git a/keyvault.go b/keyvault.go index 089c516..a61f473 100644 --- a/keyvault.go +++ b/keyvault.go @@ -25,6 +25,8 @@ func getSecretByName(ctx *pulumi.Context, kv string, rg string, name string) (se return } +// utility functions + // https://learn.microsoft.com/en-us/rest/api/keyvault/certificates/import-certificate/import-certificate?tabs=HTTP // Why PFX and not PEM: https://learn.microsoft.com/en-us/answers/questions/131459/azure-cdn-key-vault func importPfxToKeyVault(ctx *pulumi.Context, cfg *config.Config) (sec *keyvault.Secret, err error) { diff --git a/main.go b/main.go index 6f54a5c..9ff3937 100644 --- a/main.go +++ b/main.go @@ -1,95 +1,137 @@ package main import ( - "fmt" - "os" - + nativecdn "github.com/pulumi/pulumi-azure-native-sdk/cdn/v2" + "github.com/pulumi/pulumi-azure-native-sdk/resources/v2" + "github.com/pulumi/pulumi-azure-native-sdk/storage/v2" + "github.com/pulumi/pulumi-azure/sdk/v5/go/azure/core" + "github.com/pulumi/pulumi-azure/sdk/v5/go/azure/dns" "github.com/pulumi/pulumi/sdk/v3/go/pulumi" "github.com/pulumi/pulumi/sdk/v3/go/pulumi/config" ) -func main() { +type cfgKeys struct { + // pulumi/env names + projectKey string + envKey string + siteKey string + // Azure service values we pull in from projects external to this (for now) + thisAzureTenantId string + dnsResourceGrp string + dnsLookupZone string + dnsRecordTTL string + cdnAzureId string + kvAzureSubscription string // keyvault can live elsewhere + kvAzureResourceGrp string + kvAzureName string + // Github service values pulled in from other projects external to this one + ghAppSrcRepo string +} + +type svcPrincipals struct { + cicd ServicePrincipalEnvelope +} + +type dnsRecords struct { + a *dns.ARecord + cname *dns.CNameRecord +} - // Check for DEBUG mode in local execution environment - isDEBUG := os.Getenv("DEBUG") - if isDEBUG == "true" { - fmt.Println("DEBUG: Debug console logging enabled!") - } else { - fmt.Println("INFO: Debug console logging disabled!") - } +type projectResources struct { + pulumiCtx *pulumi.Context + cfg *config.Config + cfgKeys cfgKeys + // Azure service values for top-level Subscription + thisAzureSubscription *core.LookupSubscriptionResult + svcPrincipals svcPrincipals + dnsRecords dnsRecords + webResourceGrp *resources.ResourceGroup + webStorageAccount *storage.StorageAccount + webStaticEp pulumi.StringOutput + webCdnProfile *nativecdn.Profile + webCdnEp *nativecdn.Endpoint + webDnsZone *dns.LookupZoneResult + webFqdn pulumi.StringOutput +} + +const PROD = "prod" +const DEV = "dev" + +func main() { - // Begin Pulumi functionality pulumi.Run(func(ctx *pulumi.Context) error { - // Initialize and obtain config values for stack, stack name - cfg := config.New(ctx, "") - projectKey := ctx.Project() - envKey := ctx.Stack() - fmt.Printf("DEBUG: stack name: %s\n", projectKey) + // Init common resources + pr := projectResources{ + pulumiCtx: ctx, + cfg: config.New(ctx, ""), + } + // Init config keys from Pulumi key:values set per project/env + err := pr.initConfigKeys() + if err != nil { + return err + } // Create an Azure Resource Group - webResourceGrp, err := createResourceGroup(ctx, projectKey+"-"+envKey, nil) + err = pr.createResourceGroup() if err != nil { return err } // Create an Azure Storage Account to host our site - siteKey := cfg.Require("siteKey") - storageAccount, err := newStorageAccount(ctx, siteKey+envKey, webResourceGrp.Name) + err = pr.newStorageAccount() if err != nil { return err } // Enable static web hosting on storage account - err = enableStaticWebHostOnStorageAccount(ctx, storageAccount.Name, webResourceGrp.Name, siteKey) + err = pr.enableStaticWebHostOnStorageAccount() if err != nil { return err } // Strip leading 'https://' and trailing '/' from web endpoint address // for the Storage Account's static website URL - staticEndpoint := stripWebStorageEndPointUrl(storageAccount) + pr.webStaticEp = stripWebStorageEndPointUrl(pr.webStorageAccount) // Create CDN Profile for usage by our endpoint(s) - cdnProfile, err := createCdnProfile(ctx, siteKey+envKey, webResourceGrp.Name) + err = pr.createCdnProfile() if err != nil { return err } // Create CDN Endpoint using newly created CDN Profile - endpoint, err := createCdnEndpoint(ctx, siteKey+envKey, cdnProfile, webResourceGrp.Name, staticEndpoint) + err = pr.createCdnEndpoint() if err != nil { return err } // Look up DNS zone based on pulumi stack config var for external resource group that houses DNS records - dnsRG := cfg.Require("dnsResourceGroup") - dnsLookupZone := cfg.Require("dnsZoneName") - dnsZone, err := lookupDnsZone(ctx, dnsRG, dnsLookupZone) + err = pr.lookupDnsZone() if err != nil { return err } // Set up domains depending on env - fqdn, err := createDnsRecordByEnv(ctx, dnsRG, dnsZone, endpoint, envKey, siteKey) + err = pr.createDnsRecordByEnv() if err != nil { return err } // Set up TLS depending on environment and custom domain types - err = setupTlsTermination(ctx, cfg, endpoint, fqdn) + err = pr.setupTlsTermination() if err != nil { return err } // Create+authorize Service Principal to be used in CI/CD process (uploading new content, invalidating cdn cache) - cicdSp, err := generateCICDServicePrincipal(ctx, storageAccount, endpoint) + err = pr.generateCICDServicePrincipal() if err != nil { return err } // Export service principal secret/id, cdn profile/endpoint, resource group, storage acct // to GitHub repo Deployment secrets/vars where Actions build and deploy to each environment re: gitops flow - err = exportDeployEnvDataToGitHubRepo(ctx, cfg, cicdSp, webResourceGrp, storageAccount, cdnProfile, endpoint) + err = pr.exportDeployEnvDataToGitHubRepo() if err != nil { return err } diff --git a/resourceGroup.go b/resourceGroup.go index 7543482..2bd1ce9 100644 --- a/resourceGroup.go +++ b/resourceGroup.go @@ -4,15 +4,15 @@ import ( "fmt" "github.com/pulumi/pulumi-azure-native-sdk/resources/v2" - "github.com/pulumi/pulumi/sdk/v3/go/pulumi" ) -func createResourceGroup(ctx *pulumi.Context, name string, args *resources.ResourceGroupArgs) (rg *resources.ResourceGroup, err error) { +func (pr *projectResources) createResourceGroup() (err error) { // Create an Azure Resource Group - rg, err = resources.NewResourceGroup(ctx, name, args) + name := pr.cfgKeys.projectKey + "-" + pr.cfgKeys.envKey + pr.webResourceGrp, err = resources.NewResourceGroup(pr.pulumiCtx, name, nil) if err != nil { fmt.Printf("ERROR: creating webResourceGrp failed with %s\n", name) - return rg, err + return err } return } diff --git a/storage.go b/storage.go index 880259f..4c48d04 100644 --- a/storage.go +++ b/storage.go @@ -7,33 +7,34 @@ import ( "github.com/pulumi/pulumi/sdk/v3/go/pulumi" ) -func newStorageAccount(ctx *pulumi.Context, name string, rg pulumi.StringOutput) (sa *storage.StorageAccount, err error) { +func (pr *projectResources) newStorageAccount() (err error) { storageAccountArgs := storage.StorageAccountArgs{ - ResourceGroupName: rg, + ResourceGroupName: pr.webResourceGrp.Name, Sku: &storage.SkuArgs{ Name: pulumi.String("Standard_ZRS"), }, Kind: pulumi.String("StorageV2"), } - sa, err = storage.NewStorageAccount(ctx, name, &storageAccountArgs) + name := pr.cfgKeys.siteKey + pr.cfgKeys.envKey + pr.webStorageAccount, err = storage.NewStorageAccount(pr.pulumiCtx, name, &storageAccountArgs) if err != nil { fmt.Printf("ERROR: creating storageAccount %s failed\n", name) - return sa, err + return err } return } -func enableStaticWebHostOnStorageAccount(ctx *pulumi.Context, saName pulumi.StringOutput, rg pulumi.StringOutput, siteKey string) (err error) { +func (pr *projectResources) enableStaticWebHostOnStorageAccount() (err error) { // Enable static website support for the Storage Account storageArgs := storage.StorageAccountStaticWebsiteArgs{ - AccountName: saName, - ResourceGroupName: rg, + AccountName: pr.webStorageAccount.Name, + ResourceGroupName: pr.webResourceGrp.Name, IndexDocument: pulumi.String("index.html"), Error404Document: pulumi.String("404.hml"), } - _, err = storage.NewStorageAccountStaticWebsite(ctx, siteKey, &storageArgs) + _, err = storage.NewStorageAccountStaticWebsite(pr.pulumiCtx, pr.cfgKeys.siteKey, &storageArgs) if err != nil { - fmt.Printf("ERROR: creating staticWebsite %s failed\n", siteKey) + fmt.Printf("ERROR: creating staticWebsite %s failed\n", pr.cfgKeys.siteKey) return err } return diff --git a/utils.go b/utils.go index de5ec03..d5496fb 100644 --- a/utils.go +++ b/utils.go @@ -5,9 +5,8 @@ import ( "strings" "github.com/pulumi/pulumi-azure-native-sdk/storage/v2" + "github.com/pulumi/pulumi-azure/sdk/v5/go/azure/core" "github.com/pulumi/pulumi/sdk/v3/go/pulumi" - - //"golang.org/x/crypto/pkcs12" pkcs12 "software.sslmate.com/src/go-pkcs12" ) @@ -34,3 +33,24 @@ func loadFileFromDisk(path string) (data []byte, err error) { } return } + +// consider iterating over pr.cfgKeys with a k:v map to load these in if list continues to grow +func (pr *projectResources) initConfigKeys() (err error) { + pr.cfgKeys.projectKey = pr.pulumiCtx.Project() + pr.cfgKeys.envKey = pr.pulumiCtx.Stack() + pr.cfgKeys.siteKey = pr.cfg.Require("siteKey") + pr.cfgKeys.dnsResourceGrp = pr.cfg.Require("dnsResourceGroup") + pr.cfgKeys.dnsLookupZone = pr.cfg.Require("dnsZoneName") + pr.cfgKeys.ghAppSrcRepo = pr.cfg.Require("ghAppSrcRepo") + pr.cfgKeys.thisAzureTenantId = pr.cfg.Require("AzTenantId") + pr.cfgKeys.dnsRecordTTL = pr.cfg.Require("dnsRecordTTL") + if pr.cfgKeys.envKey == PROD { + pr.cfgKeys.cdnAzureId = pr.cfg.Require("Microsoft.AzureFrontDoor-Cdn") + pr.cfgKeys.kvAzureSubscription = pr.cfg.Require("keyVaultAzureSubscription") + pr.cfgKeys.kvAzureResourceGrp = pr.cfg.Require("keyvaultResourceGroup") + pr.cfgKeys.kvAzureName = pr.cfg.Require("keyVaultName") + } + pr.thisAzureSubscription, err = core.LookupSubscription(pr.pulumiCtx, nil) + + return +}