From a60bac5e347b06950c46f7df64287342433c59bb Mon Sep 17 00:00:00 2001 From: Mischa Spiegelmock Date: Sun, 19 Oct 2025 20:48:07 -0700 Subject: [PATCH 1/4] chore: deploy to AWS --- .github/workflows/deploy.yml | 52 ++++++ infra/.gitignore | 1 + infra/Pulumi.dev.yaml | 9 ++ infra/Pulumi.yaml | 7 + infra/README.md | 63 ++++++++ infra/index.ts | 296 +++++++++++++++++++++++++++++++++++ infra/package.json | 21 +++ infra/tsconfig.json | 20 +++ 8 files changed, 469 insertions(+) create mode 100644 .github/workflows/deploy.yml create mode 100644 infra/.gitignore create mode 100644 infra/Pulumi.dev.yaml create mode 100644 infra/Pulumi.yaml create mode 100644 infra/README.md create mode 100644 infra/index.ts create mode 100644 infra/package.json create mode 100644 infra/tsconfig.json diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..84cb10b --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,52 @@ +name: Deploy Static Site + +on: + push: + branches: + - master + - aws-deploy + workflow_dispatch: + +permissions: + id-token: write + contents: read + +env: + AWS_REGION: ${{ vars.AWS_REGION }} + S3_BUCKET: ${{ vars.S3_BUCKET }} + CLOUDFRONT_DISTRIBUTION_ID: ${{ vars.CLOUDFRONT_DISTRIBUTION_ID }} + GH_TOKEN: ${{ secrets.GH_TOKEN }} + NUXT_PUBLIC_ASSET_KEY: ${{ secrets.NUXT_PUBLIC_ASSET_KEY }} + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Bun + uses: oven-sh/setup-bun@v1 + with: + bun-version: latest + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Generate reports + run: bun run generate-reports + + - name: Generate static site + run: bun run generate + + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v4 + with: + role-to-assume: ${{ secrets.AWS_ROLE_ARN }} + aws-region: ${{ env.AWS_REGION }} + + - name: Sync artifacts to S3 + run: aws s3 sync .output/public "s3://${S3_BUCKET}" --delete + + - name: Invalidate CloudFront + run: aws cloudfront create-invalidation --distribution-id "${CLOUDFRONT_DISTRIBUTION_ID}" --paths "/*" diff --git a/infra/.gitignore b/infra/.gitignore new file mode 100644 index 0000000..c511586 --- /dev/null +++ b/infra/.gitignore @@ -0,0 +1 @@ +.pulumi/ diff --git a/infra/Pulumi.dev.yaml b/infra/Pulumi.dev.yaml new file mode 100644 index 0000000..0d12634 --- /dev/null +++ b/infra/Pulumi.dev.yaml @@ -0,0 +1,9 @@ +encryptionsalt: v1:W1NLKXoFxV4=:v1:S5quyyMBvgH4qAzD:tYNPw7qdVcUbCoFdkwH+URbo1ukeqQ== +config: + aws:region: us-west-2 + projectm-infra:bucketName: prjm + projectm-infra:cloudfrontPriceClass: PriceClass_100 + projectm-infra:githubOwner: projectM-visualizer + projectm-infra:githubRefs: + - refs/heads/master + projectm-infra:githubRepo: projectm-visualizer.org diff --git a/infra/Pulumi.yaml b/infra/Pulumi.yaml new file mode 100644 index 0000000..9c3060f --- /dev/null +++ b/infra/Pulumi.yaml @@ -0,0 +1,7 @@ +name: projectm-infra +runtime: + name: nodejs + options: + typescript: true +main: index.ts +description: Infrastructure for the ProjectM static site on AWS. diff --git a/infra/README.md b/infra/README.md new file mode 100644 index 0000000..d398325 --- /dev/null +++ b/infra/README.md @@ -0,0 +1,63 @@ +# Infrastructure + +Manage the static site infrastructure with Pulumi. + +## Prerequisites + +- Pulumi CLI +- AWS credentials with permissions to manage S3, CloudFront, ACM, Route53, and IAM + +## Setup + +1. Install dependencies: + ```bash + cd infra + npm install + ``` +2. Log into the shared S3 backend (only needs to be done once per environment): + ```bash + AWS_PROFILE=projectm pulumi login s3://pulumi-state-projectm + ``` +3. Create a stack (example `dev`) if it does not exist: + ```bash + pulumi stack init dev + ``` +4. Configure required values: + ```bash + pulumi config set bucketName prjm + pulumi config set githubOwner projectM-visualizer + pulumi config set githubRepo projectm-visualizer.org + pulumi config set githubRefs '["refs/heads/master"]' + pulumi config set aws:region your-app-region + ``` +5. Optional configuration: + - `cloudfrontPriceClass` (`PriceClass_100`, `PriceClass_200`, `PriceClass_All`) + - `primaryDomain` and `alternateDomains` to enable custom domains + - `hostedZoneId` to request an ACM certificate via DNS validation + - `certificateArn` to reuse an existing certificate instead of provisioning one + - `oidcProviderArn` to reference an existing GitHub OIDC provider + - `githubRoleName` to override the IAM role name + +6. Deploy: + ```bash + AWS_PROFILE=projectm PULUMI_CONFIG_PASSPHRASE=projectm pulumi up + ``` + +Outputs include the CloudFront distribution details and the IAM role ARN. + +### State + +State lives in `s3://pulumi-state-projectm` (versioned). Set `AWS_PROFILE=projectm` and `PULUMI_CONFIG_PASSPHRASE=projectm` when running Pulumi commands so AWS calls and encrypted config values work consistently. + +## GitHub Actions + +Set these repository secrets and variables before running the deployment workflow: + +- `AWS_ROLE_ARN` (secret): ARN of the IAM role exported by Pulumi. +- `GH_TOKEN` (secret): GitHub token with `repo` scope for `generate-reports`. +- `NUXT_PUBLIC_ASSET_KEY` (secret): Encryption key used by `generate-reports`. +- `vars.AWS_REGION`: AWS region for S3 operations (for example, `us-west-2`). +- `vars.S3_BUCKET`: Target S3 bucket name (`prjm`). +- `vars.CLOUDFRONT_DISTRIBUTION_ID`: Distribution ID exported by Pulumi. + +The workflow runs on pushes to `master` and can also be triggered manually. diff --git a/infra/index.ts b/infra/index.ts new file mode 100644 index 0000000..e1865cc --- /dev/null +++ b/infra/index.ts @@ -0,0 +1,296 @@ +import * as pulumi from '@pulumi/pulumi' +import * as aws from '@pulumi/aws' + +const cfg = new pulumi.Config() +const bucketName = cfg.require('bucketName') +const githubOwner = cfg.require('githubOwner') +const githubRepo = cfg.require('githubRepo') +const githubRefs = cfg.getObject('githubRefs') ?? ['refs/heads/main'] +const priceClass = cfg.get('cloudfrontPriceClass') ?? 'PriceClass_100' +const primaryDomain = cfg.get('primaryDomain') +const alternateDomains = cfg.getObject('alternateDomains') ?? [] +const hostedZoneId = cfg.get('hostedZoneId') +const certificateArnFromConfig = cfg.get('certificateArn') +const existingOidcProviderArn = cfg.get('oidcProviderArn') +const githubRoleName + = cfg.get('githubRoleName') + ?? `${pulumi.getProject()}-${pulumi.getStack()}-gha` + +const callerIdentity = pulumi.output(aws.getCallerIdentity({})) +const allDomains = primaryDomain + ? [primaryDomain, ...alternateDomains.filter(d => d !== primaryDomain)] + : [] + +const bucket = new aws.s3.BucketV2( + 'siteBucket', + { + bucket: bucketName + }, + { import: bucketName } +) + +const originAccessControl = new aws.cloudfront.OriginAccessControl('siteOac', { + name: `${pulumi.getProject()}-${pulumi.getStack()}-oac`, + description: 'Origin access control for the static site bucket', + originAccessControlOriginType: 's3', + signingBehavior: 'always', + signingProtocol: 'sigv4' +}) + +let certificateArn: pulumi.Output | undefined + +if (certificateArnFromConfig) { + certificateArn = pulumi.output(certificateArnFromConfig) +} else if (primaryDomain && hostedZoneId) { + const eastRegion = new aws.Provider('usEast1', { region: 'us-east-1' }) + const certificate = new aws.acm.Certificate( + 'siteCertificate', + { + domainName: primaryDomain, + validationMethod: 'DNS', + subjectAlternativeNames: alternateDomains + }, + { provider: eastRegion } + ) + + const validationRecords = certificate.domainValidationOptions.apply( + options => + options.map( + (option, index) => + new aws.route53.Record( + `siteCertValidation-${index}`, + { + zoneId: hostedZoneId, + name: option.resourceRecordName, + type: option.resourceRecordType, + records: [option.resourceRecordValue], + ttl: 60 + }, + { dependsOn: certificate } + ) + ) + ) + + const certificateValidation = new aws.acm.CertificateValidation( + 'siteCertificateValidation', + { + certificateArn: certificate.arn, + validationRecordFqdns: validationRecords.apply(records => + records.map(record => record.fqdn) + ) + }, + { provider: eastRegion } + ) + + certificateArn = certificateValidation.certificateArn +} else if (primaryDomain) { + throw new Error( + 'primaryDomain requires either certificateArn or hostedZoneId to request a certificate.' + ) +} + +const distributionAliases = certificateArn ? allDomains : [] + +const distribution = new aws.cloudfront.Distribution('siteDistribution', { + enabled: true, + isIpv6Enabled: true, + priceClass, + defaultRootObject: 'index.html', + origins: [ + { + originId: 'site-bucket-origin', + domainName: bucket.bucketRegionalDomainName, + originAccessControlId: originAccessControl.id + } + ], + defaultCacheBehavior: { + targetOriginId: 'site-bucket-origin', + viewerProtocolPolicy: 'redirect-to-https', + allowedMethods: ['GET', 'HEAD', 'OPTIONS'], + cachedMethods: ['GET', 'HEAD'], + compress: true, + cachePolicyId: '658327ea-f89d-4fab-a63d-7e88639e58f6', + originRequestPolicyId: 'b689b0a8-53d0-40ab-baf2-68738e2966ac' + }, + restrictions: { + geoRestriction: { + restrictionType: 'none' + } + }, + viewerCertificate: certificateArn + ? { + acmCertificateArn: certificateArn, + sslSupportMethod: 'sni-only', + minimumProtocolVersion: 'TLSv1.2_2021' + } + : { + cloudfrontDefaultCertificate: true + }, + aliases: distributionAliases, + httpVersion: 'http2and3', + customErrorResponses: [ + { + errorCode: 404, + responseCode: 200, + responsePagePath: '/index.html', + errorCachingMinTtl: 300 + }, + { + errorCode: 403, + responseCode: 200, + responsePagePath: '/index.html', + errorCachingMinTtl: 300 + } + ] +}) + +const bucketPolicy = pulumi + .all([bucket.arn, distribution.arn]) + .apply(([bucketArn, distributionArn]) => + JSON.stringify({ + Version: '2012-10-17', + Statement: [ + { + Sid: 'AllowCloudFrontServiceAccess', + Effect: 'Allow', + Principal: { + Service: 'cloudfront.amazonaws.com' + }, + Action: ['s3:GetObject'], + Resource: `${bucketArn}/*`, + Condition: { + StringEquals: { + 'AWS:SourceArn': distributionArn + } + } + } + ] + }) + ) + +new aws.s3.BucketPolicy('siteBucketPolicy', { + bucket: bucket.id, + policy: bucketPolicy +}) + +if (primaryDomain && hostedZoneId && certificateArn) { + const aliases = pulumi + .all([distribution.domainName, distribution.hostedZoneId]) + .apply(([domainName, zoneId]) => ({ + name: domainName, + zoneId + })) + + allDomains.forEach((domain, index) => { + new aws.route53.Record(`siteAliasA-${index}`, { + zoneId: hostedZoneId, + name: domain, + type: 'A', + aliases: [ + aliases.apply(alias => ({ + name: alias.name, + zoneId: alias.zoneId, + evaluateTargetHealth: false + })) + ] + }) + + new aws.route53.Record(`siteAliasAAAA-${index}`, { + zoneId: hostedZoneId, + name: domain, + type: 'AAAA', + aliases: [ + aliases.apply(alias => ({ + name: alias.name, + zoneId: alias.zoneId, + evaluateTargetHealth: false + })) + ] + }) + }) +} + +const oidcProvider = existingOidcProviderArn + ? aws.iam.OpenIdConnectProvider.get('githubProvider', existingOidcProviderArn) + : new aws.iam.OpenIdConnectProvider('githubProvider', { + url: 'https://token.actions.githubusercontent.com', + clientIdLists: ['sts.amazonaws.com'], + thumbprintLists: ['6938fd4d98bab03faadb97b34396831e3780aea1'] + }) + +const subjects = githubRefs.map( + ref => `repo:${githubOwner}/${githubRepo}:${ref}` +) + +const assumeRolePolicy = pulumi.all([oidcProvider.arn]).apply(([providerArn]) => + JSON.stringify({ + Version: '2012-10-17', + Statement: [ + { + Effect: 'Allow', + Principal: { + Federated: providerArn + }, + Action: 'sts:AssumeRoleWithWebIdentity', + Condition: { + StringEquals: { + 'token.actions.githubusercontent.com:aud': 'sts.amazonaws.com' + }, + StringLike: { + 'token.actions.githubusercontent.com:sub': + subjects.length === 1 ? subjects[0] : subjects + } + } + } + ] + }) +) + +const deploymentRole = new aws.iam.Role('githubActionsRole', { + name: githubRoleName, + assumeRolePolicy, + description: 'Role assumed by GitHub Actions for static site deployments.' +}) + +const bucketArn = pulumi.interpolate`arn:aws:s3:::${bucketName}` +const bucketObjectsArn = pulumi.interpolate`arn:aws:s3:::${bucketName}/*` +const distributionArn = pulumi.interpolate`arn:aws:cloudfront::${callerIdentity.accountId}:distribution/${distribution.id}` + +new aws.iam.RolePolicy('githubActionsPolicy', { + role: deploymentRole.id, + policy: pulumi + .all([bucketArn, bucketObjectsArn, distributionArn]) + .apply(([arn, objectsArn, distArn]) => + JSON.stringify({ + Version: '2012-10-17', + Statement: [ + { + Effect: 'Allow', + Action: [ + 's3:PutObject', + 's3:PutObjectAcl', + 's3:DeleteObject', + 's3:GetObject' + ], + Resource: objectsArn + }, + { + Effect: 'Allow', + Action: ['s3:ListBucket', 's3:GetBucketLocation'], + Resource: arn + }, + { + Effect: 'Allow', + Action: ['cloudfront:CreateInvalidation'], + Resource: distArn + } + ] + }) + ) +}) + +export const siteBucketName = bucket.bucket +export const cloudfrontDistributionId = distribution.id +export const cloudfrontDistributionDomain = distribution.domainName +export const githubActionsRoleArn = deploymentRole.arn +export const acmCertificateArn = certificateArn ?? null diff --git a/infra/package.json b/infra/package.json new file mode 100644 index 0000000..b2b7914 --- /dev/null +++ b/infra/package.json @@ -0,0 +1,21 @@ +{ + "name": "projectm-infra", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "build": "tsc --noEmit", + "lint": "tsc --noEmit", + "prettier": "prettier --check \"src/**/*.ts\"" + }, + "dependencies": { + "@pulumi/aws": "^6.28.0", + "@pulumi/pulumi": "^3.149.0" + }, + "devDependencies": { + "@types/node": "^20.17.10", + "prettier": "^3.4.2", + "ts-node": "^10.9.2", + "typescript": "^5.8.3" + } +} diff --git a/infra/tsconfig.json b/infra/tsconfig.json new file mode 100644 index 0000000..6d12a52 --- /dev/null +++ b/infra/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ES2020", + "moduleResolution": "node", + "esModuleInterop": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true, + "outDir": "bin", + "rootDir": "." + }, + "include": [ + "**/*.ts" + ], + "exclude": [ + "node_modules", + "bin" + ] +} From ea24facd745fce2f57a54254ab3eacbab6343b34 Mon Sep 17 00:00:00 2001 From: Mischa Spiegelmock Date: Sun, 19 Oct 2025 21:22:00 -0700 Subject: [PATCH 2/4] fix: stabilize preview deployment flow --- .github/workflows/deploy.yml | 5 +++++ infra/Pulumi.dev.yaml | 3 +-- infra/README.md | 12 ++++++++---- infra/index.ts | 25 +++++++++++++++++++++++-- 4 files changed, 37 insertions(+), 8 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 84cb10b..c941767 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -15,6 +15,7 @@ env: AWS_REGION: ${{ vars.AWS_REGION }} S3_BUCKET: ${{ vars.S3_BUCKET }} CLOUDFRONT_DISTRIBUTION_ID: ${{ vars.CLOUDFRONT_DISTRIBUTION_ID }} + NUXT_PUBLIC_SITE_URL: ${{ github.ref == 'refs/heads/master' && secrets.NUXT_PUBLIC_SITE_URL || vars.PREVIEW_SITE_URL }} GH_TOKEN: ${{ secrets.GH_TOKEN }} NUXT_PUBLIC_ASSET_KEY: ${{ secrets.NUXT_PUBLIC_ASSET_KEY }} @@ -33,6 +34,10 @@ jobs: - name: Install dependencies run: bun install --frozen-lockfile + - name: Show site URL + if: github.ref != 'refs/heads/master' + run: echo "NUXT_PUBLIC_SITE_URL=${NUXT_PUBLIC_SITE_URL}" + - name: Generate reports run: bun run generate-reports diff --git a/infra/Pulumi.dev.yaml b/infra/Pulumi.dev.yaml index 0d12634..968609b 100644 --- a/infra/Pulumi.dev.yaml +++ b/infra/Pulumi.dev.yaml @@ -4,6 +4,5 @@ config: projectm-infra:bucketName: prjm projectm-infra:cloudfrontPriceClass: PriceClass_100 projectm-infra:githubOwner: projectM-visualizer - projectm-infra:githubRefs: - - refs/heads/master + projectm-infra:githubRefs: '["ref:refs/heads/*","ref:refs/tags/*","ref:refs/pull/*"]' projectm-infra:githubRepo: projectm-visualizer.org diff --git a/infra/README.md b/infra/README.md index d398325..fa6a9ce 100644 --- a/infra/README.md +++ b/infra/README.md @@ -25,10 +25,12 @@ Manage the static site infrastructure with Pulumi. 4. Configure required values: ```bash pulumi config set bucketName prjm - pulumi config set githubOwner projectM-visualizer - pulumi config set githubRepo projectm-visualizer.org - pulumi config set githubRefs '["refs/heads/master"]' - pulumi config set aws:region your-app-region + pulumi config set githubOwner projectM-visualizer + pulumi config set githubRepo projectm-visualizer.org + pulumi config set githubRefs '["ref:refs/heads/master"]' + # Allow additional refs as needed, for example: + pulumi config set githubRefs '["ref:refs/heads/master","ref:refs/heads/*","ref:refs/tags/*","ref:refs/pull/*"]' + pulumi config set aws:region your-app-region ``` 5. Optional configuration: - `cloudfrontPriceClass` (`PriceClass_100`, `PriceClass_200`, `PriceClass_All`) @@ -56,6 +58,8 @@ Set these repository secrets and variables before running the deployment workflo - `AWS_ROLE_ARN` (secret): ARN of the IAM role exported by Pulumi. - `GH_TOKEN` (secret): GitHub token with `repo` scope for `generate-reports`. - `NUXT_PUBLIC_ASSET_KEY` (secret): Encryption key used by `generate-reports`. +- `vars.PREVIEW_SITE_URL`: CloudFront preview URL (for example, `https://d15wenzbsa5dzp.cloudfront.net`). +- `secrets.NUXT_PUBLIC_SITE_URL`: Production URL used on the `master` branch (for example, `https://projectm-visualizer.org`). - `vars.AWS_REGION`: AWS region for S3 operations (for example, `us-west-2`). - `vars.S3_BUCKET`: Target S3 bucket name (`prjm`). - `vars.CLOUDFRONT_DISTRIBUTION_ID`: Distribution ID exported by Pulumi. diff --git a/infra/index.ts b/infra/index.ts index e1865cc..fb28979 100644 --- a/infra/index.ts +++ b/infra/index.ts @@ -218,9 +218,30 @@ const oidcProvider = existingOidcProviderArn thumbprintLists: ['6938fd4d98bab03faadb97b34396831e3780aea1'] }) -const subjects = githubRefs.map( - ref => `repo:${githubOwner}/${githubRepo}:${ref}` +const normalizeRef = (ref: string) => { + if (ref.startsWith('ref:')) return ref + if (ref.startsWith(':')) return `ref${ref}` + return `ref:${ref}` +} + +const ownerCandidates = Array.from( + new Set([githubOwner, githubOwner.toLowerCase()]) ) +const repoCandidates = Array.from( + new Set([githubRepo, githubRepo.toLowerCase()]) +) + +const subjectSet = new Set() +for (const owner of ownerCandidates) { + for (const repo of repoCandidates) { + subjectSet.add(`repo:${owner}/${repo}:*`) + for (const ref of githubRefs) { + subjectSet.add(`repo:${owner}/${repo}:${normalizeRef(ref)}`) + } + } +} + +const subjects = Array.from(subjectSet) const assumeRolePolicy = pulumi.all([oidcProvider.arn]).apply(([providerArn]) => JSON.stringify({ From 096999ebcc447298e1ca3e73460b16323486884f Mon Sep 17 00:00:00 2001 From: Mischa Spiegelmock Date: Sun, 19 Oct 2025 21:33:02 -0700 Subject: [PATCH 3/4] chore: enable CloudFront access logging --- infra/README.md | 12 +++ infra/package.json | 2 + infra/scripts/enable-cloudfront-logging.mjs | 114 ++++++++++++++++++++ 3 files changed, 128 insertions(+) create mode 100644 infra/scripts/enable-cloudfront-logging.mjs diff --git a/infra/README.md b/infra/README.md index fa6a9ce..980c2d9 100644 --- a/infra/README.md +++ b/infra/README.md @@ -64,4 +64,16 @@ Set these repository secrets and variables before running the deployment workflo - `vars.S3_BUCKET`: Target S3 bucket name (`prjm`). - `vars.CLOUDFRONT_DISTRIBUTION_ID`: Distribution ID exported by Pulumi. +### CloudFront Access Logging + +Provision the log bucket and enable CloudFront logging with the helper script (run once per environment): + +```bash +cd infra +AWS_PROFILE=projectm AWS_SDK_LOAD_CONFIG=1 node scripts/enable-cloudfront-logging.mjs +``` + +By default this creates `projectm-visualizer-cloudfront-logs` in `us-west-2` and configures the distribution to write compressed logs (cookies included) under the `cloudfront/` prefix. + + The workflow runs on pushes to `master` and can also be triggered manually. diff --git a/infra/package.json b/infra/package.json index b2b7914..c2654dd 100644 --- a/infra/package.json +++ b/infra/package.json @@ -9,6 +9,8 @@ "prettier": "prettier --check \"src/**/*.ts\"" }, "dependencies": { + "@aws-sdk/client-cloudfront": "^3.913.0", + "@aws-sdk/client-s3": "^3.913.0", "@pulumi/aws": "^6.28.0", "@pulumi/pulumi": "^3.149.0" }, diff --git a/infra/scripts/enable-cloudfront-logging.mjs b/infra/scripts/enable-cloudfront-logging.mjs new file mode 100644 index 0000000..c9a29a6 --- /dev/null +++ b/infra/scripts/enable-cloudfront-logging.mjs @@ -0,0 +1,114 @@ +import { + CloudFrontClient, + GetDistributionCommand, + UpdateDistributionCommand +} from '@aws-sdk/client-cloudfront' +import { + S3Client, + CreateBucketCommand, + PutBucketOwnershipControlsCommand, + PutBucketAclCommand, + PutBucketPolicyCommand +} from '@aws-sdk/client-s3' + +const region = process.env.AWS_REGION || 'us-west-2' +const accountId = '533267091967' +const distributionId = process.env.CF_DISTRIBUTION_ID || 'E1DLLQU8OGUHTK' +const logsBucket = process.env.CF_LOG_BUCKET || 'projectm-visualizer-cloudfront-logs' +const logsPrefix = process.env.CF_LOG_PREFIX || 'cloudfront/' + +const s3 = new S3Client({ region }) +const cf = new CloudFrontClient({ region }) + +async function ensureBucket() { + try { + await s3.send(new CreateBucketCommand({ + Bucket: logsBucket, + CreateBucketConfiguration: { LocationConstraint: region } + })) + console.log(`Created S3 bucket ${logsBucket}`) + } catch (error) { + if (error.name === 'BucketAlreadyOwnedByYou' || error.$metadata?.httpStatusCode === 409) { + console.log(`Bucket ${logsBucket} already exists`) + } else { + throw error + } + } + + try { + await s3.send(new PutBucketOwnershipControlsCommand({ + Bucket: logsBucket, + OwnershipControls: { + Rules: [{ ObjectOwnership: 'ObjectWriter' }] + } + })) + console.log('Set bucket ownership to ObjectWriter (enables ACLs)') + } catch (error) { + if (error.name !== 'OwnershipControlsNotFoundError') { + console.warn('Unable to set ownership controls:', error.message) + } + } + + try { + await s3.send(new PutBucketAclCommand({ + Bucket: logsBucket, + ACL: 'log-delivery-write' + })) + console.log('Applied log-delivery-write ACL') + } catch (error) { + console.warn('Skipping ACL update:', error.message) + } + + const policy = { + Version: '2012-10-17', + Statement: [ + { + Effect: 'Allow', + Principal: { Service: 'cloudfront.amazonaws.com' }, + Action: 's3:PutObject', + Resource: `arn:aws:s3:::${logsBucket}/${logsPrefix}*`, + Condition: { + StringEquals: { + 'AWS:SourceArn': `arn:aws:cloudfront::${accountId}:distribution/${distributionId}` + } + } + } + ] + } + + await s3.send(new PutBucketPolicyCommand({ + Bucket: logsBucket, + Policy: JSON.stringify(policy) + })) + console.log(`Bucket policy set on ${logsBucket}`) +} + +async function enableLogging() { + await ensureBucket() + + const { Distribution, ETag } = await cf.send(new GetDistributionCommand({ Id: distributionId })) + if (!Distribution?.DistributionConfig) { + throw new Error('Distribution configuration not found') + } + + const config = Distribution.DistributionConfig + config.Logging = { + Enabled: true, + IncludeCookies: true, + Bucket: `${logsBucket}.s3.amazonaws.com`, + Prefix: logsPrefix + } + + await cf.send(new UpdateDistributionCommand({ + Id: distributionId, + IfMatch: ETag, + DistributionConfig: config + })) + + console.log('CloudFront logging enabled for distribution', distributionId) +} + +enableLogging().catch((err) => { + console.error('Failed to enable CloudFront logging:', err) + process.exit(1) +}) From f9a850cd46968ef1340c65ca55444329c8c6a035 Mon Sep 17 00:00:00 2001 From: Mischa Spiegelmock Date: Tue, 21 Oct 2025 19:25:39 -0700 Subject: [PATCH 4/4] feat: configure production domain and certificate --- infra/Pulumi.dev.yaml | 2 ++ infra/index.ts | 44 ++++++++++++++++++++++++++++++++++++------- 2 files changed, 39 insertions(+), 7 deletions(-) diff --git a/infra/Pulumi.dev.yaml b/infra/Pulumi.dev.yaml index 968609b..60dab9d 100644 --- a/infra/Pulumi.dev.yaml +++ b/infra/Pulumi.dev.yaml @@ -6,3 +6,5 @@ config: projectm-infra:githubOwner: projectM-visualizer projectm-infra:githubRefs: '["ref:refs/heads/*","ref:refs/tags/*","ref:refs/pull/*"]' projectm-infra:githubRepo: projectm-visualizer.org + projectm-infra:primaryDomain: projectm-visualizer.org + projectm-infra:hostedZoneId: Z0970442162ZXM1J1N8GK diff --git a/infra/index.ts b/infra/index.ts index fb28979..30b8293 100644 --- a/infra/index.ts +++ b/infra/index.ts @@ -48,14 +48,36 @@ if (certificateArnFromConfig) { { domainName: primaryDomain, validationMethod: 'DNS', - subjectAlternativeNames: alternateDomains + subjectAlternativeNames: alternateDomains, + validationOptions: [ + { + domainName: primaryDomain, + validationDomain: primaryDomain + }, + ...alternateDomains.map(domain => ({ + domainName: domain, + validationDomain: domain + })) + ] }, - { provider: eastRegion } + { + provider: eastRegion, + customTimeouts: { + create: '20m', + delete: '5m' + } + } ) - const validationRecords = certificate.domainValidationOptions.apply( - options => - options.map( + const validationRecords = certificate.domainValidationOptions.apply(options => + options + .filter( + option => + option.resourceRecordName + && option.resourceRecordType + && option.resourceRecordValue + ) + .map( (option, index) => new aws.route53.Record( `siteCertValidation-${index}`, @@ -64,7 +86,8 @@ if (certificateArnFromConfig) { name: option.resourceRecordName, type: option.resourceRecordType, records: [option.resourceRecordValue], - ttl: 60 + ttl: 60, + allowOverwrite: true }, { dependsOn: certificate } ) @@ -79,7 +102,14 @@ if (certificateArnFromConfig) { records.map(record => record.fqdn) ) }, - { provider: eastRegion } + { + provider: eastRegion, + customTimeouts: { + create: '20m', + update: '20m', + delete: '5m' + } + } ) certificateArn = certificateValidation.certificateArn