diff --git a/.github/workflows/bootstrap_bazel_remote_cache.sh b/.github/workflows/bootstrap_bazel_remote_cache.sh new file mode 100755 index 000000000..96b3e7ef8 --- /dev/null +++ b/.github/workflows/bootstrap_bazel_remote_cache.sh @@ -0,0 +1,15 @@ +#! /usr/bin/env bash + +echo "::group::Configure Bazel Remote Cache" + +if [[ -z "${BAZEL_REMOTE_CACHE_URL}" ]]; then + echo "::warning file=.github/workflows/bootstrap_bazel_remote_cache.sh,line=3,endLine=3,title=Running without bazel cache!:: BAZEL_REMOTE_CACHE_URL was not specified, so tests will be doing all the work from scratch." +else + echo "Using bazel remote cache." + echo "build --remote_cache=${BAZEL_REMOTE_CACHE_URL}" > .bazelrc + echo "test --remote_cache=${BAZEL_REMOTE_CACHE_URL}" > .bazelrc +fi + +echo "::endgroup::" + +exit 0 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 29c24e885..13b1b7fac 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -96,22 +96,12 @@ jobs: large-packages: false - name: Checkout code uses: actions/checkout@v3 - # example copied from: - # https://github.com/actions/cache/blob/04f198bf0b2a39f7230a4304bf07747a0bddf146/examples.md - - name: Cache Bazel - uses: actions/cache@v3 - with: - path: | - ~/.cache/bazel - key: > - ${{ runner.os }}-bazel-${{ - hashFiles('.bazelversion', '.bazelrc', 'WORKSPACE', - 'WORKSPACE.bazel', 'MODULE.bazel') }} - restore-keys: | - ${{ runner.os }}-bazel- - name: Presubmit run: | + ./.github/workflows/bootstrap_bazel_remote_cache.sh bazel run //ci:presubmit -- --skip-pulumi-deploy + env: + BAZEL_REMOTE_CACHE_URL: ${{ secrets.BAZEL_REMOTE_CACHE_URL }} Staging: # Pulumi doesn't like it when multiple deploys are attempted at once. # This is also enforced at the pulumi layer, but i'm sure github actions @@ -134,19 +124,6 @@ jobs: docker-images: true large-packages: false swap-storage: true - # example copied from: - # https://github.com/actions/cache/blob/04f198bf0b2a39f7230a4304bf07747a0bddf146/examples.md - - name: Cache Bazel - uses: actions/cache@v3 - with: - path: | - ~/.cache/bazel - key: > - ${{ runner.os }}-bazel-${{ - hashFiles('.bazelversion', '.bazelrc', 'WORKSPACE', - 'WORKSPACE.bazel', 'MODULE.bazel') }} - restore-keys: | - ${{ runner.os }}-bazel- # in order to determine if applying this patch would succeed # *on the mainline branch* # we have to set the Pulumi state to be the same. @@ -163,11 +140,13 @@ jobs: AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} PULUMI_ACCESS_TOKEN: ${{ secrets.PULUMI_SECRET }} + BAZEL_REMOTE_CACHE_URL: ${{ secrets.BAZEL_REMOTE_CACHE_URL }} - name: Switch back to candidate branch uses: actions/checkout@v3 - name: Deploy candidate branch to Staging # we can run this dirty since the next run will --overwrite anyway run: | + ./.github/workflows/bootstrap_bazel_remote_cache.sh bazel run //ci:presubmit -- \ --skip-bazel-tests \ --dangerously-skip-pnpm-lockfile-validation --dirty @@ -175,6 +154,7 @@ jobs: AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} PULUMI_ACCESS_TOKEN: ${{ secrets.PULUMI_SECRET }} + BAZEL_REMOTE_CACHE_URL: ${{ secrets.BAZEL_REMOTE_CACHE_URL }} Submit: concurrency: pulumi_production if: github.event_name == 'push' @@ -192,23 +172,11 @@ jobs: large-packages: false - name: Checkout code uses: actions/checkout@v3 - # example copied from: - # https://github.com/actions/cache/blob/04f198bf0b2a39f7230a4304bf07747a0bddf146/examples.md - - name: Cache Bazel - uses: actions/cache@v3 - with: - path: | - ~/.cache/bazel - key: > - ${{ runner.os }}-bazel-${{ - hashFiles('.bazelversion', '.bazelrc', 'WORKSPACE', - 'WORKSPACE.bazel', 'MODULE.bazel') }} - restore-keys: | - ${{ runner.os }}-bazel- - name: Submit # Use npx to try to generate only # bazel generated node_modules run: | + ./.github/workflows/bootstrap_bazel_remote_cache.sh bazel run //ci:submit env: NPM_TOKEN: ${{ secrets.NPM_TOKEN }} @@ -217,6 +185,7 @@ jobs: AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} PULUMI_ACCESS_TOKEN: ${{ secrets.PULUMI_SECRET }} + BAZEL_REMOTE_CACHE_URL: ${{ secrets.BAZEL_REMOTE_CACHE_URL }} Postsubmit: runs-on: ubuntu-latest if: github.event_name == 'push' @@ -233,21 +202,9 @@ jobs: swap-storage: true - name: Checkout code uses: actions/checkout@v3 - # example copied from: - # https://github.com/actions/cache/blob/04f198bf0b2a39f7230a4304bf07747a0bddf146/examples.md - - name: Cache Bazel - uses: actions/cache@v3 - with: - path: | - ~/.cache/bazel - key: > - ${{ runner.os }}-bazel-${{ - hashFiles('.bazelversion', '.bazelrc', 'WORKSPACE', - 'WORKSPACE.bazel', 'MODULE.bazel') }} - restore-keys: | - ${{ runner.os }}-bazel- - name: Postsubmit run: | + ./.github/workflows/bootstrap_bazel_remote_cache.sh bazel run //ci:postsubmit env: NPM_TOKEN: ${{ secrets.NPM_TOKEN }} @@ -256,3 +213,4 @@ jobs: AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} PULUMI_ACCESS_TOKEN: ${{ secrets.PULUMI_SECRET }} + BAZEL_REMOTE_CACHE_URL: ${{ secrets.BAZEL_REMOTE_CACHE_URL }} diff --git a/ci/presubmit.ts b/ci/presubmit.ts index 70641c1b0..38548ab69 100644 --- a/ci/presubmit.ts +++ b/ci/presubmit.ts @@ -132,7 +132,39 @@ const cmd = new Command('presubmit') } }); +function logError(e: unknown) { + if (!(e instanceof Error)) return console.log(e); + + const stack = e.stack ?? Error.prototype.stack; + + if (!stack) return console.log(e); + + const runfilesRoot = process.env['TEST_SRCDIR']; + + if (!runfilesRoot) return console.log(e); + + const runfilesRootIndex = stack.indexOf(runfilesRoot); + + if (runfilesRootIndex == -1) return console.log(e); + + const suffix = stack.slice(runfilesRootIndex + runfilesRoot.length); + + const res = /^([A-Za-z0-9/._]+)\.(?:ts|js):(\d+):(\d+)/.exec(suffix); + + if (res === null) return console.log(e); + + const [, filePrefix, line, offset] = res; + + console.error( + WorkflowCommand('error')({ + file: filePrefix, + line: line, + col: offset, + })('' + e) + ); +} + cmd.parseAsync(process.argv).catch(e => { process.exitCode = 2; - console.error(e); + logError(e); }); diff --git a/package.json b/package.json index 93af0e15e..1e593dd5e 100644 --- a/package.json +++ b/package.json @@ -105,8 +105,11 @@ "dependencies": { "@commander-js/extra-typings": "^11.0.0", "@pulumi/command": "4.5.0", + "@pulumi/github": "^5.17.0", + "@pulumi/random": "^4.13.4", "@react-spring/rafz": "^9.7.3", "@types/bcryptjs": "2.4.3", + "aws-sdk": "^2.1459.0", "csstype": "^3.1.1", "devtools-protocol": "^0.0.1193409", "eslint-mdx": "^2.1.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 38e41c833..0b6bbbeb0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -16,12 +16,21 @@ dependencies: '@pulumi/command': specifier: 4.5.0 version: 4.5.0 + '@pulumi/github': + specifier: ^5.17.0 + version: 5.17.0 + '@pulumi/random': + specifier: ^4.13.4 + version: 4.13.4 '@react-spring/rafz': specifier: ^9.7.3 version: 9.7.3 '@types/bcryptjs': specifier: 2.4.3 version: 2.4.3 + aws-sdk: + specifier: ^2.1459.0 + version: 2.1459.0 csstype: specifier: ^3.1.1 version: 3.1.2 @@ -4327,7 +4336,7 @@ packages: requiresBuild: true dependencies: '@pulumi/pulumi': 3.81.0 - aws-sdk: 2.1414.0 + aws-sdk: 2.1459.0 builtin-modules: 3.0.0 mime: 2.6.0 read-package-tree: 5.3.1 @@ -4382,6 +4391,15 @@ packages: - supports-color dev: true + /@pulumi/github@5.17.0: + resolution: {integrity: sha512-6WPBoknTvQOzTeFNX1A3kqppkFwyDXaxhFIShKUlfhCU06FW3u510mD2p0mErRJ1Y6zG9+KBVKm570f0gRfHMg==} + requiresBuild: true + dependencies: + '@pulumi/pulumi': 3.81.0 + transitivePeerDependencies: + - supports-color + dev: false + /@pulumi/pulumi@3.81.0: resolution: {integrity: sha512-zqgRd7s7ETJLvkzzIv9Qi+pS3HwXivVWQDNjxpNOvORrbsQDIW+QTzv22i0rG00lpEm7R5jMQAaFqrsGRuwOUw==} engines: {node: '>=8.13.0 || >=10.10.0'} @@ -4417,6 +4435,14 @@ packages: /@pulumi/query@0.3.0: resolution: {integrity: sha512-xfo+yLRM2zVjVEA4p23IjQWzyWl1ZhWOGobsBqRpIarzLvwNH/RAGaoehdxlhx4X92302DrpdIFgTICMN4P38w==} + /@pulumi/random@4.13.4: + resolution: {integrity: sha512-PvmBma+/sFWh4NFXyViXDu01ZNvhoWLVHMIJRCXBjWcyzX6L2NPt5BqsHn/afiCESd5Q4tZqj3KZn6z5WS/2iQ==} + dependencies: + '@pulumi/pulumi': 3.81.0 + transitivePeerDependencies: + - supports-color + dev: false + /@puppeteer/browsers@1.7.0: resolution: {integrity: sha512-sl7zI0IkbQGak/+IE3VEEZab5SSOlI5F6558WvzWGC1n3+C722rfewC1ZIkcF9dsoGSsxhsONoseVlNQG4wWvQ==} engines: {node: '>=16.3.0'} @@ -6674,8 +6700,8 @@ packages: resolution: {integrity: sha512-DMD0KiN46eipeziST1LPP/STfDU0sufISXmjSgvVsoU2tqxctQeASejWcfNtxYKqETM1UxQ8sp2OrSBWpHY6sw==} engines: {node: '>= 0.4'} - /aws-sdk@2.1414.0: - resolution: {integrity: sha512-WhqTWiTZRUxWITvUG5VMPYGdCLNAm4zOTDIiotbErR9x+uDExk2CAGbXE8HH11+tD8PhZVXyukymSiG+7rJMMg==} + /aws-sdk@2.1459.0: + resolution: {integrity: sha512-My45PgQYhRTh6fOeZ94ELUoXzza/6gTy0J22aK4iy0DEA+uE5gjr1VthnIwbLYNMeEqn8xwJZuNJqvi/WaUUcQ==} engines: {node: '>= 10.0.0'} dependencies: buffer: 4.9.2 @@ -6688,7 +6714,6 @@ packages: util: 0.12.5 uuid: 8.0.0 xml2js: 0.5.0 - dev: true /aws4@1.12.0: resolution: {integrity: sha512-NmWvPnx0F1SfrQbYwOi7OeaNGokp9XhzNioJ/CSBs8Qa4vxug81mhJEAVZwxXuBmYB5KDRfMq/F3RR0BIU7sWg==} @@ -6900,7 +6925,6 @@ packages: /base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} - dev: true /basic-auth@2.0.1: resolution: {integrity: sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg==} @@ -7028,7 +7052,6 @@ packages: base64-js: 1.5.1 ieee754: 1.2.1 isarray: 1.0.0 - dev: true /buffer@5.7.1: resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} @@ -8935,7 +8958,6 @@ packages: /events@1.1.1: resolution: {integrity: sha512-kEcvvCBByWXGnZy6JUlgAp2gBIUjfCAV6P6TgT1/aaQKcmuAEC4OZTV1I4EWQLz2gxZw76atuVyvHhTxvi0Flw==} engines: {node: '>=0.4.x'} - dev: true /execa@0.7.0: resolution: {integrity: sha512-RztN09XglpYI7aBBrJCPW95jEH7YF1UEPOoX9yDhUTPdp7mK+CQvnLTuD10BNXZ3byLTu2uehZ8EcKT/4CGiFw==} @@ -10070,11 +10092,9 @@ packages: /ieee754@1.1.13: resolution: {integrity: sha512-4vf7I2LYV/HaWerSo3XmlMkp5eZ83i+/CDluXi/IGTs/O1sejBNhTtnxzmRZfvOUqj7lZjqHkeTvpgSFDlWZTg==} - dev: true /ieee754@1.2.1: resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} - dev: true /ignore@5.2.4: resolution: {integrity: sha512-MAb38BcSbH0eHNBxn7ql2NH/kX33OkB3lZ1BNdh7ENeRChHTYsTvWrMubiIAMNS2llXEEgZ1MUOBtXChP3kaFQ==} @@ -10252,7 +10272,6 @@ packages: dependencies: call-bind: 1.0.2 has-tostringtag: 1.0.0 - dev: true /is-array-buffer@3.0.2: resolution: {integrity: sha512-y+FyyR/w8vfIRq4eQcM1EYgSTnmHXPqaF+IgzgraytCFq5Xh8lllDVmAZolPJiZttZLeFSINPYMaEJ7/vWUa1w==} @@ -10361,7 +10380,6 @@ packages: engines: {node: '>= 0.4'} dependencies: has-tostringtag: 1.0.0 - dev: true /is-glob@4.0.3: resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} @@ -10569,7 +10587,6 @@ packages: /isarray@1.0.0: resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} - dev: true /isarray@2.0.5: resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==} @@ -11135,7 +11152,6 @@ packages: /jmespath@0.16.0: resolution: {integrity: sha512-9FzQjJ7MATs1tSpnco1K6ayiYE3figslrXA72G2HQ/n76RzvYlofyi5QM+iX4YRs/pu3yzxlVQSST23+dMDknw==} engines: {node: '>= 0.6.0'} - dev: true /joi@17.9.2: resolution: {integrity: sha512-Itk/r+V4Dx0V3c7RLFdRh12IOjySm2/WGPMubBT92cQvRfYZhPM2W0hZlctjj72iES8jsRCwp7S/cRmWBnJ4nw==} @@ -14194,7 +14210,6 @@ packages: /punycode@1.3.2: resolution: {integrity: sha512-RofWgt/7fL5wP1Y7fxE7/EmTLzQVnB0ycyibJ0OOHIlJqTNzglYFxVwETOcIoJqJmpDXJ9xImDv+Fq34F/d4Dw==} - dev: true /punycode@2.3.0: resolution: {integrity: sha512-rRV+zQD8tVFys26lAGR9WUuS4iUAngJScM+ZRSKtvl5tKeZ2t5bvdNFdNHBW9FWR4guGHlgmsZ1G7BSm2wTbuA==} @@ -14247,7 +14262,6 @@ packages: resolution: {integrity: sha512-X/xY82scca2tau62i9mDyU9K+I+djTMUsvwf7xnUX5GLvVzgJybOJf4Y6o9Zx3oJK/LSXg5tTZBjwzqVPaPO2g==} engines: {node: '>=0.4.x'} deprecated: The querystring API is considered Legacy. new code should use the URLSearchParams API instead. - dev: true /querystringify@2.2.0: resolution: {integrity: sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==} @@ -15184,11 +15198,9 @@ packages: /sax@1.2.1: resolution: {integrity: sha512-8I2a3LovHTOpm7NV5yOyO8IHqgVsfK4+UuySrXU8YXkSRX7k6hCV9b3HrkKCr3nMpgj+0bmocaJJWpvp1oc7ZA==} - dev: true /sax@1.2.4: resolution: {integrity: sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw==} - dev: true /saxes@6.0.0: resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} @@ -16807,7 +16819,6 @@ packages: dependencies: punycode: 1.3.2 querystring: 0.2.0 - dev: true /use-sync-external-store@1.2.0(react@18.2.0): resolution: {integrity: sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA==} @@ -16833,7 +16844,6 @@ packages: is-generator-function: 1.0.10 is-typed-array: 1.1.10 which-typed-array: 1.1.10 - dev: true /utils-merge@1.0.1: resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==} @@ -16843,7 +16853,6 @@ packages: /uuid@8.0.0: resolution: {integrity: sha512-jOXGuXZAWdsTH7eZLtyXMqUb9EcWMGZNbL9YcGBJl4MH4nrxHmZJhEHvyLFrkxo+28uLb/NYRcStH48fnD0Vzw==} hasBin: true - dev: true /uuid@8.3.2: resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==} @@ -17252,12 +17261,10 @@ packages: dependencies: sax: 1.2.4 xmlbuilder: 11.0.1 - dev: true /xmlbuilder@11.0.1: resolution: {integrity: sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==} engines: {node: '>=4.0'} - dev: true /xmlchars@2.2.0: resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} diff --git a/ts/github/actions/index.ts b/ts/github/actions/index.ts index c8fc9f8ec..4dfc3e0fd 100644 --- a/ts/github/actions/index.ts +++ b/ts/github/actions/index.ts @@ -5,6 +5,8 @@ interface FilePositionParams { line?: string; endLine?: string; title?: string; + col?: string; + endColumn?: string; } interface CommandValidation { @@ -18,6 +20,13 @@ interface CommandValidation { const isDefinedString = (v: string | undefined): v is string => v !== undefined; +// https://github.com/actions/toolkit/blob/7b617c260dff86f8d044d5ab0425444b29fa0d18/packages/core/src/command.ts#L80-L85 +const escapeCommandValue = (s: string) => + s.replace(/%/g, '%25').replace(/\r/g, '%0D').replace(/\n/g, '%0A'); + +const escapeKeyValue = (s: string) => + escapeCommandValue(s).replace(/=/g, '%3D'); + const commandIdent = (command: T) => (parameters: CommandValidation[T]) => @@ -27,13 +36,7 @@ const commandIdent = `::${[ command, Object.entries(parameters) - .map( - ([k, v]) => - `${k.replaceAll(/[,=]/g, '')}=${v.replaceAll( - /[=,]/g, - '' - )}` - ) + .map(([k, v]) => `${escapeKeyValue(k)}=${escapeKeyValue(v)}`) .join(','), ] .filter(isDefinedString) @@ -43,7 +46,9 @@ export const Command = (command: T) => (parameters: CommandValidation[T]) => (line?: string) => - (line ?? '').replaceAll(/^/g, commandIdent(command)(parameters)); + `${commandIdent(command)(parameters)}${ + line ? escapeCommandValue(line) : '' + }`; export const Summarize = async (summary: string) => { const step_summary_file_path = process.env['GITHUB_STEP_SUMMARY']; diff --git a/ts/pulumi/BUILD.bazel b/ts/pulumi/BUILD.bazel index 1b21151ca..a1fd8b71d 100644 --- a/ts/pulumi/BUILD.bazel +++ b/ts/pulumi/BUILD.bazel @@ -15,7 +15,11 @@ ts_project( "//:node_modules/@types/jest", "//:node_modules/cross-spawn", "//:node_modules/@pulumi/pulumi", + "//:node_modules/@pulumi/awsx", + "//:node_modules/@pulumi/github", + "//:node_modules/@pulumi/random", "//:node_modules/@pulumi/aws", + "//:node_modules/aws-sdk", "//ts/pulumi/pleaseintroducemetoyour.dog:ts", "//ts/pulumi/lib", "//ts/github/actions" diff --git a/ts/pulumi/bazel_rce/index.ts b/ts/pulumi/bazel_rce/index.ts new file mode 100644 index 000000000..556083db4 --- /dev/null +++ b/ts/pulumi/bazel_rce/index.ts @@ -0,0 +1,457 @@ +/** + * @fileoverview Provisions a bazel remote caching server + * @see https://bazel.build/remote/caching + */ +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; + +import * as aws from '@pulumi/aws'; +import * as awsx from '@pulumi/awsx'; +import * as GitHub from '@pulumi/github'; +import * as Pulumi from '@pulumi/pulumi'; +import * as random from '@pulumi/random'; +import * as Cert from 'ts/pulumi/lib/certificate'; + +export interface Args { + /** + * The zone to deploy to + */ + zoneId: Pulumi.Input; + + domain: string; +} + +const deriveAWSRestrictedName = + (restriction: RegExp) => + (maximumLength: number) => + (uncontrolledSuffixLength: number) => + (suffix: string) => + (input: string) => + [...input.replaceAll(restriction, '-')] + .slice(0, maximumLength - suffix.length - uncontrolledSuffixLength) + .join('') + suffix; + +// bucket has a maximum length of 63 (56 minus the 7 random chars that Pulumi adds) + +const AWSIdentRestriction = deriveAWSRestrictedName(/[^a-z0-9.-]/g); + +const deriveAWSRestrictedBucketName = AWSIdentRestriction(56)(8)('-bucket'); + +const deriveAWSRestrictedELBName = AWSIdentRestriction(32)(8)('-elb'); + +interface DockerFileParams { + accessKey: string; + s3Bucket: string; +} + +const password_file_name = '.htpasswd'; +const monorepo_github_name = 'zemnmez/monorepo'; + +function DockerFile(params: DockerFileParams) { + return ` +# Use a base image with Bazel and other dependencies +FROM buchgr/bazel-remote-cache:latest + +# Set the user and group to 1000:1000 +USER 1000:1000 + +# Set the working directory +WORKDIR /data + +# Mount cache directory and AWS configuration from the host +VOLUME /data +VOLUME /aws-config + +# Expose ports 9090 and 9092 +EXPOSE 9090 +EXPOSE 9092 + +COPY ${password_file_name} ${password_file_name} + +# Set the entry point and command +CMD [ \\ + "--s3.auth_method=aws_credentials_file", \\ + "--s3.aws_profile=supercool", \\ + "--s3.secret_access_key=${params.accessKey}", \\ + "--s3.bucket=${params.s3Bucket}", \\ + "--s3.endpoint=s3.us-east-1.amazonaws.com", \\ + "--htpasswd_file=${password_file_name}", \\ + "--max_size", \\ + "5" \\ +] + + + `; +} + +export class BazelRemoteCache extends Pulumi.ComponentResource { + constructor( + name: string, + args: Args, + opts?: Pulumi.ComponentResourceOptions + ) { + super('ts:pulumi:bazel_rce:BazelRemoteCache', name, args, opts); + + // + // Provision the bucket that will contain the bazel cache + // + + /** + * IAM user for the cache service. + */ + const iamuser = new aws.iam.User( + `${name}_iam_user`, + { + path: '/system/', + }, + { parent: this } + ); + + /** + * Access key for the cache service IAM user. + */ + const accessKey = new aws.iam.AccessKey( + `${name}_user_access_key`, + { + user: iamuser.name, + }, + { parent: this } + ); + + /** + * S3 bucket for cache server backend + */ + const bucket = new aws.s3.BucketV2( + deriveAWSRestrictedBucketName(name), + {}, + { + parent: this, + } + ); + + /** + * Bucket policy that allows the cache service IAM user ( @see iamuser ) + * to read & write to the cache bucket. + */ + const bucketPolicy = new aws.s3.BucketPolicy( + `${name}_bucket_policy`, + { + bucket: bucket.id, + policy: Pulumi.all([iamuser.arn, bucket.arn]).apply( + ([iamuserarn, bucketArn]) => + JSON.stringify({ + Version: '2012-10-17', + Statement: [ + { + Effect: 'Allow', + Action: ['s3:ListBucket'], + Principal: { + AWS: iamuserarn, + }, + Resource: [bucketArn], + }, + { + Effect: 'Allow', + Principal: { + AWS: iamuserarn, + }, + Action: ['s3:*Object'], // R/W + Resource: [`${bucketArn}/*`], + }, + ], + }) + ), + }, + { parent: this } + ); + + /** + * Username used by the GitHub actions runners to use the cache bucket. + */ + const username = new random.RandomPassword( + `${name}_auth_username`, + { + length: 25, + }, + { parent: this } + ); + + /** + * Password used by the GitHub actions runners to use the cache bucket. + */ + const password = new random.RandomPassword( + `${name}_auth_password`, + { + length: 25, + }, + { parent: this } + ); + + /** + * Content for the htpasswd file (auth for the cache server) + */ + const htpasswdFileContent = Pulumi.all([username, password]).apply( + ([username, password]) => + `${username.result}:${password.bcryptHash}` + ); + + /** + * Temporary directory to contain assets needed for the Docker container. + */ + const deployContextDirName = (async () => { + const target = path.join(os.tmpdir(), 'monorepo-pulumi-deploy'); + const temp = await fs.mkdtemp(target); + return temp; + })(); + + /** + * Promise fulfilled when the password file is written + */ + const passwordFile = Pulumi.all([ + deployContextDirName, + htpasswdFileContent, + ]).apply(([dir, content]) => + fs.writeFile(path.join(dir, password_file_name), content) + ); + + /** + * Promise to the deploy directory that also ensures its contents are written. + */ + const deployContextDir = Pulumi.all([ + deployContextDirName, + passwordFile, + ]).apply(([dirName]) => dirName); + + /** + * Cluster on which the cache service will run + */ + const cluster = new aws.ecs.Cluster( + `${name}_cluster`, + {}, + { parent: this } + ); + + /** + * Load balancer for the cache service + */ + const loadBalancer = new awsx.lb.ApplicationLoadBalancer( + deriveAWSRestrictedELBName(name), + {}, + { parent: this } + ); + + /** + * Repository to contain the built Docker image. + */ + const repo = new awsx.ecr.Repository( + `${name}_ecr`, + { forceDelete: true }, + { parent: this } + ); + + /** + * Content of the Dockerfile needed to turn up this service. + */ + const dockerFile = Pulumi.all([bucket.id, accessKey.secret]).apply( + ([bucketId, accessKey]) => + DockerFile({ s3Bucket: bucketId, accessKey }) + ); + + /** + * The built Docker image (uploaded to the repo). + */ + const image = new awsx.ecr.Image( + `${name}_image`, + { + repositoryUrl: repo.url, + dockerfile: dockerFile, + path: deployContextDir, + }, + { parent: this } + ); + + /** + * The cache service itself on fargate. + */ + const service = new awsx.ecs.FargateService( + `${name}_service`, + { + cluster: cluster.arn, + // we don't want it accessible outside of TLS + assignPublicIp: false, + // if we aren't using the cache for a while, it's cool to turn it down + desiredCount: 1, + deploymentMaximumPercent: 100, + deploymentMinimumHealthyPercent: 0, + taskDefinitionArgs: { + container: { + name: `${name}_container`, + image: image.imageUri, + cpu: 128, + memory: 512, + essential: true, + portMappings: [ + { + containerPort: 80, + targetGroup: loadBalancer.defaultTargetGroup, + }, + ], + }, + }, + }, + { parent: this } + ); + + /** + * Get a certificate for the cache server + */ + const certReq = new Cert.Certificate( + `${name}_cert`, + { + zoneId: args.zoneId, + domain: args.domain, + }, + { parent: this } + ); + + /** + * DomainName for the API TLS proxy server. + */ + const apiProxyDomainName = new aws.apigatewayv2.DomainName( + `${name}_domain_name`, + { + domainName: args.domain, + domainNameConfiguration: { + certificateArn: certReq.validation.certificateArn, + endpointType: 'REGIONAL', + securityPolicy: 'TLS_1_2', + }, + }, + { parent: this } + ); + + const record = new aws.route53.Record(`${name}_record`, { + name: apiProxyDomainName.domainName, + type: 'A', + zoneId: args.zoneId, + aliases: [ + { + name: apiProxyDomainName.domainNameConfiguration.apply( + domainNameConfiguration => + domainNameConfiguration.targetDomainName + ), + zoneId: apiProxyDomainName.domainNameConfiguration.apply( + domainNameConfiguration => + domainNameConfiguration.hostedZoneId + ), + evaluateTargetHealth: false, + }, + ], + }); + + /** + * Proxy API to pipe the cache server through an encrypted reverse-proxy. + */ + const api = new aws.apigatewayv2.Api( + `${name}_api_proxy`, + { + protocolType: 'HTTP', + disableExecuteApiEndpoint: true, + }, + { parent: this } + ); + + /** + * Stage for the API gateway that applies the SSL cert. + */ + const apiStage = new aws.apigatewayv2.Stage( + `${name}_proxy_api_gateway_stage`, + { + apiId: api.id, + }, + { parent: this } + ); + + new aws.apigatewayv2.ApiMapping( + `${name}_api_mapping`, + { + apiId: api.id, + domainName: apiProxyDomainName.id, + stage: apiStage.id, + }, + { parent: this } + ); + + /** + * Proxy integration that proxies the private cache server ELB + */ + const integration = new aws.apigatewayv2.Integration( + `${name}_proxy_integration`, + { + apiId: api.id, + integrationType: 'HTTP_PROXY', + integrationMethod: 'ANY', + // proxy the load balancer for the cache service + integrationUri: loadBalancer.loadBalancer.arn, + }, + { parent: this } + ); + + /** + * Route that wildcard proxies everything to the integration + */ + const apiProxyRoute = new aws.apigatewayv2.Route( + `${name}_api_proxy_route`, + { + apiId: api.id, + routeKey: 'ANY {proxy+}', + // what is this syntax lol + target: Pulumi.interpolate`integrations/${integration.id}`, + }, + { parent: this } + ); + + /** + * Reverse-proxy to serve SSL certificate on the cache server. + */ + new aws.apigatewayv2.Deployment( + `${name}_gateway_deployment`, + { + apiId: api.id, + description: `SSL / TLS API gateway to the ${args.domain} bazel cache api.`, + }, + { + parent: this, + // **Note:** Creating a deployment for an API requires at least one `aws.apigatewayv2.Route` + // resource associated with that API. To avoid race conditions when all resources are being + // created together, you need to add implicit resource references via the `triggers` argument + // or explicit resource references using the + // [resource `dependsOn` meta-argument](https://www.pulumi.com/docs/intro/concepts/programming-model/#dependson). + dependsOn: [apiProxyRoute], + } + ); + + /** + * Get a handle on the monorepo itself. + */ + const monorepo = GitHub.getRepository( + { + fullName: monorepo_github_name, + }, + { parent: this } + ); + + new GitHub.ActionsSecret( + `${name}_actions_secret_cache_url`, + { + plaintextValue: Pulumi.interpolate`https://${username.result}:${password.result}@${record.name}`, + repository: monorepo.then(v => v.name), + secretName: 'BAZEL_REMOTE_CACHE_URL', + }, + { parent: this } + ); + + super.registerOutputs({ service, bucketPolicy }); + } +} diff --git a/ts/pulumi/index.ts b/ts/pulumi/index.ts index e0662bb5b..f4df13fcf 100644 --- a/ts/pulumi/index.ts +++ b/ts/pulumi/index.ts @@ -1,5 +1,6 @@ import * as aws from '@pulumi/aws'; import * as Pulumi from '@pulumi/pulumi'; +import * as BazelCache from 'ts/pulumi/bazel_rce'; import * as PleaseIntroduceMeToYourDog from 'ts/pulumi/pleaseintroducemetoyour.dog'; import * as ShadwellIm from 'ts/pulumi/shadwell.im'; import * as ZemnMe from 'ts/pulumi/zemn.me'; @@ -15,6 +16,7 @@ export class Component extends Pulumi.ComponentResource { pleaseIntroduceMeToYourDog: PleaseIntroduceMeToYourDog.Component; zemnMe: ZemnMe.Component; shadwellIm: ShadwellIm.Component; + bazelCache: BazelCache.BazelRemoteCache; constructor( name: string, args: Args, @@ -76,6 +78,15 @@ export class Component extends Pulumi.ComponentResource { { parent: this } ); + this.bazelCache = new BazelCache.BazelRemoteCache( + `${name}_bazel_remote_cache_server`, + { + zoneId: Pulumi.output(zone.me.zemn.then(z => z.id)), + domain: stage('cache.bazel.zemn.me'), + }, + { parent: this } + ); + super.registerOutputs({ pleaseIntroduceMeToYourDog: this.pleaseIntroduceMeToYourDog, }); diff --git a/ts/pulumi/stack.ts b/ts/pulumi/stack.ts index 763265055..ab3582542 100644 --- a/ts/pulumi/stack.ts +++ b/ts/pulumi/stack.ts @@ -28,6 +28,7 @@ export const projectName = 'monorepo-2'; async function provisionStack(s: Promise): Promise { await (await s).workspace.installPlugin('aws', 'v5.13.0'); // can I get rid of this? it seems stupid + await (await s).workspace.installPlugin('github', 'v5.17.0'); // can I get rid of this? it seems stupid await (await s).setConfig('aws:region', { value: 'us-east-1' }); return s;