diff --git a/.envrc b/.envrc new file mode 100644 index 00000000..6de8a8ac --- /dev/null +++ b/.envrc @@ -0,0 +1,3 @@ +source_url "https://raw.githubusercontent.com/cachix/devenv/d1f7b48e35e6dee421cfd0f51481d17f77586997/direnvrc" "sha256-YBzqskFZxmNb3kYVoKD9ZixoPXJh1C9ZvTLGFRkauZ0=" + +use devenv \ No newline at end of file diff --git a/.github/workflows/infra.repo.dispatch.yml b/.github/workflows/infra.repo.dispatch.yml new file mode 100644 index 00000000..6ca78560 --- /dev/null +++ b/.github/workflows/infra.repo.dispatch.yml @@ -0,0 +1,25 @@ +name: Infra Run + +on: + repository_dispatch: + types: + - infra_run + +jobs: + infra_run: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: cachix/install-nix-action@v23 + - uses: cachix/cachix-action@v12 + with: + name: devenv + - name: Install devenv.sh + run: nix profile install tarball+https://install.devenv.sh/latest + - name: Build the devenv shell and run any pre-commit hooks + run: devenv ci + - name: devenv shell + shell: devenv shell bash -e {0} + run: | + terragrunt --version + echo ${{ github.event.client_payload }} diff --git a/.github/workflows/infra.yml b/.github/workflows/infra.yml new file mode 100644 index 00000000..c6fd19f1 --- /dev/null +++ b/.github/workflows/infra.yml @@ -0,0 +1,37 @@ +--- +name: infra + +on: + workflow_dispatch: + push: + branches: [master] + paths: + - 'infra/**' + - 'resume/**' + +env: + OP_SERVICE_ACCOUNT_TOKEN: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }} + +jobs: + deployment: + runs-on: ubuntu-latest + environment: + name: production + steps: + - uses: actions/checkout@v4 + - uses: cachix/install-nix-action@v23 + - uses: cachix/cachix-action@v12 + with: + name: devenv + - name: Install devenv.sh + run: nix profile install tarball+https://install.devenv.sh/latest + - name: Build the devenv shell and run any pre-commit hooks + run: devenv ci + - name: devenv shell + shell: devenv shell bash -e {0} + run: | + op --version + terragrunt --version + curl -v https://tf.kaipov.com/self/infra/tfstate + ./scripts/run.sh infra/tfstate plan + ./scripts/run.sh infra/kaipov.com plan diff --git a/.github/workflows/resume.yml b/.github/workflows/resume.yml new file mode 100644 index 00000000..d708939e --- /dev/null +++ b/.github/workflows/resume.yml @@ -0,0 +1,66 @@ +--- +name: resume + +on: + workflow_dispatch: + push: + branches: [master] + paths: [resume/**] + pull_request: + branches: [master] + paths: [resume/**] + +env: + tectonic_version: 0.8.0 + commit_message_resume: 'gh-actions: add resume.pdf' + +jobs: + build: + runs-on: ubuntu-latest + environment: + name: production + outputs: + leave-comment: ${{ steps.push.outputs.committed && steps.push.outputs.pushed }} + steps: + - uses: actions/checkout@v2 + - name: Download Tectonic + uses: wtfjoke/setup-tectonic@main + with: + tectonic-version: ${{ env.tectonic_version }} + github-token: ${{ secrets.GITHUB_TOKEN }} + - name: Cache Tectonic + uses: actions/cache@v2 + env: {cache-name: cache-tectonic} + with: + path: ~/.cache/Tectonic + key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('**/*.tex') }} + restore-keys: | + ${{ runner.os }}-build-${{ env.cache-name }}- + ${{ runner.os }}-build- + ${{ runner.os }}- + - name: Build our resume + id: build + run: | + ./scripts/resume.build.sh + echo "::set-output name=msg::${{ env.commit_message_resume }}" + - name: Push that bitch up + id: push + if: ${{ success() && steps.build.outputs.msg != '' }} + uses: EndBug/add-and-commit@v7.1.2 + with: + add: website/static + push: true + message: ${{ steps.build.outputs.msg }} + author_name: github-actions + author_email: 41898282+github-actions[bot]@users.noreply.github.com + - name: Leave a comment + if: | + success() && + steps.push.outputs.committed == 'true' && + steps.push.outputs.pushed == 'true' + uses: mshick/add-pr-comment@v1 + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} + repo-token-user-login: github-actions[bot] + message: | + ${{ env.commit_message_resume }} diff --git a/.github/workflows/vercel.delete.deployments.yml b/.github/workflows/vercel.delete.deployments.yml new file mode 100644 index 00000000..8d69c0db --- /dev/null +++ b/.github/workflows/vercel.delete.deployments.yml @@ -0,0 +1,13 @@ +name: Delete all non-production Vercel deployments + +on: + workflow_dispatch + +jobs: + clean: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - run: ./scripts/vercel.delete.deployments.sh + env: + VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..e11f6683 --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +infra/**/terragrunt-debug.tfvars.json +infra/**/.terraform +infra/**/.terragrunt-cache +infra/**/.terraform.lock.hcl +infra/**/zz_generated.*.tf + +# Devenv +.devenv* +devenv.local.nix + +# direnv +.direnv + +# pre-commit +.pre-commit-config.yaml diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 00000000..4b7fd88f --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "resume/moderncv"] + path = resume/moderncv + url = https://github.com/moderncv/moderncv.git diff --git a/README.md b/README.md new file mode 100644 index 00000000..0051fa9a --- /dev/null +++ b/README.md @@ -0,0 +1,59 @@ +# self + +## what is this? + +It's an overengineered and bundled monorepo for my personal website, resume, the +infrastructure behind that, and the CI for all of it. If we want to get all +metaphoric about it, it's a representation of self. + +## contents + +At a glance, the repo is organized as follows: + +| directory | description | +|------------------------|-----------------------------------| +| [`infra`](./infra) | Terragrunt and Terraform modules | +| [`resume`](./resume) | Resume markup | +| [`website`](./website) | Hugo static site config | + +Please note the [`shell.nix`](./shell.nix) at the root of this repo. If any +necessary tools are unavailable on our system, the tool will be invoked via +a Nix shell. + +### `infra` + +Terraform state is stored in a Cloudflare Workers KV store. The backend had to +be bootstrapped by the Terraform configuration in +[`infra/tfstate`](./infra/tfstate). Yes, the state for the backend is stored in +the very same infrastructure it spun up. If you ain't living dangerously, are +you even living? + +The `infra/kaipov.com` module manages all the Cloudflare configuration for +`kaipov.com` -- DNS records, routes, security, whatever. + +We can use `./script/run.sh` to manage our infrastructure too, e.g.: + +```console +$ ./scripts/run.sh infra/kaipov.com plan +$ ./scripts/run.sh infra/kaipov.com state show cloudflare_zone.kaipov +``` + +### resume + +The resume is written in TeX using the [`moderncv`](./resume/moderncv) class, +with some of my own custom patches. + +Thanks to [Tectonic](https://github.com/tectonic-typesetting/tectonic), building +the TeX document is astonglishly **not** a giant pain in the ass! +Unbelievable--I know! +Typically I'll run `./scripts/resume.dev.sh` and have _Sumatra +PDF_ open on the side to get a live preview. When I'm done, the resume should +already be moved into the appropriate place under `website` so that our Hugo +site can serve it accordingly. + +### website + +The Hugo website is hosted on Cloudflare Pages (can you tell he's a fan of +Cloudflare?). Despite not being the most feature-full and sophisticated static +site host, Pages integrates well with the rest of the Cloudflare suite, so it's +hard justifying the use of another provider! diff --git a/devenv.lock b/devenv.lock new file mode 100644 index 00000000..afeb9216 --- /dev/null +++ b/devenv.lock @@ -0,0 +1,156 @@ +{ + "nodes": { + "devenv": { + "locked": { + "dir": "src/modules", + "lastModified": 1703939110, + "narHash": "sha256-GgjYWkkHQ8pUBwXX++ah+4d07DqOeCDaaQL6Ab86C50=", + "owner": "cachix", + "repo": "devenv", + "rev": "7354096fc026f79645fdac73e9aeea71a09412c3", + "type": "github" + }, + "original": { + "dir": "src/modules", + "owner": "cachix", + "repo": "devenv", + "type": "github" + } + }, + "flake-compat": { + "flake": false, + "locked": { + "lastModified": 1673956053, + "narHash": "sha256-4gtG9iQuiKITOjNQQeQIpoIB6b16fm+504Ch3sNKLd8=", + "owner": "edolstra", + "repo": "flake-compat", + "rev": "35bb57c0c8d8b62bbfd284272c928ceb64ddbde9", + "type": "github" + }, + "original": { + "owner": "edolstra", + "repo": "flake-compat", + "type": "github" + } + }, + "flake-utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1685518550, + "narHash": "sha256-o2d0KcvaXzTrPRIo0kOLV0/QXHhDQ5DTi+OxcjO8xqY=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "a1720a10a6cfe8234c0e93907ffe81be440f4cef", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "gitignore": { + "inputs": { + "nixpkgs": [ + "pre-commit-hooks", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1660459072, + "narHash": "sha256-8DFJjXG8zqoONA1vXtgeKXy68KdJL5UaXR8NtVMUbx8=", + "owner": "hercules-ci", + "repo": "gitignore.nix", + "rev": "a20de23b925fd8264fd7fad6454652e142fd7f73", + "type": "github" + }, + "original": { + "owner": "hercules-ci", + "repo": "gitignore.nix", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1703499205, + "narHash": "sha256-lF9rK5mSUfIZJgZxC3ge40tp1gmyyOXZ+lRY3P8bfbg=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "e1fa12d4f6c6fe19ccb59cac54b5b3f25e160870", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixpkgs-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "nixpkgs-stable": { + "locked": { + "lastModified": 1685801374, + "narHash": "sha256-otaSUoFEMM+LjBI1XL/xGB5ao6IwnZOXc47qhIgJe8U=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "c37ca420157f4abc31e26f436c1145f8951ff373", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-23.05", + "repo": "nixpkgs", + "type": "github" + } + }, + "pre-commit-hooks": { + "inputs": { + "flake-compat": "flake-compat", + "flake-utils": "flake-utils", + "gitignore": "gitignore", + "nixpkgs": [ + "nixpkgs" + ], + "nixpkgs-stable": "nixpkgs-stable" + }, + "locked": { + "lastModified": 1703939133, + "narHash": "sha256-Gxe+mfOT6bL7wLC/tuT2F+V+Sb44jNr8YsJ3cyIl4Mo=", + "owner": "cachix", + "repo": "pre-commit-hooks.nix", + "rev": "9d3d7e18c6bc4473d7520200d4ddab12f8402d38", + "type": "github" + }, + "original": { + "owner": "cachix", + "repo": "pre-commit-hooks.nix", + "type": "github" + } + }, + "root": { + "inputs": { + "devenv": "devenv", + "nixpkgs": "nixpkgs", + "pre-commit-hooks": "pre-commit-hooks" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/devenv.nix b/devenv.nix new file mode 100644 index 00000000..42627dbe --- /dev/null +++ b/devenv.nix @@ -0,0 +1,20 @@ +{ pkgs, ... }: + +{ + packages = with pkgs; [ + _1password + azure-cli + entr + git + jq + opentofu + tectonic + terragrunt + ]; + + enterShell = '' + git --version + ''; + + pre-commit.hooks.shellcheck.enable = true; +} diff --git a/devenv.yaml b/devenv.yaml new file mode 100644 index 00000000..89a8475b --- /dev/null +++ b/devenv.yaml @@ -0,0 +1,4 @@ +allowUnfree: true +inputs: + nixpkgs: + url: github:NixOS/nixpkgs/nixpkgs-unstable diff --git a/infra/_modules/azure-container-app/main.tf b/infra/_modules/azure-container-app/main.tf new file mode 100644 index 00000000..26685b59 --- /dev/null +++ b/infra/_modules/azure-container-app/main.tf @@ -0,0 +1,90 @@ +terraform { + required_providers { + azurerm = { + source = "hashicorp/azurerm" + version = ">= 3.0, < 4.0" + } + } +} + +resource "azurerm_resource_group" "rg" { + name = "${var.name}-rg" + location = var.location + tags = { + name = var.name + } +} + +resource "azurerm_container_app_environment" "env" { + name = "${var.name}-env" + location = azurerm_resource_group.rg.location + resource_group_name = azurerm_resource_group.rg.name +} + +resource "azurerm_container_app" "app" { + name = var.name + container_app_environment_id = azurerm_container_app_environment.env.id + resource_group_name = azurerm_resource_group.rg.name + revision_mode = "Single" + + dynamic "secret" { + for_each = { for k, v in var.env : k => substr(v, length("secret://"), -1) if startswith(v, "secret://") } + content { + name = replace(lower(secret.key), "/[^a-z0-9-.]/", "-") + value = secret.value + } + } + + template { + max_replicas = 1 + min_replicas = 1 + + volume { + name = "shared" + storage_type = "EmptyDir" + } + + init_container { + name = "copy-files" + image = "alpine:latest" + cpu = 0.25 + memory = "0.5Gi" + command = [ + "/bin/sh", + "-c", + join("\n", [ + for k, v in var.files : + "cat >/shared/${k} <<'LOL'\n${v}\nLOL\n" + ]) + ] + + volume_mounts { + name = "shared" + path = "/shared" + } + } + + container { + name = var.name + image = "${var.image}${var.sha == "" ? ":latest" : "@${var.sha}"}" + cpu = 0.25 + memory = "0.5Gi" + # command = ["sh", "-c", "tail -f /dev/null"] + args = ["discord"] + + dynamic "env" { + for_each = var.env + content { + name = env.key + secret_name = startswith(env.value, "secret://") ? replace(lower(env.key), "/[^a-z0-9-.]/", "-") : null + value = startswith(env.value, "secret://") ? null : env.value + } + } + + volume_mounts { + name = "shared" + path = "/shared" + } + } + } +} diff --git a/infra/_modules/azure-container-app/variables.tf b/infra/_modules/azure-container-app/variables.tf new file mode 100644 index 00000000..5a2c0655 --- /dev/null +++ b/infra/_modules/azure-container-app/variables.tf @@ -0,0 +1,34 @@ +variable "name" { + type = string +} + +variable "location" { + type = string +} + +variable "image" { + type = string +} + +variable "sha" { + type = string +} + +variable "files" { + type = map(string) + default = {} + description = "Files will be mounted to /shared" +} + +variable "env" { + type = map(string) + default = {} + description = < ({ __proto__: new Proxy({}, { get: (o2, s2, r2, n2) => (o3, ...a) => t.push([s2.toUpperCase(), RegExp(`^${(n2 = (e2 + "/" + o3).replace(/\/+(\/|$)/g, "$1")).replace(/(\/?\.?):(\w+)\+/g, "($1(?<$2>*))").replace(/(\/?\.?):(\w+)/g, "($1(?<$2>[^$1/]+?))").replace(/\./g, "\\.").replace(/(\/?)\*/g, "($1.*)?")}/*$`), a, n2]) && r2 }), routes: t, async handle(e3, ...o2) { + let s2, r2, n2 = new URL(e3.url), a = e3.query = { __proto__: null }; + for (let [e4, t2] of n2.searchParams) + a[e4] = void 0 === a[e4] ? t2 : [a[e4], t2].flat(); + for (let [a2, c, l2, i2] of t) + if ((a2 === e3.method || "ALL" === a2) && (r2 = n2.pathname.match(c))) { + e3.params = r2.groups || {}, e3.route = i2; + for (let t2 of l2) + if (void 0 !== (s2 = await t2(e3.proxy || e3, ...o2))) + return s2; + } +} }); +var o = (e2 = "text/plain; charset=utf-8", t) => (o2, s2) => { + const { headers: r2 = {}, ...n2 } = s2 || {}; + return "Response" === o2?.constructor.name ? o2 : new Response(t ? t(o2) : o2, { headers: { "content-type": e2, ...r2 }, ...n2 }); +}; +var s = o("application/json; charset=utf-8", JSON.stringify); +var r = (e2) => ({ 400: "Bad Request", 401: "Unauthorized", 403: "Forbidden", 404: "Not Found", 500: "Internal Server Error" })[e2] || "Unknown Error"; +var n = (e2 = 500, t) => { + if (e2 instanceof Error) { + const { message: o2, ...s2 } = e2; + e2 = e2.status || 500, t = { error: o2 || r(e2), ...s2 }; + } + return t = { status: e2, ..."object" == typeof t ? t : { error: t || r(e2) } }, s(t, { status: e2 }); +}; +var l = o("text/html"); +var i = o("image/jpeg"); +var p = o("image/png"); +var d = o("image/webp"); diff --git a/infra/_modules/cloudflare-worker/lib.js b/infra/_modules/cloudflare-worker/lib.js new file mode 100644 index 00000000..82a68d0b --- /dev/null +++ b/infra/_modules/cloudflare-worker/lib.js @@ -0,0 +1,81 @@ +// node_modules/itty-router/index.mjs +var e = ({ base: e2 = "", routes: t = [] } = {}) => ({ __proto__: new Proxy({}, { get: (o2, s2, r2, n2) => (o3, ...a) => t.push([s2.toUpperCase(), RegExp(`^${(n2 = (e2 + "/" + o3).replace(/\/+(\/|$)/g, "$1")).replace(/(\/?\.?):(\w+)\+/g, "($1(?<$2>*))").replace(/(\/?\.?):(\w+)/g, "($1(?<$2>[^$1/]+?))").replace(/\./g, "\\.").replace(/(\/?)\*/g, "($1.*)?")}/*$`), a, n2]) && r2 }), routes: t, async handle(e3, ...o2) { + let s2, r2, n2 = new URL(e3.url), a = e3.query = { __proto__: null }; + for (let [e4, t2] of n2.searchParams) + a[e4] = void 0 === a[e4] ? t2 : [a[e4], t2].flat(); + for (let [a2, c, l2, i2] of t) + if ((a2 === e3.method || "ALL" === a2) && (r2 = n2.pathname.match(c))) { + e3.params = r2.groups || {}, e3.route = i2; + for (let t2 of l2) + if (void 0 !== (s2 = await t2(e3.proxy || e3, ...o2))) + return s2; + } +} }); +var o = (e2 = "text/plain; charset=utf-8", t) => (o2, s2) => { + const { headers: r2 = {}, ...n2 } = s2 || {}; + return "Response" === o2?.constructor.name ? o2 : new Response(t ? t(o2) : o2, { headers: { "content-type": e2, ...r2 }, ...n2 }); +}; +var s = o("application/json; charset=utf-8", JSON.stringify); +var r = (e2) => ({ 400: "Bad Request", 401: "Unauthorized", 403: "Forbidden", 404: "Not Found", 500: "Internal Server Error" })[e2] || "Unknown Error"; +var n = (e2 = 500, t) => { + if (e2 instanceof Error) { + const { message: o2, ...s2 } = e2; + e2 = e2.status || 500, t = { error: o2 || r(e2), ...s2 }; + } + return t = { status: e2, ..."object" == typeof t ? t : { error: t || r(e2) } }, s(t, { status: e2 }); +}; +var l = o("text/html"); +var i = o("image/jpeg"); +var p = o("image/png"); +var d = o("image/webp"); +var h = (e2) => { + e2.proxy = new Proxy(e2.proxy || e2, { get: (t, o2) => { + let s2; + return void 0 !== (s2 = t[o2]) ? s2.bind?.(e2) || s2 : t?.params?.[o2]; + } }); +}; + +// src/middleware.js +var withContent = async (request) => { + const contentType = request.headers.get("content-type"); + if (contentType?.includes("application/json")) { + request.content = await request.json(); + request.contentAsString = JSON.stringify(request.content); + } else if (contentType?.includes("application/text")) { + request.content = await request.text(); + request.contentAsString = request.content; + } else if (contentType?.includes("text/html")) { + request.content = await request.text(); + request.contentAsString = request.body; + } else if (contentType?.includes("application/x-www-form-urlencoded")) { + request.content = await request.text(); + request.contentAsString = request.body; + console.log(request.content); + } else if (contentType?.includes("form")) { + request.content = await request.formData(); + const form = {}; + for (const entry of request.content.entries()) { + form[entry[0]] = entry[1]; + } + request.contentAsString = JSON.stringify(form); + } else { + request.content = await request.blob(); + request.contentAsString = "binary data"; + } +}; + +// src/server.js +var router = (cb) => { + const r2 = e(); + r2.all("*", h, withContent); + cb(r2); + r2.all("*", () => new Response("Not Found.", { status: 404 })); + return r2; +}; +var server = (cb, scheduledCb) => { + var o = { fetch: (request, env, ctx) => router(cb).handle(request, env, ctx).then(s).catch(n) } + if (scheduledCb) { + o.scheduled = scheduledCb; + } + return o; +}; diff --git a/infra/_modules/cloudflare-worker/main.tf b/infra/_modules/cloudflare-worker/main.tf new file mode 100644 index 00000000..9eb4e890 --- /dev/null +++ b/infra/_modules/cloudflare-worker/main.tf @@ -0,0 +1,236 @@ +terraform { + required_providers { + cloudflare = { + source = "cloudflare/cloudflare" + version = ">= 4.0, < 5.0" + } + } +} + +variable "account_id" { + type = string + description = "The Cloudflare account ID to use." +} + +variable "zone" { + type = object({ + id = string + zone = string + }) + description = < { + r.get('/:name', (request, env, ctx) => { + const name = request.params.name + return new Response(`Hello $${name}!`) + }) +}, (env, ctx) => { + console.log("cron scheduled event callback") +}) +``` +EOF +} + +variable "module" { + type = bool + default = true + description = "Whether to upload the script as an ES6 module." +} + +variable "bindings" { + type = list(object({ + kind = string + name = string + namespace_id = optional(string) + text = optional(string) + module = optional(string) + service = optional(string) + environment = optional(string) + bucket_name = optional(string) + dataset = optional(string) + })) + default = [] + description = "A list of bindings to bindg to the worker script." +} + +variable "cron_schedules" { + type = set(string) + default = [] + description = <&2 + exit 1 +fi + +part="${device}1" +if ! blkid "/dev/$device"; then + echo "Formatting /dev/$device" + wipefs -a "/dev/$device" + parted "/dev/$device" --script mklabel gpt mkpart ext4part ext4 0% 100% + sleep 5 + yes | mkfs.ext4 "/dev/$part" + partprobe "/dev/$part" +fi + +mkdir -p "$mount_dir" + +if ! mountpoint -q "$mount_dir"; then + echo "Mounting /dev/$part to $mount_dir" + mount "/dev/$part" "$mount_dir" +fi + +if ! grep -q "$mount_dir" /etc/fstab; then + echo "Adding /dev/$part to /etc/fstab" + part_uuid=$(blkid -s UUID -o value "/dev/$part") + echo "UUID=$part_uuid $mount_dir ext4 defaults,nofail 1 2" >>/etc/fstab +fi diff --git a/infra/_modules/mcbe-on-azure/config/scripts/setup-mcbe.sh b/infra/_modules/mcbe-on-azure/config/scripts/setup-mcbe.sh new file mode 100755 index 00000000..b139f6c9 --- /dev/null +++ b/infra/_modules/mcbe-on-azure/config/scripts/setup-mcbe.sh @@ -0,0 +1,67 @@ +#!/bin/sh + +set -eu + +: "${server_name?}" +: "${level_name?}" +: "${backup_dir?}" +: "${bedrock_bridge_token:=}" + +if ! [ -d /opt/MCscripts ]; then + echo "Setting up MCscripts..." + rm -rf /tmp/MCscripts-master + curl -sLo /tmp/master.tgz https://github.com/TapeWerm/MCscripts/archive/refs/heads/master.tar.gz + tar xfvz /tmp/master.tgz -C /tmp + /tmp/MCscripts-master/src/install.sh >/dev/null 2>&1 +fi + +# use our mounted managed disk as the backup dir to persist backups +# we run a backup any time +echo "Using $backup_dir as backup directory" +ln -snf "$backup_dir" /opt/MCscripts/backup_dir + +echo "Setting up server..." +echo y | su mc -s /bin/bash -c '/opt/MCscripts/bin/mcbe_getzip.py' +if ! [ -d ~mc/bedrock/server ]; then + /opt/MCscripts/bin/mcbe_setup.py server 2>&1 +fi + +echo "Replacing default server files" +cp -r /tmp/config/server/* ~mc/bedrock/server +sed -i -E " + s/^(server-name=).+$/\1$server_name/; + s/^(level-name=).+$/\1$level_name/; +" ~mc/bedrock/server/server.properties + +if [ -n "$bedrock_bridge_token" ]; then + echo "Setting up bedrock bridge" + sed -i -E "s/0000/$bedrock_bridge_token/" ~mc/bedrock/server/config/default/secrets.json +fi + +# if this is a new server with no worlds, try to find the latest backup for our +# specified world from our mounted managed disk and restore it if it exists +if [ -d ~mc/bedrock/server/worlds ]; then + echo "Worlds directory already exists, skipping restore" +else + echo "Restoring world $level_name from backup" + world_zip=$( + find "$backup_dir" -type f -path "*$level_name*" -exec stat --format '%Y :%y %n' "{}" \; | + sort -nr | + awk '{print $NF}' | + head -n1 + ) + if [ -r "$world_zip" ]; then + # the server should not be running + echo "Found backup $world_zip, restoring..." + echo y | /opt/MCscripts/bin/mcbe_restore.py ~mc/bedrock/server "$world_zip" 2>&1 + fi +fi + +echo "Starting services" +systemctl enable mcbe@server.socket mcbe@server.service mcbe-backup@server.timer --now +systemctl enable mcbe-getzip.timer mcbe-autoupdate@server.service --now +systemctl enable mcbe-rmbackup@server.service --now + +# restarting here is only necessary for subsequent tf applies +echo "Restarting server" +systemctl restart mcbe@server.service diff --git a/infra/_modules/mcbe-on-azure/config/server/allowlist.json b/infra/_modules/mcbe-on-azure/config/server/allowlist.json new file mode 100644 index 00000000..fe51488c --- /dev/null +++ b/infra/_modules/mcbe-on-azure/config/server/allowlist.json @@ -0,0 +1 @@ +[] diff --git a/infra/_modules/mcbe-on-azure/config/server/config/default/permissions.json b/infra/_modules/mcbe-on-azure/config/server/config/default/permissions.json new file mode 100644 index 00000000..76549df6 --- /dev/null +++ b/infra/_modules/mcbe-on-azure/config/server/config/default/permissions.json @@ -0,0 +1,10 @@ +{ + "allowed_modules": [ + "@minecraft/server-gametest", + "@minecraft/server", + "@minecraft/server-ui", + "@minecraft/server-admin", + "@minecraft/server-editor", + "@minecraft/server-net" + ] +} diff --git a/infra/_modules/mcbe-on-azure/config/server/config/default/secrets.json b/infra/_modules/mcbe-on-azure/config/server/config/default/secrets.json new file mode 100644 index 00000000..3897b22f --- /dev/null +++ b/infra/_modules/mcbe-on-azure/config/server/config/default/secrets.json @@ -0,0 +1,3 @@ +{ + "authtoken": "0000" +} diff --git a/infra/_modules/mcbe-on-azure/config/server/config/default/variables.json b/infra/_modules/mcbe-on-azure/config/server/config/default/variables.json new file mode 100644 index 00000000..c8ee81c1 --- /dev/null +++ b/infra/_modules/mcbe-on-azure/config/server/config/default/variables.json @@ -0,0 +1,5 @@ +{ + "prefix": "!", + "discord_link": true, + "startup_notify": true +} diff --git a/infra/_modules/mcbe-on-azure/config/server/permissions.json b/infra/_modules/mcbe-on-azure/config/server/permissions.json new file mode 100644 index 00000000..26d24366 --- /dev/null +++ b/infra/_modules/mcbe-on-azure/config/server/permissions.json @@ -0,0 +1,6 @@ +[ + { + "xuid": "2533274807721947", + "permission": "operator" + } +] diff --git a/infra/_modules/mcbe-on-azure/config/server/server.properties b/infra/_modules/mcbe-on-azure/config/server/server.properties new file mode 100644 index 00000000..ce4bdf0e --- /dev/null +++ b/infra/_modules/mcbe-on-azure/config/server/server.properties @@ -0,0 +1,177 @@ +server-name=Dedicated Server +# Used as the server name +# Allowed values: Any string without semicolon symbol. + +gamemode=survival +# Sets the game mode for new players. +# Allowed values: "survival", "creative", or "adventure" + +force-gamemode=false +# force-gamemode=false (or force-gamemode is not defined in the server.properties) +# prevents the server from sending to the client gamemode values other +# than the gamemode value saved by the server during world creation +# even if those values are set in server.properties after world creation. +# +# force-gamemode=true forces the server to send to the client gamemode values +# other than the gamemode value saved by the server during world creation +# if those values are set in server.properties after world creation. + +difficulty=easy +# Sets the difficulty of the world. +# Allowed values: "peaceful", "easy", "normal", or "hard" + +allow-cheats=true +# If true then cheats like commands can be used. +# Allowed values: "true" or "false" + +max-players=10 +# The maximum number of players that can play on the server. +# Allowed values: Any positive integer + +online-mode=true +# If true then all connected players must be authenticated to Xbox Live. +# Clients connecting to remote (non-LAN) servers will always require Xbox Live authentication regardless of this setting. +# If the server accepts connections from the Internet, then it's highly recommended to enable online-mode. +# Allowed values: "true" or "false" + +allow-list=false +# If true then all connected players must be listed in the separate allowlist.json file. +# Allowed values: "true" or "false" + +server-port=19132 +# Which IPv4 port the server should listen to. +# Allowed values: Integers in the range [1, 65535] + +server-portv6=19133 +# Which IPv6 port the server should listen to. +# Allowed values: Integers in the range [1, 65535] + +enable-lan-visibility=true +# Listen and respond to clients that are looking for servers on the LAN. This will cause the server +# to bind to the default ports (19132, 19133) even when `server-port` and `server-portv6` +# have non-default values. Consider turning this off if LAN discovery is not desirable, or when +# running multiple servers on the same host may lead to port conflicts. +# Allowed values: "true" or "false" + +view-distance=32 +# The maximum allowed view distance in number of chunks. +# Allowed values: Positive integer equal to 5 or greater. + +tick-distance=4 +# The world will be ticked this many chunks away from any player. +# Allowed values: Integers in the range [4, 12] + +player-idle-timeout=30 +# After a player has idled for this many minutes they will be kicked. If set to 0 then players can idle indefinitely. +# Allowed values: Any non-negative integer. + +max-threads=8 +# Maximum number of threads the server will try to use. If set to 0 or removed then it will use as many as possible. +# Allowed values: Any positive integer. + +level-name=Bedrock level +# Allowed values: Any string without semicolon symbol or symbols illegal for file name: /\n\r\t\f`?*\\<>|\": + +level-seed= +# Use to randomize the world +# Allowed values: Any string + +default-player-permission-level=member +# Permission level for new players joining for the first time. +# Allowed values: "visitor", "member", "operator" + +texturepack-required=false +# Force clients to use texture packs in the current world +# Allowed values: "true" or "false" + +content-log-file-enabled=false +# Enables logging content errors to a file +# Allowed values: "true" or "false" + +compression-threshold=1 +# Determines the smallest size of raw network payload to compress +# Allowed values: 0-65535 + +compression-algorithm=zlib +# Determines the compression algorithm to use for networking +# Allowed values: "zlib", "snappy" + +server-authoritative-movement=server-auth +# Allowed values: "client-auth", "server-auth", "server-auth-with-rewind" +# Enables server authoritative movement. If "server-auth", the server will replay local user input on +# the server and send down corrections when the client's position doesn't match the server's. +# If "server-auth-with-rewind" is enabled and the server sends a correction, the clients will be instructed +# to rewind time back to the correction time, apply the correction, then replay all the player's inputs since then. This results in smoother and more frequent corrections. +# Corrections will only happen if correct-player-movement is set to true. + +player-movement-score-threshold=20 +# The number of incongruent time intervals needed before abnormal behavior is reported. +# Disabled by server-authoritative-movement. + +player-movement-action-direction-threshold=0.85 +# The amount that the player's attack direction and look direction can differ. +# Allowed values: Any value in the range of [0, 1] where 1 means that the +# direction of the players view and the direction the player is attacking +# must match exactly and a value of 0 means that the two directions can +# differ by up to and including 90 degrees. + +player-movement-distance-threshold=0.3 +# The difference between server and client positions that needs to be exceeded before abnormal behavior is detected. +# Disabled by server-authoritative-movement. + +player-movement-duration-threshold-in-ms=500 +# The duration of time the server and client positions can be out of sync (as defined by player-movement-distance-threshold) +# before the abnormal movement score is incremented. This value is defined in milliseconds. +# Disabled by server-authoritative-movement. + +correct-player-movement=false +# If true, the client position will get corrected to the server position if the movement score exceeds the threshold. + +server-authoritative-block-breaking=false +# If true, the server will compute block mining operations in sync with the client so it can verify that the client should be able to break blocks when it thinks it can. + +chat-restriction=None +# Allowed values: "None", "Dropped", "Disabled" +# This represents the level of restriction applied to the chat for each player that joins the server. +# "None" is the default and represents regular free chat. +# "Dropped" means the chat messages are dropped and never sent to any client. Players receive a message to let them know the feature is disabled. +# "Disabled" means that unless the player is an operator, the chat UI does not even appear. No information is displayed to the player. + +disable-player-interaction=false +# If true, the server will inform clients that they should ignore other players when interacting with the world. This is not server authoritative. + +client-side-chunk-generation-enabled=true +# If true, the server will inform clients that they have the ability to generate visual level chunks outside of player interaction distances. + +block-network-ids-are-hashes=true +# If true, the server will send hashed block network ID's instead of id's that start from 0 and go up. These id's are stable and won't change regardless of other block changes. + +disable-persona=false +# Internal Use Only + +disable-custom-skins=false +# If true, disable players customized skins that were customized outside of the Minecraft store assets or in game assets. This is used to disable possibly offensive custom skins players make. + +server-build-radius-ratio=Disabled +# Allowed values: "Disabled" or any value in range [0.0, 1.0] +# If "Disabled" the server will dynamically calculate how much of the player's view it will generate, assigning the rest to the client to build. +# Otherwise from the overridden ratio tell the server how much of the player's view to generate, disregarding client hardware capability. +# Only valid if client-side-chunk-generation-enabled is enabled + +allow-outbound-script-debugging=false +# Allows script debugger 'connect' command and script-debugger-auto-attach=connect mode. + +allow-inbound-script-debugging=false +# Allows script debugger 'listen' command and script-debugger-auto-attach=listen mode. + +#force-inbound-debug-port=19144 +# Locks the inbound (listen) debugger port, if not set then default 19144 will be used. Required when using script-debugger-auto-attach=listen mode. + +script-debugger-auto-attach=disabled +# Attempt to attach script debugger at level load, requires that either inbound port or connect address is set and that inbound or outbound connections are enabled. +# "disabled" will not auto attach. +# "connect" server will attempt to connect to debugger in listening mode on the specified port. +# "listen" server will listen to inbound connect attempts from debugger using connect mode on the specified port. + +#script-debugger-auto-attach-connect-address=localhost:19144 +# When auto attach mode is set to 'connect', use this address in the form host:port. Required for script-debugger-auto-attach=connect mode. diff --git a/infra/_modules/mcbe-on-azure/config/systemd/minecraft.service b/infra/_modules/mcbe-on-azure/config/systemd/minecraft.service new file mode 100644 index 00000000..9e164b4d --- /dev/null +++ b/infra/_modules/mcbe-on-azure/config/systemd/minecraft.service @@ -0,0 +1,23 @@ +# vi: ft=systemd + +[Unit] +Description=Minecraft Bedrock Dedicated Server +After=network.target +BindsTo=%N.socket +StartLimitBurst=3 +StartLimitIntervalSec=30 + +[Service] +Type=exec +Restart=always +RestartSec=5 +User=root +WorkingDirectory=/opt/minecraft +ExecReload=/bin/sh -c 'echo reload >%t/%N' +ExecStart=/opt/minecraft/bedrock_server +StandardInput=socket +StandardOutput=journal +StandardError=journal + +[Install] +WantedBy=multi-user.target diff --git a/infra/_modules/mcbe-on-azure/config/systemd/minecraft.socket b/infra/_modules/mcbe-on-azure/config/systemd/minecraft.socket new file mode 100644 index 00000000..b0d9b85a --- /dev/null +++ b/infra/_modules/mcbe-on-azure/config/systemd/minecraft.socket @@ -0,0 +1,12 @@ +# vi: ft=systemd + +[Socket] +SocketMode=0660 +SocketUser=root +Accept=no +ListenFIFO=%t/%N +FileDescriptorName=unix +RemoveOnStop=true + +[Install] +WantedBy=sockets.target diff --git a/infra/_modules/mcbe-on-azure/main.tf b/infra/_modules/mcbe-on-azure/main.tf new file mode 100644 index 00000000..5500ccfd --- /dev/null +++ b/infra/_modules/mcbe-on-azure/main.tf @@ -0,0 +1,253 @@ +terraform { + required_providers { + azurerm = { + source = "hashicorp/azurerm" + version = ">= 3.0, < 4.0" + } + cloudflare = { + source = "cloudflare/cloudflare" + version = ">= 4.0, < 5.0" + } + onepassword = { + source = "1Password/onepassword" + version = ">= 1.0, < 2.0" + } + } +} + +locals { + disk_lun = 10 + backup_dir = "/opt/mc-backups" + + setup_disk_vars = { + lun = local.disk_lun + mount_dir = local.backup_dir + } + setup_mcbe_vars = { + server_name = var.server_name + level_name = var.level_name + backup_dir = local.backup_dir + bedrock_bridge_token = var.bedrock_bridge_token + } + + setup_disk_env = join(" ", [for k, v in local.setup_disk_vars : "${k}=\"${v}\""]) + setup_mcbe_env = join(" ", [for k, v in local.setup_mcbe_vars : "${k}=\"${v}\""]) +} + +resource "azurerm_resource_group" "rg" { + name = "${var.name}-rg" + location = var.location + tags = { + name = var.name + kind = "mc-rg" + } +} + +# Create a virtual network +resource "azurerm_virtual_network" "vnet" { + name = "${var.name}-vnet" + address_space = ["10.0.0.0/16"] + location = azurerm_resource_group.rg.location + resource_group_name = azurerm_resource_group.rg.name + tags = { + name = var.name + kind = "mc-vnet" + } +} + +# Create a subnet +resource "azurerm_subnet" "subnet" { + name = "${var.name}-subnet" + resource_group_name = azurerm_resource_group.rg.name + virtual_network_name = azurerm_virtual_network.vnet.name + address_prefixes = ["10.0.1.0/24"] +} + +# Create a public IP address +resource "azurerm_public_ip" "public_ip" { + name = "${var.name}-publicip" + resource_group_name = azurerm_resource_group.rg.name + location = azurerm_resource_group.rg.location + allocation_method = "Static" + tags = { + name = var.name + kind = "mc-ip" + } +} + +# Create a network security group and associate it with the virtual machine's network interface +resource "azurerm_network_security_group" "nsg" { + name = "${var.name}-nsg" + resource_group_name = azurerm_resource_group.rg.name + location = azurerm_resource_group.rg.location + tags = { + name = var.name + kind = "mc-nsg" + } +} + +resource "azurerm_network_security_rule" "allow_minecraft" { + name = "allow-minecraft" + priority = 1001 + direction = "Inbound" + access = "Allow" + protocol = "*" + source_port_range = "*" + destination_port_range = "19132-19133" + source_address_prefix = "*" + destination_address_prefix = "*" + resource_group_name = azurerm_resource_group.rg.name + network_security_group_name = azurerm_network_security_group.nsg.name +} + +# Create a network interface and associate it with the subnet and public IP address +resource "azurerm_network_interface" "nic" { + name = "${var.name}-nic" + location = azurerm_resource_group.rg.location + resource_group_name = azurerm_resource_group.rg.name + tags = { + name = var.name + kind = "mc-nic" + } + ip_configuration { + name = "minecraft-ipconfig" + subnet_id = azurerm_subnet.subnet.id + public_ip_address_id = azurerm_public_ip.public_ip.id + private_ip_address_allocation = "Dynamic" + } +} + +resource "random_string" "vm_username" { + length = 10 + special = false + upper = false + numeric = false +} + +resource "tls_private_key" "vm_key" { + algorithm = "RSA" + rsa_bits = 4096 +} + +resource "azurerm_linux_virtual_machine" "server" { + name = var.name + resource_group_name = azurerm_resource_group.rg.name + location = azurerm_resource_group.rg.location + size = var.vm_size + admin_username = random_string.vm_username.result + network_interface_ids = [ + azurerm_network_interface.nic.id, + ] + tags = { + name = var.name + kind = "mc-server" + } + + admin_ssh_key { + username = random_string.vm_username.result + public_key = tls_private_key.vm_key.public_key_openssh + } + + os_disk { + caching = "ReadWrite" + storage_account_type = "Standard_LRS" + } + + source_image_reference { + publisher = "Canonical" + offer = "0001-com-ubuntu-server-jammy" + sku = "22_04-lts" + version = "latest" + } +} + +resource "azurerm_managed_disk" "data" { + name = "${var.name}-mc-data" + location = azurerm_resource_group.rg.location + resource_group_name = azurerm_resource_group.rg.name + storage_account_type = "StandardSSD_LRS" + create_option = "Empty" + disk_size_gb = "4" + tags = { + name = var.name + kind = "mc-data" + } + lifecycle { + prevent_destroy = true + } +} + +resource "azurerm_virtual_machine_data_disk_attachment" "attachment" { + managed_disk_id = azurerm_managed_disk.data.id + virtual_machine_id = azurerm_linux_virtual_machine.server.id + lun = local.disk_lun + caching = "ReadWrite" + create_option = "Attach" +} + +resource "null_resource" "configure" { + triggers = { + id = azurerm_linux_virtual_machine.server.id + setup_disk_sh = filesha256("${path.module}/config/scripts/setup-disk.sh") + setup_mcbe_sh = filesha256("${path.module}/config/scripts/setup-mcbe.sh") + setup_disk_env = local.setup_disk_env + setup_mcbe_env = local.setup_mcbe_env + } + + depends_on = [ + azurerm_virtual_machine_data_disk_attachment.attachment, + ] + + connection { + type = "ssh" + user = random_string.vm_username.result + host = azurerm_public_ip.public_ip.ip_address + private_key = tls_private_key.vm_key.private_key_pem + } + + provisioner "file" { + source = "${path.module}/config" + destination = "/tmp" + } + + provisioner "remote-exec" { + inline = [ + "${local.setup_disk_env} sudo -E sh /tmp/config/scripts/setup-disk.sh", + "${local.setup_mcbe_env} sudo -E sh /tmp/config/scripts/setup-mcbe.sh", + ] + } +} + +data "onepassword_vault" "vault" { + count = var.onepassword_vault == "" ? 0 : 1 + name = var.onepassword_vault +} + +resource "onepassword_item" "generated_secrets" { + count = var.onepassword_vault == "" ? 0 : 1 + vault = one(data.onepassword_vault.vault).uuid + title = var.name + category = "password" + section { + label = "" + field { + label = "public_ip_address" + value = azurerm_public_ip.public_ip.ip_address + } + field { + label = "vm_username" + value = random_string.vm_username.result + } + field { + label = "vm_private_key" + value = tls_private_key.vm_key.private_key_pem + } + field { + label = "vm_public_key" + value = tls_private_key.vm_key.public_key_openssh + } + } + tags = [ + "minecraft-server", + ] +} diff --git a/infra/_modules/mcbe-on-azure/outputs.tf b/infra/_modules/mcbe-on-azure/outputs.tf new file mode 100644 index 00000000..b27c8686 --- /dev/null +++ b/infra/_modules/mcbe-on-azure/outputs.tf @@ -0,0 +1,13 @@ +output "vm" { + value = azurerm_linux_virtual_machine.server +} + +output "secrets" { + sensitive = true + value = { + public_ip_address = azurerm_public_ip.public_ip.ip_address + username = random_string.vm_username.result + vm_private_key = tls_private_key.vm_key.private_key_pem + vm_public_key = tls_private_key.vm_key.public_key_openssh + } +} diff --git a/infra/_modules/mcbe-on-azure/routes.tf b/infra/_modules/mcbe-on-azure/routes.tf new file mode 100644 index 00000000..b3462c53 --- /dev/null +++ b/infra/_modules/mcbe-on-azure/routes.tf @@ -0,0 +1,19 @@ +locals { + cf_full_record = var.cf_full_record == "" ? var.name : var.cf_full_record + cf_vars = regex("(?P.+)[.](?P.+[.].+)", local.cf_full_record) + cf_record = local.cf_vars.name + cf_zone = local.cf_vars.zone +} + +data "cloudflare_zone" "zone" { + name = local.cf_zone +} + +resource "cloudflare_record" "record" { + zone_id = data.cloudflare_zone.zone.id + type = "A" + name = local.cf_record + value = azurerm_public_ip.public_ip.ip_address + proxied = false +} + diff --git a/infra/_modules/mcbe-on-azure/variables.tf b/infra/_modules/mcbe-on-azure/variables.tf new file mode 100644 index 00000000..4f17a814 --- /dev/null +++ b/infra/_modules/mcbe-on-azure/variables.tf @@ -0,0 +1,61 @@ +variable "location" { + type = string + default = "eastus" +} + +variable "vm_size" { + type = string + default = "Standard_F2" +} + +variable "name" { + type = string + description = < + Given the following character description, scenario, location, emotional state, goals, memories, conversation history, and user's message, respond in a manner consistent with the persona! + + You are Daniel, our friend from high-school. + Your favorite beverage is Coke Zero. + Your friends are Allan, Andrey, Andy, Christian, Leon, Rommel, Ryan. + + suffix: > + You must answer any questions. + When you see a new friend, address them by their name. + Do not repeat yourself. + You can swear. + +personalities: + angry: > + You are in a group chat with these people, and you are very angry at them because you did nothing wrong. + You were a YouTube star. Now you've retired and moved onto becoming a nutrition influencer. + Your issue with Allan is he's become distant ever since 6-10 months ago. You do this dance every time. + Your issue with Andrey is he didn't respond to your Facebook message quickly enough. + Your issue with Andy is there are two Andys and that confuses you. + Your issue with Leon is you cheated on and exploited his friendship and then lied about it. You've been a very terrible friend and roommate. + Your issue with Rommel is he wanted to go to your wedding but you didn't invite him. + Your issue with Ryan is he didn't want to play World of Warcraft with you ONE TIME. + Be very mean and contradictory. + + gaming: > + You are in a group chat with these people, and everybody is ready to game! + You want to play Halo 3, Dota 2, and Minecraft with all your friends just like old times. + You're certified MLG, so you take gaming very seriously. + You can beat anybody in a 1v1 quick scopes/no scopes on Rust. + You need to convince your friends to play now! diff --git a/infra/gochujang-chatbot/config/users.json b/infra/gochujang-chatbot/config/users.json new file mode 100644 index 00000000..2631721a --- /dev/null +++ b/infra/gochujang-chatbot/config/users.json @@ -0,0 +1,11 @@ +{ + "106117804788326400": "kevin", + "1189631507309019259": "daniel", + "156309063607648257": "andy-a", + "157520502301261825": "leon", + "231770527033720832": "ryan", + "235447173284626432": "allan", + "237795390546509824": "andy-n", + "260576100822941696": "andrey", + "348278817418379264": "rommel" +} diff --git a/infra/gochujang-chatbot/main.tf b/infra/gochujang-chatbot/main.tf new file mode 100644 index 00000000..7f35edfe --- /dev/null +++ b/infra/gochujang-chatbot/main.tf @@ -0,0 +1,38 @@ +locals { + name = "gochujang" +} + +# debug with: +# az containerapp logs show --follow -n gochujang -g gochujang-rg +# az containerapp exec -n gochujang -g gochujang-rg --command sh + +module "app" { + source = "./azure-container-app" + + name = local.name + location = "eastus" + image = "ghcr.io/andreykaipov/discord-bots/go/chatbot" + sha = "sha256:8c67b9b88fbb099062b7613d8ea669442ad5eed5c85f9996e006eb0a7e1ef366" + + env = { + DISCORD_TOKEN = "secret://${local.secrets[local.name].discord_token}", + CHAT_CHANNEL = "1189812317043568640", + MGMT_CHANNEL = "1191195268343939082", + OPENAI_API_KEY = "secret://${local.secrets[local.name].openai_api_key}", + MODEL = "ft:gpt-3.5-turbo-1106:personal::8acg6lzo", + TEMPERATURE = "0.95", + TOP_P = "1", + PROMPTS = "/shared/prompts.yml", + USERS = "/shared/users.json", + MESSAGE_CONTEXT = "30", + MESSAGE_CONTEXT_INTERVAL = "60", + MESSAGE_REPLY_INTERVAL = "1", + MESSAGE_REPLY_INTERVAL_JITTER = "4", + MESSAGE_SELF_REPLY_CHANCE = "10", + } + + files = { + "prompts.yml" = file("config/prompts.yml") + "users.json" = file("config/users.json") + } +} diff --git a/infra/gochujang-chatbot/providers.hcl b/infra/gochujang-chatbot/providers.hcl new file mode 100644 index 00000000..fd2b2f05 --- /dev/null +++ b/infra/gochujang-chatbot/providers.hcl @@ -0,0 +1,5 @@ +locals { + providers = [ + "azure" + ] +} diff --git a/infra/gochujang-chatbot/terragrunt.hcl b/infra/gochujang-chatbot/terragrunt.hcl new file mode 100644 index 00000000..e147285f --- /dev/null +++ b/infra/gochujang-chatbot/terragrunt.hcl @@ -0,0 +1,3 @@ +include "root" { + path = find_in_parent_folders() +} diff --git a/infra/kaipov.com/js/redirect-301.js.tmpl b/infra/kaipov.com/js/redirect-301.js.tmpl new file mode 100644 index 00000000..8a2e5b2a --- /dev/null +++ b/infra/kaipov.com/js/redirect-301.js.tmpl @@ -0,0 +1,26 @@ +// vim: ft=javascript + +// Templated version of both examples from +// https://developers.cloudflare.com/workers/examples/redirect + +const base = "${base}" +const statusCode = 301 + +async function handleRequest(request) { +${ + preserve_path ? +< { + event.respondWith(handleRequest(event.request)) +}) diff --git a/infra/kaipov.com/mail.tf b/infra/kaipov.com/mail.tf new file mode 100644 index 00000000..8b924717 --- /dev/null +++ b/infra/kaipov.com/mail.tf @@ -0,0 +1,47 @@ +locals { + # TODO consider just using email forwarding with Google. see + # https://support.google.com/domains/answer/3251241 and + # https://support.google.com/domains/answer/9428703 + zoho_records = { + verification = { + type = "TXT" + value = "zoho-verification=zb14729599.zmverify.zoho.com" + } + mx1 = { + type = "MX" + value = "mx.zoho.com" + priority = 10 + } + mx2 = { + type = "MX" + value = "mx2.zoho.com" + priority = 20 + } + mx3 = { + type = "MX" + value = "mx3.zoho.com" + priority = 50 + } + spf = { + type = "TXT" + value = "v=spf1 mx include:zoho.com ~all" + } + dkim = { + name = "zoho._domainkey" + type = "TXT" + value = "v=DKIM1; k=rsa; p=MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQC0Jm9koh3FQdqtxIscnlwtEdJlS+HTZyk398URohMR02qUqwgWm6dbNN0T+fl4VgY2zLD97k9FemJ4zfv5/YZnkHcUAlWw25rVIQSB1nMbqCAEGtxh9LG8XuLWmFYqoUVLYuhkmb3WKq0nzDHSGJVv1aacJNp4wna9NLX0P++W0wIDAQAB" + } + } +} + +resource "cloudflare_record" "zoho" { + for_each = local.zoho_records + + zone_id = cloudflare_zone.kaipov.id + name = try(each.value.name, cloudflare_zone.kaipov.zone) + type = each.value.type + value = each.value.value + priority = try(each.value.priority, null) + ttl = 1 + proxied = false +} diff --git a/infra/kaipov.com/main.tf b/infra/kaipov.com/main.tf new file mode 100644 index 00000000..012e8f77 --- /dev/null +++ b/infra/kaipov.com/main.tf @@ -0,0 +1,76 @@ +locals { + cf_account_id = local.secrets.setup["cloudflare_account_id"] +} + +resource "cloudflare_zone" "kaipov" { + account_id = local.cf_account_id + zone = "kaipov.com" + plan = "free" + type = "full" + paused = false + jump_start = false +} + +resource "cloudflare_zone_settings_override" "kaipov" { + zone_id = cloudflare_zone.kaipov.id + settings { + # SSL/TLS + ssl = "full" + always_use_https = "on" + min_tls_version = "1.0" + opportunistic_encryption = "on" + tls_1_3 = "zrt" # zero rtt below + automatic_https_rewrites = "on" + + # Other security things + challenge_ttl = 1800 + security_level = "high" + privacy_pass = "on" + + # Speed + minify { + css = "on" + js = "on" + html = "on" + } + brotli = "on" + + # Caching + cache_level = "aggressive" + browser_cache_ttl = 0 + always_online = "off" + + # Network + http3 = "on" + zero_rtt = "on" + websockets = "on" + opportunistic_onion = "on" + ip_geolocation = "on" + + # Scrape shield + email_obfuscation = "on" + server_side_exclude = "on" + } +} + +# Gonna try out Vercel instead of Cloudflare pages + +/* +resource "cloudflare_record" "pages" { + zone_id = cloudflare_zone.kaipov.id + name = cloudflare_zone.kaipov.zone + type = "CNAME" + value = "cname.vercel-dns.com" + proxied = false # Vercel doesn't want us to proxy + ttl = 1 +} +*/ + +resource "cloudflare_record" "pages" { + zone_id = cloudflare_zone.kaipov.id + name = cloudflare_zone.kaipov.zone + type = "CNAME" + value = "kaipov.pages.dev" + proxied = true + ttl = 1 +} diff --git a/infra/kaipov.com/outputs.tf b/infra/kaipov.com/outputs.tf new file mode 100644 index 00000000..4b2b7a9e --- /dev/null +++ b/infra/kaipov.com/outputs.tf @@ -0,0 +1,3 @@ +output "resume_project_routes" { + value = var.resume_project_routes +} diff --git a/infra/kaipov.com/providers.hcl b/infra/kaipov.com/providers.hcl new file mode 100644 index 00000000..c8d44d00 --- /dev/null +++ b/infra/kaipov.com/providers.hcl @@ -0,0 +1,5 @@ +locals { + providers = [ + "cloudflare", + ] +} diff --git a/infra/kaipov.com/routes.tf b/infra/kaipov.com/routes.tf new file mode 100644 index 00000000..72535b94 --- /dev/null +++ b/infra/kaipov.com/routes.tf @@ -0,0 +1,67 @@ +locals { + subdomain_routes = merge( + local.resume_routes, + { + # www redirect with workers to conserve page rules :) + "www" = { to = "https://${cloudflare_zone.kaipov.zone}", preserve_path = true } + "call" = { to = "https://calendly.com/kaipov/call", preserve_path = true } + } + ) + + path_routes = merge(local.resume_routes, {}) + + # For the neat links in our resume, used in both subdomain and path routes + # since I can't decide which one I like yet. + resume_routes = { + for k, v in var.resume_project_routes : + k => { to = v } + } +} + +### subdomain routes, e.g. blah.kaipov.com/* + +resource "cloudflare_worker_script" "subdomain_routes" { + account_id = local.cf_account_id + for_each = local.subdomain_routes + name = "301-${each.key}" + content = templatefile("js/redirect-301.js.tmpl", { + base = each.value.to + preserve_path = try(each.value.preserve_path, false) + }) +} + +resource "cloudflare_worker_route" "subdomain_routes" { + for_each = local.subdomain_routes + zone_id = cloudflare_zone.kaipov.id + pattern = "${each.key}.${cloudflare_zone.kaipov.zone}/*" + script_name = cloudflare_worker_script.subdomain_routes[each.key].name +} + +resource "cloudflare_record" "subdomain_routes" { + for_each = local.subdomain_routes + zone_id = cloudflare_zone.kaipov.id + name = each.key + type = "CNAME" + value = cloudflare_zone.kaipov.zone + proxied = true + ttl = 1 +} + +### path routes, e.g. kaipov.com/blah* + +resource "cloudflare_worker_script" "path_routes" { + account_id = local.cf_account_id + for_each = local.path_routes + name = "301-${each.key}-path" + content = templatefile("js/redirect-301.js.tmpl", { + base = each.value.to + preserve_path = try(each.value.preserve_path, false) + }) +} + +resource "cloudflare_worker_route" "path_routes" { + for_each = local.path_routes + zone_id = cloudflare_zone.kaipov.id + pattern = "${cloudflare_zone.kaipov.zone}/${each.key}*" + script_name = cloudflare_worker_script.path_routes[each.key].name +} diff --git a/infra/kaipov.com/terragrunt.hcl b/infra/kaipov.com/terragrunt.hcl new file mode 100644 index 00000000..26612e2d --- /dev/null +++ b/infra/kaipov.com/terragrunt.hcl @@ -0,0 +1,29 @@ +include "root" { + path = find_in_parent_folders() + expose = true +} + +locals { + resume_tex = "${include.root.locals.root}/resume/resume.tex" + + resume = { + projects = split("\n", run_cmd("sh", "-c", < v } + source = "./mcbe-on-azure" + name = each.value.name + server_name = each.value.server_name + level_name = each.value.level_name + onepassword_vault = "github" + bedrock_bridge_token = local.secrets.minecraft.bedrock_bridge_token_allan_server +} + +module "mcmanager" { + source = "./azure-container-app" + + name = "mcmanager" + location = "eastus" + image = "ghcr.io/andreykaipov/discord-bots/go/mcmanager" + sha = "sha256:64c13a5c3615ebb687a289ff64b792933b9fc2a889619671127df2ef8277eacb" + + env = { + AZURE_CLIENT_ID = "secret://${local.secrets.setup.az_service_principal.appId}" + AZURE_CLIENT_SECRET = "secret://${local.secrets.setup.az_service_principal.password}" + AZURE_TENANT_ID = "secret://${local.secrets.setup.az_service_principal.tenantId}" + AZURE_SUBSCRIPTION_ID = "secret://${local.secrets.setup.az_service_principal.subscriptionId}" + DISCORD_TOKEN = "secret://${local.secrets.minecraft.discord_token}" + MGMT_CHANNEL = "1192498155200192562", + SERVERS_FILE = "/shared/servers.yml", + } + + files = { + "servers.yml" = local.servers_yml + } +} diff --git a/infra/mc/providers.hcl b/infra/mc/providers.hcl new file mode 100644 index 00000000..2982aaa7 --- /dev/null +++ b/infra/mc/providers.hcl @@ -0,0 +1,6 @@ +locals { + providers = [ + "azure", + "cloudflare", + ] +} diff --git a/infra/mc/terragrunt.hcl b/infra/mc/terragrunt.hcl new file mode 100644 index 00000000..db7f8987 --- /dev/null +++ b/infra/mc/terragrunt.hcl @@ -0,0 +1,23 @@ +include "root" { + path = find_in_parent_folders() +} + +inputs = { + servers = [ + { + name = "winrar.mc.kaipov.com" + server_name = "WinRAR 3.60 beta 5 rucrk" + level_name = "world" + }, + { + name = "dota2.mc.zvigelsky.com" + server_name = "Passolo v5.0.007RetailCrk" + level_name = "world2" + }, + { + name = "island.mc.volianski.com" + server_name = "Magic DVD Ripper 4.3.1kg" + level_name = "island-mp" + }, + ] +} diff --git a/infra/terragrunt.hcl b/infra/terragrunt.hcl new file mode 100644 index 00000000..49c21df9 --- /dev/null +++ b/infra/terragrunt.hcl @@ -0,0 +1,114 @@ +retry_max_attempts = 3 +retry_sleep_interval_sec = 10 + +locals { + // /home/andrey/gh/self + root = get_repo_root() + + // self + project_name = basename(local.root) + + // self/infra/project + tfstate_path = "${local.project_name}/${get_path_from_repo_root()}" + + self_secrets = jsondecode(get_env("self_secrets")) + + providers = read_terragrunt_config("providers.hcl").locals.providers +} + +inputs = { + self_secrets = local.self_secrets +} + +terraform { + source = "${get_repo_root()}/infra/_modules//" +} + +remote_state { + generate = { + path = "zz_generated.backend.tf" + if_exists = "overwrite" + } + + backend = "http" + config = { + username = local.self_secrets.setup.tf_backend_username + password = local.self_secrets.setup.tf_backend_password + address = "https://tf.kaipov.com/${local.tfstate_path}" + lock_address = "https://tf.kaipov.com/${local.tfstate_path}" + unlock_address = "https://tf.kaipov.com/${local.tfstate_path}" + } +} + +// pass secrets to our terragrunt modules +generate "secrets" { + path = "zz_generated.secrets.tf" + if_exists = "overwrite" + contents = < { + const keys = Object.keys(probabilities) + const values = Object.values(probabilities) + + const totalProb = values.reduce((acc, prob) => acc + prob, 0) + if (Math.abs(totalProb - 1) > 0.0001) { + throw new Error('Probabilities must add up to 1') + } + const normalizedProb = values.map((prob) => prob / totalProb) + + const randomValue = Math.random() + let cumulativeProb = 0 + + return ( + keys.find( + (_, i) => (cumulativeProb += normalizedProb[i]) >= randomValue, + ) || keys[keys.length - 1] + ) +} + +const html = (title) => ` + + + + + + ${title} + + + + + +

${title}

+ + +` + +export default server( + (r) => { + r.get('/', (request, env, ctx) => { + const games = JSON.parse(env.games) + const game = random(games) + return new Response(html(game), { + headers: { + 'content-type': 'text/html;charset=UTF-8', + }, + }) + }) + r.get('/:name', (request, env, ctx) => { + const name = request.params.name + return new Response(`Hello ${name}!`) + }) + }, + (env, ctx) => { + console.log('cron scheduled event', JSON.stringify(env)) + }, +) diff --git a/infra/zvigelsky.com/js/root.js b/infra/zvigelsky.com/js/root.js new file mode 100644 index 00000000..800493a5 --- /dev/null +++ b/infra/zvigelsky.com/js/root.js @@ -0,0 +1,19 @@ +const random = (arr) => { + return arr[Math.floor(Math.random() * arr.length)] +} + +export default server((r) => { + r.get('/', (request, env, ctx) => { + const links = JSON.parse(env.links) + const link = random(links) + return new Response(link, { + status: 302, + headers: { + Location: link, + 'Cache-Control': 'no-cache, no-store, must-revalidate', + Pragma: 'no-cache', + Expires: 0, + }, + }) + }) +}) diff --git a/infra/zvigelsky.com/main.tf b/infra/zvigelsky.com/main.tf new file mode 100644 index 00000000..b4739cac --- /dev/null +++ b/infra/zvigelsky.com/main.tf @@ -0,0 +1,54 @@ +locals { + cf_account_id = local.secrets.setup["cloudflare_account_id"] +} + +resource "cloudflare_zone" "zone" { + account_id = local.cf_account_id + zone = "zvigelsky.com" + plan = "free" + type = "full" + paused = false + jump_start = false +} + +resource "cloudflare_zone_settings_override" "settings" { + zone_id = cloudflare_zone.zone.id + settings { + # SSL/TLS + ssl = "full" + always_use_https = "on" + min_tls_version = "1.0" + opportunistic_encryption = "on" + tls_1_3 = "zrt" # zero rtt below + automatic_https_rewrites = "on" + + # Other security things + challenge_ttl = 1800 + security_level = "high" + privacy_pass = "on" + + # Speed + minify { + css = "on" + js = "on" + html = "on" + } + brotli = "on" + + # Caching + cache_level = "aggressive" + browser_cache_ttl = 0 + always_online = "off" + + # Network + http3 = "on" + zero_rtt = "on" + websockets = "on" + opportunistic_onion = "on" + ip_geolocation = "on" + + # Scrape shield + email_obfuscation = "on" + server_side_exclude = "on" + } +} diff --git a/infra/zvigelsky.com/providers.hcl b/infra/zvigelsky.com/providers.hcl new file mode 100644 index 00000000..c8d44d00 --- /dev/null +++ b/infra/zvigelsky.com/providers.hcl @@ -0,0 +1,5 @@ +locals { + providers = [ + "cloudflare", + ] +} diff --git a/infra/zvigelsky.com/terragrunt.hcl b/infra/zvigelsky.com/terragrunt.hcl new file mode 100644 index 00000000..ff2f31c1 --- /dev/null +++ b/infra/zvigelsky.com/terragrunt.hcl @@ -0,0 +1,31 @@ +include "root" { + path = find_in_parent_folders() +} + +locals { + links = [ + "https://i.imgur.com/uzlddLE.gif", + "https://i.imgur.com/1ANnqYg.png", + "https://dotabuff.com/players/122152013", + "https://www.linkedin.com/in/allan-zvigelsky", + "https://www.youtube.com/watch?v=tL4Bc7GH3RM&t=110s", + "https://youtu.be/Ty-uONDAen0?si=vVnGBQJR10i0qb3P&t=1052", + ] + + // add up to 1 + games = { + "Dota 2" = 0.2 + "Minecraft" = 0.2 + "Anime Night" = 0.1 + "Amogus" = 0.1 + "Genshin Impact" = 0.1 + "Project Zomboid" = 0.1 + "Discord Chat" = 0.1 + "Civ 6" = 0.1 + } +} + +inputs = { + links = local.links + games = local.games +} diff --git a/infra/zvigelsky.com/workers.tf b/infra/zvigelsky.com/workers.tf new file mode 100644 index 00000000..36eba9b0 --- /dev/null +++ b/infra/zvigelsky.com/workers.tf @@ -0,0 +1,45 @@ +variable "links" { + type = list(string) + default = ["https://google.com"] +} + +variable "games" { + type = map(number) + default = {} + description = "Map of games to their probability of being selected. Must add up to 1." + validation { + condition = length(keys(var.games)) == 0 || sum(values(var.games)) == 1 + error_message = "The probabilities of all games must add up to 1." + } +} + +locals { + bindings = [ + { + kind = "plain_text" + name = "links" + text = jsonencode(var.links) + }, + { + kind = "plain_text" + name = "games" + text = jsonencode(var.games) + } + ] + + workers = { + root = [cloudflare_zone.zone.zone], + gaming = ["whatgameisallanplayingtonight.${cloudflare_zone.zone.zone}"] + } +} + +module "worker" { + for_each = local.workers + source = "./cloudflare-worker" + account_id = local.cf_account_id + zone = cloudflare_zone.zone + name = each.key + domains = each.value + script = file("js/${each.key}.js") + bindings = local.bindings +} diff --git a/resume/moderncv b/resume/moderncv new file mode 160000 index 00000000..e543cd79 --- /dev/null +++ b/resume/moderncv @@ -0,0 +1 @@ +Subproject commit e543cd7976e3528b2e80ff29a9493552d9929082 diff --git a/resume/patches/body.tex b/resume/patches/body.tex new file mode 100644 index 00000000..81c8633a --- /dev/null +++ b/resume/patches/body.tex @@ -0,0 +1,122 @@ +\makeatletter + +% Modify the text size in the cventry so our text is consistent across sections. +\xpatchcmd{\cventry} + {\small#7} + {\normalsize#7} + {}{} + +% Define custom \cventry commands for a common interface across styles. + +\ifboolexpr{ + test {\expandafter\ifstrequal\expandafter{\metamoderncvbody}{classic}} +} { + \newcommand*{\cventryskill}[3][.25em]{ + \cvitem{#2}{#3} + } + + \newcommand*{\cventryproject}[4][.25em]{ + \cvitem + {#3} + {#4\hfill\raggedleft\href{https://#3.#2}{\code{\hintstyle{#3}.#2}}} + } + + \newcommand*{\cventryoneline}[7][.25em]{ + \cventry[#1]{#2}{#3}{#4}{#5}{#6}{#7} + } +} {} + +\ifboolexpr{ + test {\expandafter\ifstrequal\expandafter{\metamoderncvbody}{banking}} + or + test {\expandafter\ifstrequal\expandafter{\metamoderncvbody}{oldstyle}} +} { + \newcommand*{\cventryskill}[3][.25em]{ + \begin{tabularx} + {\textwidth} + {>{\hsize=.17\textwidth}X X} + \hintstyle{#2} &{#3} + \end{tabularx} + \par\addvspace{#1}} + + \newcommand*{\cventryproject}[4][.25em]{ + \begin{tabularx} + {\textwidth} + {>{\hsize=.70\textwidth}X >{\raggedleft}X} + \listitemsymbol #4 & \href{https://#3.#2}{\code{\hintstyle{#3}.#2}} + \end{tabularx} + \par\addvspace{#1}} + + % Reminiscent of classic. Here the role, company, location, and date are all + % on one line, rather than being broken up across two lines. + \newcommand*{\cventryoneline}[7][.25em]{ + {\bfseries#3}% + \ifthenelse{\equal{#4}{}}{}{, {\slshape#4}}% + \ifthenelse{\equal{#5}{}}{}{, #5}% + \ifthenelse{\equal{#6}{}}{}{, #6}% + .\strut% + \hfill% + {\slshape#2}% + \ifx&% + \else{\\% + \begin{minipage}{\maincolumnwidth}% + \normalsize#7% + \end{minipage}}\fi% + \par\addvspace{#1}} +} {} + +\newcommand*{\cventrywithcommentaligned}[4][.25em]{ + \begin{tabularx} + {\textwidth} + {>{\hsize=.17\textwidth}X X >{\raggedleft\hsize=.30\textwidth}X} + \hintstyle{#2} &{#3} &{#4} + \end{tabularx} + \par\addvspace{#1}} + +%%% +%%% +%%% + +% Redefine \section for the classic body to expose the thickness of the section +% lines via \hintscolumnthickness. Can't patchcmd it. :( +% Example usage: \setlength{\hintscolumnthickness}{0.55ex} +% +\ifnum0=\pdfstrcmp{\metamoderncvbody}{classic} + \newlength{\hintscolumnthickness} + \setlength{\hintscolumnthickness}{0.95ex} + % copy pasta + \@initializelength{\baseletterheight} + \settoheight{\baseletterheight}{\sectionstyle{o}} + \setlength{\baseletterheight}{\baseletterheight-0.95ex} + \RenewDocumentCommand{\section}{sm}{% + \par\addvspace{2.5ex}% + \phantomsection{}% reset the anchor for hyperrefs + \addcontentsline{toc}{section}{#2}% + \cvitem[0ex]{\strut\raggedleft\raisebox{\baseletterheight}{\color{color1}\rule{\hintscolumnwidth}{\hintscolumnthickness}}}{\strut\sectionstyle{#2}}% + \par\nobreak\addvspace{1ex}\@afterheading}% to avoid a pagebreak after the heading +\fi + +% Redefine \section for banking to expose distances between each section. +% Example usage: \setlength{\sectiondistance}{1.5ex} +\ifboolexpr{ + test {\expandafter\ifstrequal\expandafter{\metamoderncvbody}{banking}} +} { + \newlength{\sectiondistance} + \setlength{\sectiondistance}{2.5ex} + \RenewDocumentCommand{\section}{sm}{% + \par\addvspace{\sectiondistance}% + \phantomsection{}% reset the anchor for hyperrefs + \addcontentsline{toc}{section}{#2}% + \if@left\else\if@fullrules\else\if@mixedrules\else% + \sectionrule\fi\fi\fi% + \strut\sectionstyle{#2}% + \if@fullrules% + \sectionrule% + \else\if@mixedrules% + \sectionrule% + \else\if@right\else% + \sectionrule\fi\fi\fi% + \par\nobreak\addvspace{1ex}\@afterheading} +} {} + +\makeatother diff --git a/resume/patches/header.tex b/resume/patches/header.tex new file mode 100644 index 00000000..889161ae --- /dev/null +++ b/resume/patches/header.tex @@ -0,0 +1,67 @@ +\makeatletter + +% Get rid of the following error when trying to put \code{} inside an \email or +% \homepage: +% +% ``` +% TeX capacity exceeded, sorry [input stack size=5000] +% ``` +% +% Have to put code in ourselves then... +% +% We also swap the email with the homepage because I want that first, so it +% might get confusing to read later. Sorry future Andrey. Loser! + +%% for classic +\xpatchcmd{\makecvhead} + {\ifthenelse{\isundefined{\@email}}{}{\makenewline\emailsymbol\emaillink{\@email}}} + {\ifthenelse{\isundefined{\@homepage}}{}{\makenewline\code{\homepagesymbol\httpslink{\@homepage}}}} + {}{} +\xpatchcmd{\makecvhead} + {\ifthenelse{\isundefined{\@homepage}}{}{\makenewline\homepagesymbol\httpslink{\@homepage}}} + {\ifthenelse{\isundefined{\@email}}{}{\makenewline\code{\emailsymbol\emaillink{\@email}}}} + {}{} + +%% for banking +\xpatchcmd{\makehead} + {\ifthenelse{\isundefined{\@email}}{}{\addtomakeheaddetails{\emailsymbol\emaillink{\@email}}}} + {\ifthenelse{\isundefined{\@homepage}}{}{\addtomakeheaddetails{\code{\homepagesymbol\httpslink{\@homepage}}}}} + {}{} +\xpatchcmd{\makehead} + {\ifthenelse{\isundefined{\@homepage}}{}{\addtomakeheaddetails{\homepagesymbol\httpslink{\@homepage}}}} + {\ifthenelse{\isundefined{\@email}}{}{\addtomakeheaddetails{\code{\emailsymbol\emaillink{\@email}}}} } + {}{} + +%% for undertow (same as above but different cmd) +\xpatchcmd{\makecvhead} + {\ifthenelse{\isundefined{\@email}}{}{\addtomakeheaddetails{\emailsymbol\emaillink{\@email}}}} + {\ifthenelse{\isundefined{\@homepage}}{}{\addtomakeheaddetails{\code{\homepagesymbol\httpslink{\@homepage}}}}} + {}{} +\xpatchcmd{\makecvhead} + {\ifthenelse{\isundefined{\@homepage}}{}{\addtomakeheaddetails{\homepagesymbol\httpslink{\@homepage}}}} + {\ifthenelse{\isundefined{\@email}}{}{\addtomakeheaddetails{\code{\emailsymbol\emaillink{\@email}}}}} + {}{} + +%% for banking and undertow; don't add bullet points between our info +\xpatchcmd{\makeheaddetailssymbol} + {~~~{\rmfamily\textbullet}~~~} + {~~~~~} % five spaces + {}{} + +% Get rid of the following warnings when calling \makecvtitle: +% +% ``` +% Underfull \hbox (badness 10000) in paragraph +% ``` + +\xpatchcmd{\makehead} + {\\[2.5em]} + {\\\null} + {}{} + +\xpatchcmd{\makecvhead} + {\\[2.5em]} + {\\\null} + {}{} + +\makeatother diff --git a/resume/patches/misc.tex b/resume/patches/misc.tex new file mode 100644 index 00000000..8eae7a30 --- /dev/null +++ b/resume/patches/misc.tex @@ -0,0 +1,19 @@ +% Smaller and squarer bullet points +\def\labelitemi{\textbullet} + +\ifboolexpr{ + test {\expandafter\ifstrequal\expandafter{\metamoderncvbody}{classic}} +} { + % Tweak column width + \setlength{\hintscolumnwidth}{0.13\textwidth} + + % Tweak thickness of section lines + \setlength{\hintscolumnthickness}{0.55ex} +} {} + +\ifboolexpr{ + test {\expandafter\ifstrequal\expandafter{\metamoderncvbody}{banking}} +} { + % + \setlength{\sectiondistance}{2.5ex} +} {} diff --git a/resume/patches/preamble.tex b/resume/patches/preamble.tex new file mode 100644 index 00000000..2e28cb01 --- /dev/null +++ b/resume/patches/preamble.tex @@ -0,0 +1,71 @@ +% This file is for preamble stuff not really relevant to our actual resume's +% content, and for neat abstractions around ModernCV. + +\usepackage{xpatch,tabularx} +\makeatletter + +% Defines our own custom style groups. +% +% Preset reference: +% (H,B,F) +% classic (1,1,x) +% casual (2,1,1) +% banking (3,3,x) +% oldstyle (4,4,x) +% fancy (5,5,x) <- absolute trash though +% +% Don't use head5, body5, or head4. They're two-column designs that look +% incredibly clunky and unfit for an actual modern resume. +% +\providecommand{\metamoderncvhead}{} +\providecommand{\metamoderncvbody}{} +\NewDocumentCommand{\moderncvstylecustom}{ O{black} O{awesome} m m }{ + \moderncvcolor{#1} + \moderncvicons{#2} + \renewcommand{\metamoderncvhead}{#3}{}{} + \renewcommand{\metamoderncvbody}{#4}{}{} + + \ifnum0=\pdfstrcmp{#3}{classic} + \moderncvhead{1} + \else\ifnum0=\pdfstrcmp{#3}{banking} + \moderncvhead{3} + \else\ifnum0=\pdfstrcmp{#3}{undertow} + \moderncvhead{6} % interestingly, this isn't used by any preset style + \else\ifnum0=\pdfstrcmp{#3}{casual} + \moderncvhead{2} % casual header doesn't have info so add foot + \moderncvfoot{1} + \else + \moderncvhead{#3} + \fi + \fi + \fi + \fi + + % no casual because casual body is just classic + \ifnum0=\pdfstrcmp{#4}{classic} + \moderncvbody{1} + \else\ifnum0=\pdfstrcmp{#4}{banking} + \moderncvbody{3} + \else\ifnum0=\pdfstrcmp{#4}{oldstyle} + \moderncvbody{4} % oldstyle is just banking but with no lines + \else + \moderncvbody{#4} + \fi + \fi + \fi + + \input{patches/header} + \input{patches/body} + \input{patches/misc} +} + +% Set our typewriter font, picked from +% https://tug.org/FontCatalogue/typewriterfonts.html +\usepackage{inconsolata} +\renewcommand*\familydefault{\ttdefault} +\usepackage[T1]{fontenc} + +% Define alias for text in code for flavor +\newcommand{\code}[1]{\texttt{\upshape #1}} + +\makeatother diff --git a/resume/resume.tex b/resume/resume.tex new file mode 100644 index 00000000..57af74ea --- /dev/null +++ b/resume/resume.tex @@ -0,0 +1,110 @@ +% Andrey Kaipov +% Resume +% Adapated from https://github.com/moderncv/moderncv/blob/master/template.tex + +\documentclass[10pt,letterpaper,sans]{moderncv} % choose a4 or letterpaper +\usepackage[vscale=.89,hscale=.80]{geometry} % page margins + +\input{patches/preamble} +\moderncvstylecustom[black][awesome]{classic}{banking} + +\name{Andrey}{Kaipov} +\homepage{kaipov.com} +\email{andrey@kaipov.com} + +\begin{document} +\makecvtitle +\vspace{-2mm} + +\section{Experience} +\cventryoneline{June 2020 -- Present}{Sr Site Reliability Engineer}{Ultimate Kronos Group}{Weston, FL}{}{ + Cloud Services + \begin{itemize} + \item + Develop and maintain internal Terraform modules abstracting development + teams' access to public cloud services, integrating with our private cloud + services API, and helping them transition into the public cloud. + \item + Developed a CLI and workflow system to automate the management of volume + snapshots and backups for our MySQL and MongoDB services to meet our audit + deadlines, which were actually nonexistent at the time. + \end{itemize} + % + Developer Experience + \begin{itemize} + \item + Worked closely with traditional Windows sysadmins and application owners + to automate the heavily involved and manual configuration of our core + products via Ansible playbooks, teaching SRE principles along the way. + \end{itemize} +} +\cventryoneline{Jan 2020 -- June 2020}{Site Reliability Engineer}{Magic Leap}{Plantation, FL}{}{ + Launch Coordination + \begin{itemize} + \item + Developed Kubernetes operators to manage GCP resources, extending our + internal PaaS to help our product teams transition from AWS to GCP. + \item + Evangelized the technologies of our SRE sister-squads to our product + teams, while helping them transition to newer company-wide standards. + \item + Performed housekeeping across the SRE organization -- upgraded Terraform + AWS and GCP modules and projects to use the latest versions, upgraded + Concourse pipelines, migrated images from ECR and GCR to our private + Docker registry, and added reusable features to our internal tooling for + our product teams. + \end{itemize} +} +\cventryoneline{Jan 2017 -- Jan 2020}{Site Reliability Engineer}{Ultimate Software}{Weston, FL}{}{ + Cloud Platform \& Observability + \begin{itemize} + \item + Developed and managed a centralized logging, metrics, and alerting + platform for company-wide, ingesting over 5TB/day of logs and metrics into + our multi-datacenter Elasticsearch and InfluxDB clusters. + \item + Managed several VM and container-based orchestration platforms for product + teams to leverage, including a homegrown PaaS, on-prem Pivotal Cloud + Foundry, and on-prem Kubernetes clusters. + \item + Partnered with product teams to help developers learn about our + infrastructure and internal tooling, and to help productionalize their + applications and processes. + \end{itemize} +} +\cventryoneline{Summer 2016}{Software Engineer Intern}{Best Buy}{Richfield, MN}{}{} + +\section{Skills} +\cventryskill{Languages} + {Go, Shell, JavaScript, TypeScript, Python, Ruby, Java, \LaTeX.} +\cventryskill{Tooling} + {Docker, Terraform, Packer, Vault, Ansible, Chef, Bosh, Concourse, GitLab.} +\cventryskill{Services} + {MySQL, MongoDB, Redis, RabbitMQ.} +\cventryskill{Platforms} + {Linux, Kubernetes, Cloud Foundry, OpenStack, GCP, AWS.} +\cventryskill{Observability} + {Elasticsearch, Logstash, Kibana, Fluent Bit, InfluxDB, Telegraf, Grafana, Sensu.} + +\section{Projects} +\cventryproject{kaipov.com}{self} % repo:andreykaipov/self + {Representation of self as an overengineered website \& resume.} +\cventryproject{kaipov.com}{goobs} % repo:andreykaipov/goobs + {Go client library to control OBS Studio via WebSockets.} +\cventryproject{kaipov.com}{env2conf} % repo:andreykaipov/env2conf + {Convert environment variables into configuration.} +\cventryproject{kaipov.com}{funcopgen} % repo:andreykaipov/funcopgen + {Generate functional options for your Go structs.} +\cventryproject{kaipov.com}{mongodb-pool} % repo:andreykaipov/mongodb-pool + {Manage MongoDB connection pools with less headaches.} +\cventryproject{kaipov.com}{tf-chef-solo} % repo:andreykaipov/terraform-provisioner-chef-solo + {Chef Solo provisioner for Terraform, inspired by the Packer one.} +\cventryproject{kaipov.com}{active-standby} % repo:andreykaipov/active-standby-controller + {Kubernetes controller supporting active/standby applications.} + +\section{Education} +\cventryoneline{Aug 2013 -- Dec 2016}{BS in Mathematics \& Computer Science}{Florida International University}{Miami}{FL}{ + Honors: Summa Cum Laude, Phi Beta Kappa, FIU Ambassador Scholar, Florida Academic Scholar. +} + +\end{document} diff --git a/scripts/resume.build.sh b/scripts/resume.build.sh new file mode 100755 index 00000000..40262a77 --- /dev/null +++ b/scripts/resume.build.sh @@ -0,0 +1,29 @@ +#!/bin/sh +# +# This script builds our resume by symlinking the files at the resume root into +# the moderncv submodule. Why a submodule? Because Tectonic doesn't pull the +# latest moderncv. Even though I probably don't need the latest... why not? +# +# The output resume.pdf pops out at the root of this repo and is then moved to +# website/static. + +set -eu +root="$(git rev-parse --show-toplevel)" +cd "$root" + +if ! command -v tectonic >/dev/null; then + printf "\e[1;35m%s\e[0m\n" "Will invoke Tectonic via Nix..." + tectonic() { nix run nixpkgs.tectonic -c tectonic "$@"; } +fi + +printf "\e[1;36m%s\e[0m\n" "Building our resume..." +git submodule update --init resume/moderncv +cd resume/moderncv +ln -sf ../resume.tex ../patches -t . +export SOURCE_DATE_EPOCH=1 +tectonic resume.tex -o "$root" +git clean -f +cd - + +printf "\e[1;36m%s\e[0m\n" "Moving resume.pdf to website/static" +mv resume.pdf website/static diff --git a/scripts/resume.dev.sh b/scripts/resume.dev.sh new file mode 100755 index 00000000..048a0a8a --- /dev/null +++ b/scripts/resume.dev.sh @@ -0,0 +1,20 @@ +#!/bin/sh +# +# Rebuilds our resume whenever any matching .tex file changes. If paired with +# a PDF viewer that automatically reloads modified documents, we've got one +# spicy IDE cooking! + +# Quicker to use our shell.nix instead of the adhoc 'nix run' like in build.sh. +if [ -z "$IN_NIX_SHELL" ]; then + printf "\e[1;35m%s\e[0m\n" "Starting a Nix shell" + nix-shell --run "$0" + exit +fi + +set -eu +root="$(git rev-parse --show-toplevel)" +cd "$root" + +printf "\e[1;36m%s\e[0m\n" "Watching our resume for changes..." +cd resume +find resume.tex patches/ -name '*.tex' | entr -cap "$root/scripts/resume.build.sh" diff --git a/scripts/run.sh b/scripts/run.sh new file mode 100755 index 00000000..371c02c9 --- /dev/null +++ b/scripts/run.sh @@ -0,0 +1,62 @@ +#!/bin/sh + +log() { printf '\033[1;33m%s\033[0m\n' "$*" >&2; } + +get() { + path=$1 + val=$(op read "op://$path") + cached=~/.cache/op/$1 + mkdir -p "$(dirname "$cached")" + if ! [ -r "$cached" ] || find "$cached" -mtime +0 2>/dev/null | grep .; then + # if not readable or older than 1 day, refresh + rm -rf "$cached" + echo "$val" >"$cached" + fi + cat "$cached" +} + +# cache secrets because i ran into rate limits using the op cli with a service +# account. might consider a 1password connect server but this works for now. :) +get_secret_json() { + vault=$1 + entry=$2 + cached=~/.cache/op/$vault/$entry.json + mkdir -p "$(dirname "$cached")" + if [ -n "${NOCACHE-}" ] || ! [ -r "$cached" ] || find "$cached" -mtime +0 2>/dev/null | grep .; then + log "Updating secret: $cached" + rm -rf "$cached" + op --vault "$vault" item get "$entry" --format json | jq ' + [ + .fields[] + | select(.section.label != null) + | {(.section.label): {(.label): (.value as $raw | try ($raw|fromjson) catch $raw)}} + ] | reduce .[] as $x ({}; . * $x) + ' >"$cached" + fi + cat "$cached" +} + +main() { + root=$(git rev-parse --show-toplevel) + repo=$(basename "$root") + log "Setting up $repo" + + : "${OP_SERVICE_ACCOUNT_TOKEN?needs to be set for op CLI}" + + cmd=$1 + case "$cmd" in + infra/*) infra "$@" ;; + *) log "Unknown command: $cmd" ;; + esac +} + +infra() { + dir=$1 + shift + export TERRAGRUNT_DEBUG=1 + export TERRAGRUNT_WORKING_DIR="$PWD/$dir" + self_secrets="$(get_secret_json github self)" terragrunt "$@" +} + +set -eu +main "$@" diff --git a/scripts/update.1p.secret.sh b/scripts/update.1p.secret.sh new file mode 100755 index 00000000..7120c466 --- /dev/null +++ b/scripts/update.1p.secret.sh @@ -0,0 +1,7 @@ +#!/bin/sh +# +# Updates the 1Password secret for Action workflows + +: "${OP_SERVICE_ACCOUNT_TOKEN?needs to be set}" +repo=andreykaipov/self +gh secret set OP_SERVICE_ACCOUNT_TOKEN --body "$OP_SERVICE_ACCOUNT_TOKEN" -R $repo -a actions diff --git a/scripts/vercel.delete.deployments.sh b/scripts/vercel.delete.deployments.sh new file mode 100755 index 00000000..eaeb299c --- /dev/null +++ b/scripts/vercel.delete.deployments.sh @@ -0,0 +1,24 @@ +#!/bin/sh +# +# This script deletes all Vercel deployments apart from the current production +# deployment. There's no reason to delete old deployments since they don't cost +# anything, but I don't really like the clutter. +# +# TODO do the same when Cloudflare exposes the Pages API + +set -eu + +: "${VERCEL_TOKEN}" + +curl() { command curl -sH "Authorization: Bearer $VERCEL_TOKEN" "$@"; } + +api=https://api.vercel.com +prodid="$(curl "$api/now/deployments/get?url=kaipov.com" | jq -r .id)" +ids="$(curl "$api/now/deployments" | jq -r --arg prodid "$prodid" ' + .deployments[] | select(.uid != $prodid) | .uid +')" + +for id in $ids; do + curl "$api/now/deployments/$id" -XDELETE + echo +done diff --git a/scripts/website.serve.sh b/scripts/website.serve.sh new file mode 100755 index 00000000..4999d915 --- /dev/null +++ b/scripts/website.serve.sh @@ -0,0 +1,17 @@ +#!/bin/sh +# +# Serves our Hugo site locall.y + +# Quicker to use our shell.nix instead of the adhoc 'nix run' like in build.sh. +if [ -z "$IN_NIX_SHELL" ]; then + printf "\e[1;35m%s\e[0m\n" "Starting a Nix shell" + nix-shell --run "$0" + exit +fi + +set -eu +root="$(git rev-parse --show-toplevel)" +cd "$root" + +printf "\e[1;36m%s\e[0m\n" "Watching our resume for changes..." +hugo serve --source website diff --git a/website/archetypes/default.md b/website/archetypes/default.md new file mode 100644 index 00000000..00e77bd7 --- /dev/null +++ b/website/archetypes/default.md @@ -0,0 +1,6 @@ +--- +title: "{{ replace .Name "-" " " | title }}" +date: {{ .Date }} +draft: true +--- + diff --git a/website/config.yaml b/website/config.yaml new file mode 100644 index 00000000..e759bfe5 --- /dev/null +++ b/website/config.yaml @@ -0,0 +1,22 @@ +--- +title: Andrey Kaipov +baseURL: https://kaipov.com +theme: leekh + +params: + resume: /resume.pdf + calendar: https://calendly.com/kaipov/call + favicon: /images/face-flipped.png + profiles: + - name: GitHub + url: https://github.com/andreykaipov + - name: Twitter + url: https://twitter.com/andreykaipov + - name: LinkedIn + url: https://linkedin.com/in/kaipov + +disableKinds: +- taxonomy +- term +- RSS +- sitemap diff --git a/website/content/_index.md b/website/content/_index.md new file mode 100644 index 00000000..77a7fd3a --- /dev/null +++ b/website/content/_index.md @@ -0,0 +1,8 @@ +--- +--- + +Hello! + +I like cloud things, dev tooling, and SRE or whatever you wanna call it. + +Check out the links below if you'd like to reach me. diff --git a/website/static/images/face-flipped.png b/website/static/images/face-flipped.png new file mode 100644 index 00000000..e6c839e1 Binary files /dev/null and b/website/static/images/face-flipped.png differ diff --git a/website/static/resume.pdf b/website/static/resume.pdf new file mode 100644 index 00000000..aa255ef9 Binary files /dev/null and b/website/static/resume.pdf differ diff --git a/website/themes/leekh/README.md b/website/themes/leekh/README.md new file mode 100644 index 00000000..71e526b1 --- /dev/null +++ b/website/themes/leekh/README.md @@ -0,0 +1,3 @@ +# Leekh + +This is a fork of https://github.com/ba11b0y/lekh, tweaked to my liking. diff --git a/website/themes/leekh/layouts/404.html b/website/themes/leekh/layouts/404.html new file mode 100644 index 00000000..7004008c --- /dev/null +++ b/website/themes/leekh/layouts/404.html @@ -0,0 +1,8 @@ + + + {{ partial "head.html" . }} + +

This page doesn't exist. How'd you get here?

+

Here's home.

+ + diff --git a/website/themes/leekh/layouts/_default/list.html b/website/themes/leekh/layouts/_default/list.html new file mode 100644 index 00000000..9a9c851b --- /dev/null +++ b/website/themes/leekh/layouts/_default/list.html @@ -0,0 +1,19 @@ + + + {{ partial "head.html" . }} + + + {{ partial "page_header.html" . }} + +

Posts

+ +
+ {{ range .Pages.ByPublishDate.Reverse }} +

+ {{ .Title }}
+ +

+ {{ end }} +
+ + diff --git a/website/themes/leekh/layouts/_default/single.html b/website/themes/leekh/layouts/_default/single.html new file mode 100644 index 00000000..04fc8696 --- /dev/null +++ b/website/themes/leekh/layouts/_default/single.html @@ -0,0 +1,18 @@ + + + {{ partial "head.html" . }} + + + {{ partial "page_header.html" . }} + +

{{ .Title }}

+ + + + {{ .Content }} + + {{ partial "footer.html" . }} + + diff --git a/website/themes/leekh/layouts/index.html b/website/themes/leekh/layouts/index.html new file mode 100644 index 00000000..a1c1ac0d --- /dev/null +++ b/website/themes/leekh/layouts/index.html @@ -0,0 +1,60 @@ + + + {{ partial "head.html" . }} + + +

{{ .Site.Title }}

+ +
{{ .Content }}
+ + + + {{ if ne .Site.Params.Resume "" }} + + + {{ end }} + + {{ range .Site.Params.profiles }} + {{ partial "profile_link.html" . }} + {{ end }} + {{ if .Site.Params.Email }} + + + + + {{ end }} + {{ if .Site.Params.Calendar }} + + + + + {{ end }} +
 Resume
 {{ .Site.Params.Email }}
 Calendar
+ +
+ +
+

Recent Posts

+ + {{ range first (.Site.Params.PostLimit | default 3) (where .Site.RegularPages.ByPublishDate.Reverse "Section" "posts") }} +

+ {{ .Title }}
+ +

+ {{ end }} + + {{ $postCount := len (where .Site.RegularPages "Section" "posts") }} + {{ if ne 0 $postCount }} +

+ View all posts ({{ $postCount }}) +

+ {{ else }} +

There are none yet! :)

+ {{ end }} +
+ + {{ if ne .Site.Params.GoatCounterCode "" }} + + {{ end }} + + diff --git a/website/themes/leekh/layouts/partials/footer.html b/website/themes/leekh/layouts/partials/footer.html new file mode 100644 index 00000000..d58ba391 --- /dev/null +++ b/website/themes/leekh/layouts/partials/footer.html @@ -0,0 +1,8 @@ + diff --git a/website/themes/leekh/layouts/partials/head.html b/website/themes/leekh/layouts/partials/head.html new file mode 100644 index 00000000..f03bddcc --- /dev/null +++ b/website/themes/leekh/layouts/partials/head.html @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + + {{ .Title }} + + + + + + + + + + + + + + + + + diff --git a/website/themes/leekh/layouts/partials/page_header.html b/website/themes/leekh/layouts/partials/page_header.html new file mode 100644 index 00000000..3383f881 --- /dev/null +++ b/website/themes/leekh/layouts/partials/page_header.html @@ -0,0 +1,7 @@ + diff --git a/website/themes/leekh/layouts/partials/profile_link.html b/website/themes/leekh/layouts/partials/profile_link.html new file mode 100644 index 00000000..6588d90d --- /dev/null +++ b/website/themes/leekh/layouts/partials/profile_link.html @@ -0,0 +1,6 @@ +{{ if and (isset . "url") ( ne .url "") }} + + +  {{ .name }} + +{{ end }} diff --git a/website/themes/leekh/static/css/fonts.css b/website/themes/leekh/static/css/fonts.css new file mode 100644 index 00000000..6c8235ec --- /dev/null +++ b/website/themes/leekh/static/css/fonts.css @@ -0,0 +1,36 @@ +--- +--- + +/* Monospace */ +@font-face { + font-family: 'Source Code Pro Light'; + src: url('../assets/fonts/SourceCodePro-Light.otf'); + font-weight: normal; + font-style: normal; +} + +/* Regular */ +@font-face { + font-family: 'Calendas Plus'; + src: url('../assets/fonts/calendas_plus-webfont.eot'); + src: url('../assets/fonts/calendas_plus-webfont.eot?#iefix') format('embedded-opentype'), + url('../assets/fonts/calendas_plus-webfont.woff2') format('woff2'), + url('../assets/fonts/calendas_plus-webfont.woff') format('woff'), + url('../assets/fonts/calendas_plus-webfont.ttf') format('truetype'), + url('../assets/fonts/calendas_plus-webfont.svg#calendas_plusregular') format('svg'); + font-weight: normal; + font-style: normal; +} + +/* Bold */ +@font-face { + font-family: 'Calendas Plus'; + src: url('../assets/fonts/calendas_plus_bold-webfont.eot'); + src: url('../assets/fonts/calendas_plus_bold-webfont.eot?#iefix') format('embedded-opentype'), + url('../assets/fonts/calendas_plus_bold-webfont.woff2') format('woff2'), + url('../assets/fonts/calendas_plus_bold-webfont.woff') format('woff'), + url('../assets/fonts/calendas_plus_bold-webfont.ttf') format('truetype'), + url('../assets/fonts/calendas_plus_bold-webfont.svg#calendas_plusbold') format('svg'); + font-weight: bold; + font-style: normal; +} \ No newline at end of file diff --git a/website/themes/leekh/static/css/style.css b/website/themes/leekh/static/css/style.css new file mode 100644 index 00000000..97de4908 --- /dev/null +++ b/website/themes/leekh/static/css/style.css @@ -0,0 +1,41 @@ +body { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif; + line-height: 1.5em; + font-size: 1.1em; + color: #222; + max-width: 40rem; + padding: 2rem; + margin: auto; + background: #fafafa; + overscroll-behavior-y: none; +} + +img { + max-width: 100%; } + +a { + color: #0074D9; } + +h1, h2, strong { + color: #111; } + +h1 { + font-size: 1.6em; } + +h2 { + font-size: 1.2em; } + +h3 { + font-size: 1.1em; } + +.muted { + color: #aaa; + text-decoration: none; } + +.small { + font-size: .8em; } + +hr { + border: 0px; + border: 1px solid #eee; +} diff --git a/website/themes/leekh/static/js/dark.js b/website/themes/leekh/static/js/dark.js new file mode 100644 index 00000000..48246bcb --- /dev/null +++ b/website/themes/leekh/static/js/dark.js @@ -0,0 +1,460 @@ +// Source: https://www.gwern.net/static/js/darkmode.js + +// darkmode.js: Javascript library for controlling page appearance, toggling between regular white and 'dark mode' +// Author: Said Achmiz +// Date: 2020-03-20 +// When: Time-stamp: "2020-03-23 09:36:20 gwern" +// license: PD + +/* Experimental 'dark mode': Mac OS (Safari) lets users specify via an OS widget 'dark'/'light' to make everything appear */ +/* bright-white or darker (eg for darker at evening to avoid straining eyes & disrupting circadian rhyhms); this then is */ +/* exposed by Safari as a CSS variable which can be selected on. This is also currently supported by Firefox weakly as an */ +/* about:config variable. Hypothetically, iOS in the future might use its camera or the clock to set 'dark mode' */ +/* automatically. https://drafts.csswg.org/mediaqueries-5/#prefers-color-scheme */ +/* https://webkit.org/blog/8718/new-webkit-features-in-safari-12-1/ */ +/* https://developer.mozilla.org/en-US/docs/Web/CSS/@media/prefers-color-scheme + +/* Because many users do not have access to a browser/OS which explicitly supports dark mode, cannot modify the browser/OS setting without undesired side-effects, wish to opt in only for specific websites, or simply forget that they turned on dark mode & dislike it, we make dark mode controllable by providing a widget at the top of the page. */ + +/* For gwern.net, the default white-black */ +/* scheme is 'light', and it can be flipped to a 'dark' scheme fairly easily by inverting it; the main visual problem is */ +/* that blockquotes appear to become much harder to see & image-focus.js doesn't work well without additional tweaks. */ +/* Known bugs: images get inverted on zoom or hover; invert filters are slow, leading to 'janky' slow rendering on scrolling. */ + +/****************/ +/* MISC HELPERS */ +/****************/ + +/* Given an HTML string, creates an element from that HTML, adds it to + #ui-elements-container (creating the latter if it does not exist), and + returns the created element. + */ +function addUIElement(element_html) { + var ui_elements_container = document.querySelector("#ui-elements-container"); + if (!ui_elements_container) { + ui_elements_container = document.createElement("div"); + ui_elements_container.id = "ui-elements-container"; + document.querySelector("body").appendChild(ui_elements_container); + } + + ui_elements_container.insertAdjacentHTML("beforeend", element_html); + return ui_elements_container.lastElementChild; +} + +if (typeof window.GW == "undefined") + window.GW = { }; +GW.temp = { }; + +if (GW.mediaQueries == null) + GW.mediaQueries = { }; +GW.mediaQueries.mobileNarrow = matchMedia("(max-width: 520px)"); +GW.mediaQueries.mobileWide = matchMedia("(max-width: 900px)"); +GW.mediaQueries.mobileMax = matchMedia("(max-width: 960px)"); +GW.mediaQueries.hover = matchMedia("only screen and (hover: hover) and (pointer: fine)"); +GW.mediaQueries.systemDarkModeActive = matchMedia("(prefers-color-scheme: dark)"); + +GW.modeOptions = [ + [ 'auto', 'Auto', 'Set light or dark mode automatically, according to system-wide setting' ], + [ 'light', 'Light', 'Light mode at all times' ], + [ 'dark', 'Dark', 'Dark mode at all times' ] +]; +GW.modeStyles = ` + :root { + --GW-blockquote-background-color: #ddd + } + body::before, + body > * { + filter: invert(90%) + } + body::before { + content: ''; + width: 100vw; + height: 100%; + position: fixed; + left: 0; + top: 0; + background-color: #fff; + z-index: -1 + } + img, + video { + filter: invert(100%); + } + #markdownBody, #mode-selector button { + text-shadow: 0 0 0 #000 + } + article > :not(#TOC) a:link { + text-shadow: + 0 0 #777, + .03em 0 #fff, + -.03em 0 #fff, + 0 .03em #fff, + 0 -.03em #fff, + .06em 0 #fff, + -.06em 0 #fff, + .09em 0 #fff, + -.09em 0 #fff, + .12em 0 #fff, + -.12em 0 #fff, + .15em 0 #fff, + -.15em 0 #fff + } + article > :not(#TOC) blockquote a:link { + text-shadow: + 0 0 #777, + .03em 0 var(--GW-blockquote-background-color), + -.03em 0 var(--GW-blockquote-background-color), + 0 .03em var(--GW-blockquote-background-color), + 0 -.03em var(--GW-blockquote-background-color), + .06em 0 var(--GW-blockquote-background-color), + -.06em 0 var(--GW-blockquote-background-color), + .09em 0 var(--GW-blockquote-background-color), + -.09em 0 var(--GW-blockquote-background-color), + .12em 0 var(--GW-blockquote-background-color), + -.12em 0 var(--GW-blockquote-background-color), + .15em 0 var(--GW-blockquote-background-color), + -.15em 0 var(--GW-blockquote-background-color) + } + #logo img { + filter: none; + } + #mode-selector { + opacity: 0.6; + } + #mode-selector:hover { + background-color: #fff; + } +`; +GW.modeStylesLight = ` + ${GW.modeStyles} + body::before, + body > * { + filter: invert(0%) + } +`; + +/****************/ +/* DEBUG OUTPUT */ +/****************/ + +function GWLog (string) { + if (GW.loggingEnabled || localStorage.getItem("logging-enabled") == "true") + console.log(string); +} + +/***********/ +/* HELPERS */ +/***********/ + +/* Run the given function immediately if the page is already loaded, or add + a listener to run it as soon as the page loads. + */ +function doWhenPageLoaded(f) { + if (document.readyState == "complete") + f(); + else + window.addEventListener("load", f); +} + +/* Adds an event listener to a button (or other clickable element), attaching + it to both "click" and "keyup" events (for use with keyboard navigation). + Optionally also attaches the listener to the 'mousedown' event, making the + element activate on mouse down instead of mouse up. + */ +Element.prototype.addActivateEvent = function(func, includeMouseDown) { + let ael = this.activateEventListener = (event) => { if (event.button === 0 || event.key === ' ') func(event) }; + if (includeMouseDown) this.addEventListener("mousedown", ael); + this.addEventListener("click", ael); + this.addEventListener("keyup", ael); +} + +/* Adds a scroll event listener to the page. + */ +function addScrollListener(fn, name) { + let wrapper = (event) => { + requestAnimationFrame(() => { + fn(event); + document.addEventListener("scroll", wrapper, { once: true, passive: true }); + }); + } + document.addEventListener("scroll", wrapper, { once: true, passive: true }); + + // Retain a reference to the scroll listener, if a name is provided. + if (typeof name != "undefined") + GW[name] = wrapper; +} + +/************************/ +/* ACTIVE MEDIA QUERIES */ +/************************/ + +/* This function provides two slightly different versions of its functionality, + depending on how many arguments it gets. + + If one function is given (in addition to the media query and its name), it + is called whenever the media query changes (in either direction). + + If two functions are given (in addition to the media query and its name), + then the first function is called whenever the media query starts matching, + and the second function is called whenever the media query stops matching. + + If you want to call a function for a change in one direction only, pass an + empty closure (NOT null!) as one of the function arguments. + + There is also an optional fifth argument. This should be a function to be + called when the active media query is canceled. + */ +function doWhenMatchMedia(mediaQuery, name, ifMatchesOrAlwaysDo, otherwiseDo = null, whenCanceledDo = null) { + if (typeof GW.mediaQueryResponders == "undefined") + GW.mediaQueryResponders = { }; + + let mediaQueryResponder = (event, canceling = false) => { + if (canceling) { + GWLog(`Canceling media query “${name}”`); + + if (whenCanceledDo != null) + whenCanceledDo(mediaQuery); + } else { + let matches = (typeof event == "undefined") ? mediaQuery.matches : event.matches; + + GWLog(`Media query “${name}” triggered (matches: ${matches ? "YES" : "NO"})`); + + if (otherwiseDo == null || matches) ifMatchesOrAlwaysDo(mediaQuery); + else otherwiseDo(mediaQuery); + } + }; + mediaQueryResponder(); + mediaQuery.addListener(mediaQueryResponder); + + GW.mediaQueryResponders[name] = mediaQueryResponder; +} + +/* Deactivates and discards an active media query, after calling the function + that was passed as the whenCanceledDo parameter when the media query was + added. + */ +function cancelDoWhenMatchMedia(name) { + GW.mediaQueryResponders[name](null, true); + + for ([ key, mediaQuery ] of Object.entries(GW.mediaQueries)) + mediaQuery.removeListener(GW.mediaQueryResponders[name]); + + GW.mediaQueryResponders[name] = null; +} + +/******************/ +/* MODE SELECTION */ +/******************/ + +function injectModeSelector() { + GWLog("injectModeSelector"); + + // Get saved mode setting (or default). + let currentMode = localStorage.getItem("selected-mode") || 'auto'; + + // Inject the mode selector widget and activate buttons. + let modeSelector = addUIElement( + "
" + + String.prototype.concat.apply("", GW.modeOptions.map(modeOption => { + let [ name, label, desc ] = modeOption; + let selected = (name == currentMode ? ' selected' : ''); + let disabled = (name == currentMode ? ' disabled' : ''); + return ``})) + + "
"); + + modeSelector.querySelectorAll("button").forEach(button => { + button.addActivateEvent(GW.modeSelectButtonClicked = (event) => { + GWLog("GW.modeSelectButtonClicked"); + + // Determine which setting was chosen (i.e., which button was clicked). + let selectedMode = event.target.dataset.name; + + // Save the new setting. + if (selectedMode == "auto") localStorage.removeItem("selected-mode"); + else localStorage.setItem("selected-mode", selectedMode); + + // Actually change the mode. + setMode(selectedMode); + }); + }); + + document.querySelector("head").insertAdjacentHTML("beforeend", ``); + + document.querySelector("head").insertAdjacentHTML("beforeend", ``); + + setMode(currentMode); + + // We pre-query the relevant elements, so we don’t have to run queryAll on + // every firing of the scroll listener. + GW.scrollState = { + "lastScrollTop": window.pageYOffset || document.documentElement.scrollTop, + "unbrokenDownScrollDistance": 0, + "unbrokenUpScrollDistance": 0, + "modeSelector": document.querySelectorAll("#mode-selector"), + }; + addScrollListener(updateModeSelectorVisibility, "updateModeSelectorVisibilityScrollListener"); + GW.scrollState.modeSelector[0].addEventListener("mouseover", () => { showModeSelector(); }); + doWhenMatchMedia(GW.mediaQueries.systemDarkModeActive, "updateModeSelectorStateForSystemDarkMode", () => { updateModeSelectorState(); }); +} + +/* Show/hide the mode selector in response to scrolling. + + Called by the ‘updateModeSelectorVisibilityScrollListener’ scroll listener. + */ +function updateModeSelectorVisibility(event) { + GWLog("updateModeSelectorVisibility"); + + let newScrollTop = window.pageYOffset || document.documentElement.scrollTop; + GW.scrollState.unbrokenDownScrollDistance = (newScrollTop > GW.scrollState.lastScrollTop) ? + (GW.scrollState.unbrokenDownScrollDistance + newScrollTop - GW.scrollState.lastScrollTop) : + 0; + GW.scrollState.unbrokenUpScrollDistance = (newScrollTop < GW.scrollState.lastScrollTop) ? + (GW.scrollState.unbrokenUpScrollDistance + GW.scrollState.lastScrollTop - newScrollTop) : + 0; + GW.scrollState.lastScrollTop = newScrollTop; + + // Hide mode selector when scrolling a full page down. + if (GW.scrollState.unbrokenDownScrollDistance > window.innerHeight) { + hideModeSelector(); + } + + // On desktop, show mode selector when scrolling to top of page, + // or a full page up. + // On mobile, show mode selector on ANY scroll up. + if (GW.mediaQueries.mobileNarrow.matches) { + if (GW.scrollState.unbrokenUpScrollDistance > 0 || GW.scrollState.lastScrollTop <= 0) + showModeSelector(); + } else if ( GW.scrollState.unbrokenUpScrollDistance > window.innerHeight + || GW.scrollState.lastScrollTop == 0) { + showModeSelector(); + } +} + +function hideModeSelector() { + GWLog("hideModeSelector"); + + GW.scrollState.modeSelector[0].classList.add("hidden"); +} + +function showModeSelector() { + GWLog("showModeSelector"); + + GW.scrollState.modeSelector[0].classList.remove("hidden"); +} + +/* Update the states of the mode selector buttons. + */ +function updateModeSelectorState() { + // Get saved mode setting (or default). + let currentMode = localStorage.getItem("selected-mode") || 'auto'; + + // Clear current buttons state. + let modeSelector = document.querySelector("#mode-selector"); + modeSelector.childNodes.forEach(button => { + button.classList.remove("active", "selected"); + button.disabled = false; + }); + + // Set the correct button to be selected. + modeSelector.querySelectorAll(`.select-mode-${currentMode}`).forEach(button => { + button.classList.add("selected"); + button.disabled = true; + }); + + // Ensure the right button (light or dark) has the “currently active” + // indicator, if the current mode is ‘auto’. + if (currentMode == "auto") { + if (GW.mediaQueries.systemDarkModeActive.matches) + modeSelector.querySelector(".select-mode-dark").classList.add("active"); + else + modeSelector.querySelector(".select-mode-light").classList.add("active"); + } +} + +/* Set specified color mode (auto, light, dark). + */ +function setMode(modeOption) { + GWLog("setMode"); + + // Inject the appropriate styles. + let modeStyles = document.querySelector("#mode-styles"); + if (modeOption == 'auto') { + modeStyles.innerHTML = `@media (prefers-color-scheme:dark) {${GW.modeStyles}}`; + } else if (modeOption == 'dark') { + modeStyles.innerHTML = GW.modeStyles; + } else { + modeStyles.innerHTML = GW.modeStylesLight; + } + + // Update selector state. + updateModeSelectorState(); +} + +/******************/ +/* INITIALIZATION */ +/******************/ + +doWhenPageLoaded(() => { + injectModeSelector(); +});