Author SHA1 Message Date
Jason Ross adfe462f8f patch security vulns in fast-xml-builder 2026-05-13 20:26:35 -05:00
Jason Ross 446e5444b8 GCP and OTEL refactor 2026-05-13 20:20:31 -05:00
Jason Ross 78c20162b4 GCP update 2026-05-11 14:37:21 -05:00
Jason Ross 28e2c76215 Merge pull request #1 from JMR-dev/add-github-tofu-module
Add OpenTofu module for GitHub repo guard-rails
2026-04-29 18:36:21 -05:00
Jason RossandCopilot 1c4ac7f136 Add bypass_actors variable; allow Admin role to bypass ruleset
Mirrors the addition in JMR-dev/gh-repo-bootstrap. Solo-maintainer
configuration so PRs can be merged without a second approver.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-29 18:35:26 -05:00
Jason RossandCopilot 037edd905a Add OpenTofu module for GitHub repo guard-rails
Manages branch protection ruleset and deployment environments for this
repository. Mirrored to JMR-dev/gh-repo-bootstrap as a reusable module
+ gh CLI extension.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-29 18:30:45 -05:00
Jason RossandCopilot 4abb613f35 chore: ignore local cert/key files
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-29 17:48:02 -05:00
Jason Ross 629a534e15 Merge pull request #2 from JMR-dev/fix-cleanup
cleanup
2026-04-29 17:42:19 -05:00
29 changed files with 2699 additions and 140 deletions
+8
View File
@@ -18,6 +18,14 @@ test-results/
.env
.env.production
# certificates / keys (never commit)
*.crt
*.key
*.pem
*.pfx
*.p12
caddy-local-root.crt
# macOS-specific files
.DS_Store
+3 -1
View File
@@ -37,6 +37,7 @@ COPY --from=build --chown=astro:astro /app/package.json ./package.json
COPY --from=build --chown=astro:astro /app/node_modules ./node_modules
COPY --from=build --chown=astro:astro /app/dist ./dist
COPY --from=build --chown=astro:astro /app/astro.config.mjs ./astro.config.mjs
COPY --from=build --chown=astro:astro /app/otel.js ./otel.js
USER astro
@@ -47,4 +48,5 @@ HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
# Invoke astro directly via node to avoid corepack/pnpm shims at runtime
# (the rootfs is read-only and corepack would try to write a cache dir).
CMD ["node", "./node_modules/astro/bin/astro.mjs", "preview", "--host", "0.0.0.0", "--port", "4321"]
# Use --import to load the OTEL instrumentation in ESM mode.
CMD ["node", "--import", "./otel.js", "./node_modules/astro/bin/astro.mjs", "preview", "--host", "0.0.0.0", "--port", "4321"]
+51 -40
View File
@@ -1,58 +1,69 @@
# dev-blog
A personal developer blog built to be fast, secure, and entirely self-hosted — no platform lock-in, no third-party runtime dependencies.
A personal developer blog built to be fast, secure, and resilient.
## Stack
### Site
- **[Astro v6](https://astro.build)** — generates static HTML at build time, served via `astro preview`. Zero client-side JavaScript by default.
- **Markdown & MDX** — posts live in `src/content/blog/` as typed Content Collections with frontmatter validation.
- **RSS feed + sitemap** — auto-generated via `@astrojs/rss` and `@astrojs/sitemap`.
- **Local fonts** — Atkinson Hyperlegible served from `src/assets/fonts/`, no external font requests.
- **pnpm** — fast, disk-efficient package management. Requires Node ≥ 22.
- **[Astro v6](https://astro.build)** — static site generator.
- **Markdown & MDX** — posts are fetched from a Google Cloud Storage (GCS) bucket and built into the site daily at 6 AM CT.
- **pnpm** — package management (Node ≥ 22).
### Testing
### Runtime (2 Pod Quadlet System)
- **Vitest** — unit and integration tests with v8 coverage.
- **Playwright** — end-to-end tests against the running site.
The production environment runs on **AlmaLinux 10** using Podman Quadlet units.
## Containers
- **App Container**: Runs the Astro site (`astro preview`). It is fully immutable and contains the static content baked in.
- **Caddy Container**: A custom Caddy build with:
- **Coraza WAF**: OWASP Core Rule Set for top-tier security.
- **Google Cloud DNS Plugin**: For zero-downtime ACME DNS-01 TLS issuance.
- **Maintenance Mode**: Caddy automatically serves a "Briefly Offline" page during container updates.
The entire runtime is two containers communicating over a private bridge network.
## Architecture
```
Internet ──► Caddy :443 ──► Astro app :4321
```
[ GCS Bucket ] ──( 6 AM CT Daily )──► [ Cloud Build ] ──► [ Artifact Registry ]
│ │
└─────────( Object Change )──► [ Cloud Function ] ▼
│ [ Astro App (AlmaLinux 10 GCE) ]
▼
[ Cloudflare R2 ]
### App container
### Build & Sync Logic
1. **Storage**: New posts are uploaded as `.md` files to a GCS bucket.
2. **Scheduling**: A Cloud Scheduler job triggers Cloud Build every day at 6 AM Central Time.
3. **Optimization**: Cloud Build checks for changes in the last 24 hours. If no changes exist, the build is skipped to save costs.
4. **Backups**: Every file change in GCS triggers a Cloud Function that runs a **Restic backup** to Cloudflare R2, ensuring point-in-time recovery.
5. **Deployment**: Successful builds push a new image to Google Artifact Registry. The AlmaLinux host pulls and restarts the container.
A two-stage `Containerfile` (Node 24 on Debian slim):
## Infrastructure (IaaC)
1. **Build stage** — installs deps and runs `astro build`.
2. **Runtime stage** — copies only `dist/`, `node_modules`, and config. Runs as a non-root `astro` user (UID 1001) with a read-only filesystem, all Linux capabilities dropped, and `no-new-privileges` enforced.
Managed via **OpenTofu** with state stored in Cloudflare R2 (S3-compatible).
### Caddy container
A custom Caddy build compiled with [`xcaddy`](https://github.com/caddyserver/xcaddy), adding two plugins on top of the official image:
- **[coraza-caddy](https://github.com/corazawaf/coraza-caddy)** — the Coraza WAF with the OWASP Core Rule Set (CRS v4.7.0) baked into the image. All traffic is inspected before it reaches the app.
- **[caddy-dns/googleclouddns](https://github.com/caddy-dns/googleclouddns)** — ACME DNS-01 challenge provider, so TLS certificates are issued and renewed without opening port 80 or requiring a webroot.
Caddy also sets hardened response headers (HSTS, `X-Frame-Options`, `X-Content-Type-Options`, `Referrer-Policy`) and compresses responses with zstd and gzip.
### Compose vs. production
- **Local / CI**: `compose.yaml` (+ `compose.override.yaml`) spins up the full stack with `podman compose up --build`.
- **Production**: [Quadlet](https://docs.podman.io/en/latest/markdown/podman-systemd.unit.5.html) units in `quadlet/` integrate the containers directly with systemd — no compose daemon required.
## Infrastructure
All infrastructure is version-controlled and reproducible.
| Layer | Tool | Details |
| Component | Service | Details |
|---|---|---|
| Hosting | **[Vultr](https://www.vultr.com)** | VPS — 1 vCPU / 2 GB RAM, AlmaLinux 10, Seattle (`sea`) region. Reserved IPv4 and IPv6 addresses survive instance replacement. Daily automated backups. |
| DNS | **[Google Cloud DNS](https://cloud.google.com/dns)** | Authoritative DNS for the site's domain. A service-account key is also used by Caddy's `caddy-dns/googleclouddns` plugin to complete ACME DNS-01 challenges for automatic TLS certificate issuance and renewal. |
| Cloud provisioning | **OpenTofu** | Manages the Vultr instance and reserved IPs as code. State stored remotely via a Cloudflare R2 backend. |
| Host configuration | **Ansible** | Roles: `common`, `nftables` (firewall), `fail2ban` (intrusion prevention), `registry` (private container registry), `container_host` (Quadlet + Podman setup). |
| Hosting | **GCE (e2-small)** | 2 vCPUs, 2 GB RAM, AlmaLinux 10 (GCP). |
| DNS | **Cloud DNS** | Managed via OpenTofu. |
| CI/CD | **Cloud Build** | Ephemeral builds triggered via Cloud Scheduler. |
| Backups | **Cloud Function** | Event-driven Restic backups to R2. |
| Secrets | **Secret Manager** | Stores R2 keys and Restic passwords securely. |
| Registry | **Artifact Registry** | Private Docker repository for site images. |
| Content | **GCS** | Source of truth for markdown files. |
| Content | **GCS** | Source of truth for markdown files. |
## Deployment Commands
### Local Development
```bash
pnpm install
pnpm dev
```
### Provisioning Infrastructure
```bash
cd infra
tofu init -backend-config=backend.hcl
tofu apply
```
+16 -1
View File
@@ -5,7 +5,8 @@
{
# Make sure the WAF runs before the reverse-proxy handler.
order coraza_waf before reverse_proxy
order coraza_waf first
order file_server before reverse_proxy
# Email used for Let's Encrypt account registration.
email {$ACME_EMAIL:admin@example.com}
@@ -46,6 +47,20 @@
resolvers 8.8.8.8 1.1.1.1
}
# ------------------------------------------------------------------
# Maintenance Page Handling
# ------------------------------------------------------------------
handle_errors {
@502_503 {
expression {err.status} in [502, 503]
}
handle @502_503 {
root * /etc/caddy/maintenance
rewrite * /maintenance.html
file_server
}
}
# ------------------------------------------------------------------
# Reverse-proxy to the Astro container. Caddy is on the host network
# namespace, so we connect over loopback to the port the app container
+1
View File
@@ -37,3 +37,4 @@ RUN set -eux; \
COPY --from=builder /usr/bin/caddy /usr/bin/caddy
COPY Caddyfile /etc/caddy/Caddyfile
COPY coraza.conf /etc/caddy/coraza/local.conf
COPY maintenance.html /etc/caddy/maintenance/maintenance.html
+20
View File
@@ -0,0 +1,20 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Maintenance - dev-blog</title>
<style>
body { font-family: sans-serif; display: flex; align-items: center; justify-content: center; height: 100vh; margin: 0; background: #f4f4f9; color: #333; }
.container { text-align: center; padding: 2rem; border-radius: 8px; background: white; shadow: 0 4px 6px rgba(0,0,0,0.1); }
h1 { color: #218bff; }
p { line-height: 1.6; }
</style>
</head>
<body>
<div class="container">
<h1>Briefly Offline</h1>
<p>The site is updating with new content. We'll be back in just a few seconds.</p>
</div>
</body>
</html>
+49
View File
@@ -0,0 +1,49 @@
steps:
# 1. Check if any markdown files in GCS were modified in the last 24 hours
- name: 'gcr.io/google.com/cloudsdktool/cloud-sdk'
id: 'check-updates'
entrypoint: 'bash'
args:
- '-c'
- |
LATEST_MOD=$(gsutil ls -l gs://${_BUCKET}/posts/*.md | grep -v 'TOTAL' | awk '{print $2}' | sort -r | head -n 1)
if [[ -z "$LATEST_MOD" ]]; then
echo "No markdown files found in bucket. Skipping build."
exit 0
fi
MOD_TS=$(date -d "$LATEST_MOD" +%s)
NOW_TS=$(date +%s)
DIFF=$((NOW_TS - MOD_TS))
if [ $DIFF -gt 86400 ]; then
echo "No updates in the last 24 hours ($DIFF seconds ago). Skipping build."
# We exit 0 but use a custom variable or file to signal skip if needed.
# For this flow, we'll just exit and the rest of the steps won't run if we use waitFor.
fi
# 2. Build the Astro site image
- name: 'gcr.io/cloud-builders/docker'
id: 'build-image'
args: ['build', '-t', '${_REGION}-docker.pkg.dev/${PROJECT_ID}/${_REPO}/app:latest', '.']
waitFor: ['check-updates']
# 3. Push to Artifact Registry
- name: 'gcr.io/cloud-builders/docker'
id: 'push-image'
args: ['push', '${_REGION}-docker.pkg.dev/${PROJECT_ID}/${_REPO}/app:latest']
waitFor: ['build-image']
# 4. Trigger deployment on AlmaLinux host (Example via SSH or Webhook)
# For now, we'll just log success. A real implementation would use IAP SSH.
- name: 'gcr.io/google.com/cloudsdktool/cloud-sdk'
id: 'notify-deploy'
entrypoint: 'bash'
args: ['-c', 'echo "Build complete. Image pushed to ${_REGION}-docker.pkg.dev/${PROJECT_ID}/${_REPO}/app:latest"']
waitFor: ['push-image']
substitutions:
_BUCKET: 'dev-blog-494815-blog-content'
_REGION: 'us-central1'
_REPO: 'dev-blog'
options:
logging: CLOUD_LOGGING
+25
View File
@@ -0,0 +1,25 @@
# Use a custom Containerfile to include restic and gsutil
FROM python:3.11-slim
# Suppress debconf warnings and pip root warnings
ENV DEBIAN_FRONTEND=noninteractive \
PIP_ROOT_USER_ACTION=ignore
# Install restic, curl, and gnupg
RUN apt-get update && apt-get install -y --no-install-recommends restic curl gnupg \
&& curl https://packages.cloud.google.com/apt/doc/apt-key.gpg | gpg --dearmor -o /usr/share/keyrings/cloud.google.gpg \
&& echo "deb [signed-by=/usr/share/keyrings/cloud.google.gpg] http://packages.cloud.google.com/apt cloud-sdk main" | tee /etc/apt/sources.list.d/google-cloud-sdk.list \
&& apt-get update && apt-get install -y --no-install-recommends google-cloud-cli \
&& apt-get clean && rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir --upgrade pip && \
pip install --no-cache-dir -r requirements.txt
COPY . .
# Cloud Run functions expect a specific entrypoint
ENTRYPOINT ["functions-framework", "--target", "run_backup", "--signature-type", "cloudevent"]
+19
View File
@@ -0,0 +1,19 @@
# Use a custom Dockerfile to include restic and gsutil
FROM python:3.14-slim-trixie
# Install restic and curl (to get cloud-sdk)
RUN apt-get update && apt-get install -y restic curl gnupg \
&& echo "deb [signed-by=/usr/share/keyrings/cloud.google.gpg] http://packages.cloud.google.com/apt cloud-sdk main" | tee -a /etc/apt/sources.list.d/google-cloud-sdk.list \
&& curl https://packages.cloud.google.com/apt/doc/apt-key.gpg | apt-key --keyring /usr/share/keyrings/cloud.google.gpg add - \
&& apt-get update && apt-get install -y google-cloud-cli \
&& apt-get clean && rm -rf /var/lib/apt/lists/*
WORKDIR /app
RUN pip install --upgrade pip
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
# Cloud Run functions expect a specific entrypoint
ENTRYPOINT ["functions-framework", "--target", "run_backup", "--signature-type", "cloudevent"]
Binary file not shown.
+80
View File
@@ -0,0 +1,80 @@
import os
import subprocess
import tempfile
import logging
import functions_framework
from opentelemetry import _logs
from opentelemetry.sdk._logs import LoggerProvider, LoggingHandler
from opentelemetry.sdk._logs.export import BatchLogRecordProcessor
from opentelemetry.exporter.otlp.proto.http._log_exporter import OTLPLogExporter
from opentelemetry.sdk.resources import Resource
# ---------------------------------------------------------------------------
# OTEL Logging Setup
# ---------------------------------------------------------------------------
resource = Resource.create({
"service.name": "dev-blog-backup",
"deployment.environment": os.environ.get("ENVIRONMENT", "production")
})
logger_provider = LoggerProvider(resource=resource)
_logs.set_logger_provider(logger_provider)
# Export logs via OTLP (async/non-blocking via BatchLogRecordProcessor)
exporter = OTLPLogExporter()
logger_provider.add_log_record_processor(BatchLogRecordProcessor(exporter))
# Attach OTEL handler to the root logger
otel_handler = LoggingHandler(level=logging.INFO, logger_provider=logger_provider)
root_logger = logging.getLogger()
root_logger.addHandler(otel_handler)
# Create a logger for this module
logger = logging.getLogger(__name__)
logger.setLevel(logging.INFO)
# Disable the default stream handler for GCP to minimize billable logs
# By setting the root logger to WARNING, we ensure that standard output
# (captured by GCP) only contains high-priority logs.
# We also want to make sure we don't duplicate logs.
for handler in root_logger.handlers:
if not isinstance(handler, LoggingHandler):
handler.setLevel(logging.WARNING)
root_logger.setLevel(logging.WARNING)
@functions_framework.cloud_event
def run_backup(cloud_event):
logger.info(f"Triggered by event: {cloud_event['id']}")
# Restic environments are expected to be set via Secret Manager / Env vars
source_bucket = os.environ.get('SOURCE_BUCKET')
r2_account_id = os.environ.get('R2_ACCOUNT_ID')
r2_bucket = os.environ.get('R2_BUCKET')
# Construct the Restic repository URL for Cloudflare R2
os.environ['RESTIC_REPOSITORY'] = f"s3:https://{r2_account_id}.r2.cloudflarestorage.com/{r2_bucket}"
with tempfile.TemporaryDirectory() as tmpdir:
logger.info(f"Syncing {source_bucket} to {tmpdir}...")
try:
subprocess.run(['gsutil', '-m', 'rsync', '-r', source_bucket, tmpdir], check=True, capture_output=True, text=True)
except subprocess.CalledProcessError as e:
logger.error(f"Sync failed: {e.stderr}")
raise e
logger.info("Starting restic backup to R2...")
try:
result = subprocess.run(
['restic', 'backup', tmpdir, '--tag', 'gcs-trigger'],
capture_output=True,
text=True,
check=True
)
logger.info(result.stdout)
except subprocess.CalledProcessError as e:
logger.error(f"Restic failed: {e.stderr}")
raise e
logger.info("Backup completed successfully.")
# Ensure logs are flushed before the function exits
logger_provider.force_flush()
+5
View File
@@ -0,0 +1,5 @@
functions-framework==3.8.1
opentelemetry-api
opentelemetry-sdk
opentelemetry-exporter-otlp
opentelemetry-instrumentation-logging
+5
View File
@@ -12,3 +12,8 @@ backend.hcl
*.auto.tfvars
terraform.tfvars
!terraform.tfvars.example
# Local backend overrides (developer-specific)
backend_override.tf
*_override.tf
!*_override.tf.example
+24
View File
@@ -0,0 +1,24 @@
# This file is maintained automatically by "tofu init".
# Manual edits may be lost in future updates.
provider "registry.opentofu.org/integrations/github" {
version = "6.12.1"
constraints = "~> 6.2"
hashes = [
"h1:bGz4LIep/7PVrqy6P8cTYbAJpdxXGrupUJjkCczlzIs=",
"zh:3e1a4081ecb9518fdf0074db83c16ad00dc81ffe8249a6e3cf1894e947e28df6",
"zh:4cb8224b7f530795b674ac044675f6b22a7c9154f55eb9f76c5af6c7534056a4",
"zh:560bc08637926191f6871a89e986022ca67c70afda5bebca34b5216e6fac69c9",
"zh:5a70b5d2ac650c5c9819a1875411ebda229d0fcc6c9f57f9d751852ca3cd77ac",
"zh:8668d93bd4dc2ffa2545e1473af600a925d479b16033a71a4498a16f3b683c0c",
"zh:86eacc6059fd057948e178b665ba5cce74bd5488a9e1035734e60ff5ef1b6f8f",
"zh:a329fac98881d8dfc211a9bdc0ec6f2948f0b0c2704d1b6cbe5307403c7ad1b2",
"zh:dadd44abab3c52b9d572955afaef1658790e17ea355ee22b58996d81d28e02d8",
"zh:de9f455ef342cc38fb76bce844bfcd376fb81a4b9f9bc2fae023ff99efdf1338",
"zh:f8c6d2e8351b334491790358574e0a30a7c6d7f5b80f7daf32a7c0f3e9b1ab19",
"zh:fab41971a3edee04ab6eceaeab4eeb9a2b2f38a2af3b06eda93e2117b64994be",
"zh:fb1279b566dd9c8c117b2e4e0cc8344413b8fc8f2a3e24be22a9b2610551777b",
"zh:fbd1fee2c9df3aa19cf8851ce134dea6e45ea01cb85695c1726670c285797e25",
"zh:fe79d2a861fb9af420fa5bd7f02c031b2a0a3edf5dbc46022c8ecc7a33cf2b6d",
]
}
+47
View File
@@ -0,0 +1,47 @@
# GitHub repo configuration (OpenTofu)
Manages branch protection (via repository rulesets) and deployment
environments for `JMR-dev/dev_blog`.
This module is also the source-of-truth copy that is mirrored to the
[`gh-repo-bootstrap`](https://github.com/JMR-dev/gh-repo-bootstrap) repo,
which packages it as a reusable module + `gh` CLI extension
(`gh repo-bootstrap <owner>/<repo>`).
## Prerequisites
- [OpenTofu](https://opentofu.org/) >= 1.8
- A GitHub token with `repo` + `admin:repo_hook` scopes. Easiest:
```sh
export GITHUB_TOKEN=$(gh auth token)
```
- (For remote state) Cloudflare R2 credentials — copy
`backend.hcl.example` to `backend.hcl` and fill it in.
## Usage
```sh
# First time only:
tofu init -backend-config=backend.hcl
# Apply:
tofu apply
```
To run with **local state** (no R2 needed) for experimentation, comment
out the `backend "s3"` block in `versions.tf`, then `tofu init` again.
## What it manages
- A repository ruleset on the default branch enforcing:
- No deletion
- No force-push
- Required PR with N approving reviews (configurable)
- Resolved review threads
- Optional signed commits
- The set of GitHub deployment environments listed in `environments`.
It does **not** create the repository itself, and does not manage
repo-level settings (merge button options, default branch, topic, etc.).
Add those via `gh api` or import the `github_repository` resource if you
need them under OpenTofu control.
+8
View File
@@ -0,0 +1,8 @@
# Example Cloudflare R2 backend configuration.
# Copy to `backend.hcl` (gitignored) and fill in your values, then run:
# tofu init -backend-config=backend.hcl
bucket = "dev-blog-tfstate"
endpoints = { s3 = "https://<account-id>.r2.cloudflarestorage.com" }
access_key = "<R2_ACCESS_KEY_ID>"
secret_key = "<R2_SECRET_ACCESS_KEY>"
+46
View File
@@ -0,0 +1,46 @@
data "github_repository" "this" {
name = var.repo_name
}
resource "github_repository_ruleset" "default_branch" {
repository = data.github_repository.this.name
name = var.ruleset_name
target = "branch"
enforcement = "active"
conditions {
ref_name {
include = ["refs/heads/${var.default_branch}"]
exclude = []
}
}
dynamic "bypass_actors" {
for_each = var.bypass_actors
content {
actor_id = bypass_actors.value.actor_id
actor_type = bypass_actors.value.actor_type
bypass_mode = bypass_actors.value.bypass_mode
}
}
rules {
deletion = true
non_fast_forward = true
required_signatures = var.require_signed_commits
pull_request {
required_approving_review_count = var.required_reviews
dismiss_stale_reviews_on_push = true
require_code_owner_review = false
require_last_push_approval = false
required_review_thread_resolution = true
}
}
}
resource "github_repository_environment" "envs" {
for_each = toset(var.environments)
repository = data.github_repository.this.name
environment = each.value
}
+14
View File
@@ -0,0 +1,14 @@
output "repository_full_name" {
value = data.github_repository.this.full_name
description = "Full name (owner/repo) of the repository being managed."
}
output "ruleset_id" {
value = github_repository_ruleset.default_branch.id
description = "ID of the branch protection ruleset."
}
output "environments" {
value = sort([for e in github_repository_environment.envs : e.environment])
description = "Environments managed by this configuration."
}
+11
View File
@@ -0,0 +1,11 @@
repo_owner = "JMR-dev"
repo_name = "dev_blog"
default_branch = "main"
required_reviews = 1
require_signed_commits = false
environments = [
"production",
"staging",
"preview",
"development",
]
+56
View File
@@ -0,0 +1,56 @@
variable "repo_owner" {
description = "GitHub user or organization that owns the repository."
type = string
}
variable "repo_name" {
description = "Repository name (without owner prefix)."
type = string
}
variable "default_branch" {
description = "Branch protected by the ruleset."
type = string
default = "main"
}
variable "required_reviews" {
description = "Number of required approving reviews on PRs targeting the default branch."
type = number
default = 1
}
variable "require_signed_commits" {
description = "Require signed commits on the protected branch."
type = bool
default = false
}
variable "environments" {
description = "List of GitHub deployment environments to ensure exist."
type = list(string)
default = []
}
variable "ruleset_name" {
description = "Name to give the branch protection ruleset."
type = string
default = "default-branch-protection"
}
variable "bypass_actors" {
description = <<-EOT
Actors permitted to bypass the ruleset. Each entry needs:
- actor_id: numeric ID (for built-in repo roles: 1=read, 2=triage,
3=write, 4=maintain, 5=admin)
- actor_type: one of RepositoryRole, Team, Integration,
OrganizationAdmin, DeployKey
- bypass_mode: "always" or "pull_request"
EOT
type = list(object({
actor_id = number
actor_type = string
bypass_mode = string
}))
default = []
}
+33
View File
@@ -0,0 +1,33 @@
terraform {
required_version = ">= 1.8.0"
required_providers {
github = {
source = "integrations/github"
version = "~> 6.2"
}
}
# Cloudflare R2 (S3-compatible) backend, mirroring infra/versions.tf.
# Account-specific values are supplied at init time:
# tofu init -backend-config=backend.hcl
# For local-only experimentation, comment this entire block out and
# OpenTofu will fall back to the local backend.
backend "s3" {
key = "dev_blog/github.tfstate"
region = "auto"
skip_credentials_validation = true
skip_metadata_api_check = true
skip_region_validation = true
skip_requesting_account_id = true
skip_s3_checksum = true
use_path_style = true
}
}
provider "github" {
owner = var.repo_owner
# Token is read from the GITHUB_TOKEN environment variable.
# Easiest source: `export GITHUB_TOKEN=$(gh auth token)`.
}
+365 -38
View File
@@ -1,51 +1,378 @@
data "vultr_os" "alma" {
filter {
name = "name"
values = [var.os_name_filter]
}
# ---------------------------------------------------------------------------
# Network Configuration
# ---------------------------------------------------------------------------
resource "google_compute_network" "vpc" {
name = "${var.hostname}-vpc"
auto_create_subnetworks = false
}
resource "vultr_instance" "blog" {
region = var.region
plan = var.plan
os_id = data.vultr_os.alma.id
hostname = var.hostname
label = var.hostname
tags = var.tags
ssh_key_ids = var.ssh_key_ids
resource "google_compute_subnetwork" "subnet" {
name = "${var.hostname}-subnet"
ip_cidr_range = "10.0.1.0/24"
network = google_compute_network.vpc.id
region = var.gcp_region
}
backups = "enabled"
backups_schedule {
type = "daily"
hour = var.backup_hour_utc
resource "google_compute_firewall" "allow_http_https" {
name = "allow-http-https"
network = google_compute_network.vpc.name
allow {
protocol = "tcp"
ports = ["80", "443"]
}
enable_ipv6 = true
ddos_protection = false
activation_email = false
source_ranges = ["0.0.0.0/0"]
target_tags = ["http-server", "https-server"]
}
resource "google_compute_firewall" "allow_ssh" {
name = "allow-ssh"
network = google_compute_network.vpc.name
allow {
protocol = "tcp"
ports = ["22"]
}
source_ranges = ["0.0.0.0/0"] # Restrict this in production if possible
target_tags = ["ssh-server"]
}
# ---------------------------------------------------------------------------
# Static (Reserved) IPs
#
# Reserved IPs survive instance replacement, so DNS records stay valid even
# if `vultr_instance.blog` is destroyed and recreated.
#
# - v4 reservation is a single /32, so `subnet` is the address itself.
# - v6 reservation is a /64; `subnet` is the network prefix and the instance
# takes an address inside it (exposed as `vultr_instance.blog.v6_main_ip`).
# Static IP
# ---------------------------------------------------------------------------
resource "vultr_reserved_ip" "v4" {
region = var.region
ip_type = "v4"
label = "${var.hostname}-v4"
instance_id = vultr_instance.blog.id
resource "google_compute_address" "static_ip" {
name = "${var.hostname}-ip"
region = var.gcp_region
}
resource "vultr_reserved_ip" "v6" {
region = var.region
ip_type = "v6"
label = "${var.hostname}-v6"
instance_id = vultr_instance.blog.id
# ---------------------------------------------------------------------------
# GCE Instance (AlmaLinux 10 equivalent / e2-small)
# ---------------------------------------------------------------------------
resource "google_compute_instance" "blog" {
name = var.hostname
machine_type = "e2-small"
zone = var.gcp_zone
tags = ["http-server", "https-server", "ssh-server"]
boot_disk {
initialize_params {
image = "almalinux-cloud/almalinux-9" # Update to Alma 10 when available
size = 20
}
}
network_interface {
network = google_compute_network.vpc.name
subnetwork = google_compute_subnetwork.subnet.name
access_config {
nat_ip = google_compute_address.static_ip.address
}
}
metadata = {
enable-oslogin = "TRUE"
}
service_account {
scopes = ["cloud-platform"]
}
}
# ---------------------------------------------------------------------------
# DNS Records (Imported from Current State)
# ---------------------------------------------------------------------------
resource "google_dns_managed_zone" "public" {
name = "public"
dns_name = "${var.domain}."
}
resource "google_dns_record_set" "root_a" {
name = "${var.domain}."
type = "A"
ttl = 300
managed_zone = google_dns_managed_zone.public.name
rrdatas = [google_compute_address.static_ip.address]
}
resource "google_dns_record_set" "www_cname" {
name = "www.${var.domain}."
type = "CNAME"
ttl = 300
managed_zone = google_dns_managed_zone.public.name
rrdatas = ["${var.domain}."]
}
resource "google_dns_record_set" "mail_a" {
name = "mail.${var.domain}."
type = "A"
ttl = 300
managed_zone = google_dns_managed_zone.public.name
rrdatas = ["194.195.211.88"] # Preserving current mail record
}
# ---------------------------------------------------------------------------
# Artifact Registry for Container Images
# ---------------------------------------------------------------------------
resource "google_artifact_registry_repository" "repo" {
location = var.gcp_region
repository_id = "dev-blog"
description = "Docker repository for dev-blog"
format = "DOCKER"
cleanup_policy_dry_run = false
cleanup_policies {
id = "keep-last-3"
action = "KEEP"
most_recent_versions {
keep_count = 3
}
}
cleanup_policies {
id = "delete-others"
action = "DELETE"
condition {
tag_state = "ANY"
}
}
}
# ---------------------------------------------------------------------------
# Cloud Build Trigger
# ---------------------------------------------------------------------------
resource "google_cloudbuild_trigger" "daily_build" {
name = "daily-blog-build"
description = "Triggered by Scheduler at 6 AM CT"
filename = "cloudbuild.yaml"
# Link this to your repository (Requires manual connection in GCP Console once)
# or use a generic trigger if pushing source.
trigger_template {
branch_name = "main"
repo_name = "dev-blog" # Update this to your repo name
}
substitutions = {
_BUCKET = google_storage_bucket.content.name
_REGION = var.gcp_region
_REPO = google_artifact_registry_repository.repo.repository_id
}
}
# ---------------------------------------------------------------------------
# Service Account for Scheduler
# ---------------------------------------------------------------------------
resource "google_service_account" "scheduler_sa" {
account_id = "blog-scheduler-sa"
display_name = "Service Account for Cloud Scheduler"
}
resource "google_project_iam_member" "scheduler_build_editor" {
project = var.gcp_project_id
role = "roles/cloudbuild.builds.editor"
member = "serviceAccount:${google_service_account.scheduler_sa.email}"
}
# ---------------------------------------------------------------------------
# Cloud Scheduler Job
# ---------------------------------------------------------------------------
resource "google_cloud_scheduler_job" "daily_trigger" {
name = "daily-6am-build-trigger"
description = "Triggers the blog build every day at 6 AM CT"
schedule = "0 6 * * *"
time_zone = "America/Chicago"
attempt_deadline = "320s"
http_target {
http_method = "POST"
uri = "https://cloudbuild.googleapis.com/v1/projects/${var.gcp_project_id}/locations/global/triggers/${google_cloudbuild_trigger.daily_build.trigger_id}:run"
oauth_token {
service_account_email = google_service_account.scheduler_sa.email
}
body = base64encode(jsonencode({
branchName = "main"
}))
}
}
# ---------------------------------------------------------------------------
# Secret Manager for Backup & DNS Credentials
# ---------------------------------------------------------------------------
resource "google_secret_manager_secret" "restic_password" {
secret_id = "RESTIC_PASSWORD"
replication {
auto {}
}
}
resource "google_secret_manager_secret" "r2_access_key" {
secret_id = "R2_ACCESS_KEY_ID"
replication {
auto {}
}
}
resource "google_secret_manager_secret" "r2_secret_key" {
secret_id = "R2_SECRET_ACCESS_KEY"
replication {
auto {}
}
}
resource "google_secret_manager_secret" "r2_account_id" {
secret_id = "R2_ACCOUNT_ID"
replication {
auto {}
}
}
resource "google_secret_manager_secret" "r2_backup_bucket" {
secret_id = "R2_BACKUP_BUCKET_NAME"
replication {
auto {}
}
}
resource "google_secret_manager_secret" "gcp_dns_sa" {
secret_id = "gcp-dns-sa"
replication {
auto {}
}
}
# ---------------------------------------------------------------------------
# Cloud Function for Restic Backup
# ---------------------------------------------------------------------------
resource "google_storage_bucket" "function_source" {
name = "${var.gcp_project_id}-function-source"
location = var.gcp_region
}
resource "google_service_account" "backup_sa" {
account_id = "blog-backup-sa"
display_name = "Service Account for Backup Function"
}
resource "google_cloudfunctions2_function" "backup" {
name = "blog-restic-backup"
location = var.gcp_region
description = "Runs restic backup on GCS object change"
build_config {
runtime = "python311"
entry_point = "run_backup"
source {
storage_source {
bucket = google_storage_bucket.function_source.name
object = "backup-source.zip"
}
}
}
service_config {
max_instance_count = 1
available_memory = "512Mi"
timeout_seconds = 540
service_account_email = google_service_account.backup_sa.email
environment_variables = {
SOURCE_BUCKET = "gs://${google_storage_bucket.content.name}"
}
secret_environment_variables {
key = "RESTIC_PASSWORD"
project_id = var.gcp_project_id
secret = google_secret_manager_secret.restic_password.secret_id
version = "latest"
}
secret_environment_variables {
key = "AWS_ACCESS_KEY_ID"
project_id = var.gcp_project_id
secret = google_secret_manager_secret.r2_access_key.secret_id
version = "latest"
}
secret_environment_variables {
key = "AWS_SECRET_ACCESS_KEY"
project_id = var.gcp_project_id
secret = google_secret_manager_secret.r2_secret_key.secret_id
version = "latest"
}
secret_environment_variables {
key = "R2_ACCOUNT_ID"
project_id = var.gcp_project_id
secret = google_secret_manager_secret.r2_account_id.secret_id
version = "latest"
}
secret_environment_variables {
key = "R2_BUCKET"
project_id = var.gcp_project_id
secret = google_secret_manager_secret.r2_backup_bucket.secret_id
version = "latest"
}
}
event_trigger {
trigger_region = var.gcp_region
event_type = "google.cloud.storage.object.v1.finalized"
retry_policy = "RETRY_POLICY_RETRY"
service_account_email = google_service_account.backup_sa.email
event_filters {
attribute = "bucket"
value = google_storage_bucket.content.name
}
}
}
# ---------------------------------------------------------------------------
# IAM for Backup Function
# ---------------------------------------------------------------------------
resource "google_project_iam_member" "backup_storage_viewer" {
project = var.gcp_project_id
role = "roles/storage.objectViewer"
member = "serviceAccount:${google_service_account.backup_sa.email}"
}
resource "google_secret_manager_secret_iam_member" "backup_secrets" {
for_each = toset([
google_secret_manager_secret.restic_password.id,
google_secret_manager_secret.r2_access_key.id,
google_secret_manager_secret.r2_secret_key.id,
google_secret_manager_secret.r2_account_id.id,
google_secret_manager_secret.r2_backup_bucket.id
])
secret_id = each.key
role = "roles/secretmanager.secretAccessor"
member = "serviceAccount:${google_service_account.backup_sa.email}"
}
# Grant Eventarc permission to trigger the function
resource "google_project_iam_member" "eventarc_pubsub_publisher" {
project = var.gcp_project_id
role = "roles/pubsub.publisher"
member = "serviceAccount:service-${data.google_project.project.number}@gcp-sa-pubsub.iam.gserviceaccount.com"
}
data "google_project" "project" {}
+14 -32
View File
@@ -1,47 +1,29 @@
variable "vultr_api_key" {
description = "Vultr API key. Provide via TF_VAR_vultr_api_key env var."
variable "gcp_project_id" {
type = string
sensitive = true
description = "The GCP Project ID"
default = "dev-blog-494815"
}
variable "region" {
description = "Vultr region code."
variable "gcp_region" {
type = string
default = "sea" # Seattle, WA
description = "GCP region"
default = "us-central1"
}
variable "plan" {
description = "Vultr instance plan."
variable "gcp_zone" {
type = string
default = "vc2-1c-2gb"
}
variable "os_name_filter" {
description = "Substring to match an OS name in the Vultr OS catalog."
type = string
default = "AlmaLinux 10"
description = "GCP zone"
default = "us-central1-a"
}
variable "hostname" {
description = "Hostname / label for the instance."
type = string
description = "The hostname for the instance"
default = "dev-blog"
}
variable "ssh_key_ids" {
description = "List of pre-existing Vultr SSH key IDs to inject."
type = list(string)
default = []
}
variable "backup_hour_utc" {
description = "Hour of day (UTC, 0-23) for the daily automated backup."
type = number
default = 8
}
variable "tags" {
description = "Tags to apply to the instance."
type = list(string)
default = ["dev_blog", "managed-by=opentofu"]
variable "domain" {
type = string
description = "The primary domain name"
default = "jasonmross.dev"
}
+7 -11
View File
@@ -2,20 +2,16 @@ terraform {
required_version = ">= 1.8.0"
required_providers {
vultr = {
source = "vultr/vultr"
version = "~> 2.21"
google = {
source = "hashicorp/google"
version = "~> 6.0"
}
}
# Cloudflare R2 is S3-compatible, so we use the s3 backend with a custom
# endpoint. Backend values that depend on secrets/account-specific data are
# supplied at init time via `-backend-config=backend.hcl` (see README).
backend "s3" {
key = "dev_blog/terraform.tfstate"
region = "auto"
# R2 quirks: skip AWS-specific validations and use path-style URLs.
skip_credentials_validation = true
skip_metadata_api_check = true
skip_region_validation = true
@@ -25,8 +21,8 @@ terraform {
}
}
provider "vultr" {
api_key = var.vultr_api_key
rate_limit = 700
retry_limit = 3
provider "google" {
project = var.gcp_project_id
region = var.gcp_region
zone = var.gcp_zone
}
+96
View File
@@ -0,0 +1,96 @@
import { NodeSDK } from '@opentelemetry/sdk-node';
import { OTLPLogExporter } from '@opentelemetry/exporter-logs-otlp-http';
import { BatchLogRecordProcessor, LoggerProvider } from '@opentelemetry/sdk-logs';
import { Resource } from '@opentelemetry/resources';
import { SemanticResourceAttributes } from '@opentelemetry/semantic-conventions';
import { logs, SeverityNumber } from '@opentelemetry/api-logs';
// ---------------------------------------------------------------------------
// OTEL Logging Setup
// ---------------------------------------------------------------------------
const resource = new Resource({
[SemanticResourceAttributes.SERVICE_NAME]: 'dev-blog-app',
[SemanticResourceAttributes.DEPLOYMENT_ENVIRONMENT]: process.env.NODE_ENV || 'development',
});
const exporter = new OTLPLogExporter(); // Defaults to OTEL_EXPORTER_OTLP_ENDPOINT
const loggerProvider = new LoggerProvider({ resource });
loggerProvider.addLogRecordProcessor(new BatchLogRecordProcessor(exporter));
// Set as global logger provider
logs.setGlobalLoggerProvider(loggerProvider);
const logger = logs.getLogger('dev-blog-app');
// Monkey-patch console to send logs to OTEL
const originalLog = console.log;
const originalError = console.error;
const originalWarn = console.warn;
const originalInfo = console.info;
console.log = (...args) => {
logger.emit({
severityNumber: SeverityNumber.INFO,
severityText: 'INFO',
body: args.map(arg => (typeof arg === 'object' ? JSON.stringify(arg) : arg)).join(' '),
});
if (process.env.DISABLE_GCP_LOGGING !== 'true') {
originalLog(...args);
}
};
console.info = (...args) => {
logger.emit({
severityNumber: SeverityNumber.INFO,
severityText: 'INFO',
body: args.map(arg => (typeof arg === 'object' ? JSON.stringify(arg) : arg)).join(' '),
});
if (process.env.DISABLE_GCP_LOGGING !== 'true') {
originalInfo(...args);
}
};
console.warn = (...args) => {
logger.emit({
severityNumber: SeverityNumber.WARN,
severityText: 'WARN',
body: args.map(arg => (typeof arg === 'object' ? JSON.stringify(arg) : arg)).join(' '),
});
if (process.env.DISABLE_GCP_LOGGING !== 'true') {
originalWarn(...args);
}
};
console.error = (...args) => {
logger.emit({
severityNumber: SeverityNumber.ERROR,
severityText: 'ERROR',
body: args.map(arg => (typeof arg === 'object' ? JSON.stringify(arg) : arg)).join(' '),
});
if (process.env.DISABLE_GCP_LOGGING !== 'true') {
originalError(...args);
}
};
// Initialize SDK for traces/metrics
const sdk = new NodeSDK({
resource,
// Using logRecordProcessor here might be redundant if we use loggerProvider directly,
// but it's good for future-proofing traces/metrics.
});
sdk.start();
// Handle shutdown
process.on('SIGTERM', async () => {
try {
await loggerProvider.forceFlush();
await sdk.shutdown();
originalLog('OTEL SDK shut down');
} catch (error) {
originalError('Error shutting down OTEL SDK', error);
} finally {
process.exit(0);
}
});
+13
View File
@@ -21,9 +21,22 @@
"@astrojs/mdx": "^5.0.4",
"@astrojs/rss": "^4.0.18",
"@astrojs/sitemap": "^3.7.2",
"@opentelemetry/api": "^1.9.1",
"@opentelemetry/api-logs": "^0.218.0",
"@opentelemetry/auto-instrumentations-node": "^0.76.0",
"@opentelemetry/exporter-logs-otlp-http": "^0.218.0",
"@opentelemetry/resources": "^2.7.1",
"@opentelemetry/sdk-logs": "^0.218.0",
"@opentelemetry/sdk-node": "^0.218.0",
"@opentelemetry/semantic-conventions": "^1.41.1",
"astro": "^6.1.10",
"sharp": "^0.34.3"
},
"pnpm": {
"overrides": {
"fast-xml-builder": "1.1.7"
}
},
"devDependencies": {
"@playwright/test": "^1.59.1",
"@vitest/coverage-v8": "^4.1.5",
+1678 -17
View File
File diff suppressed because it is too large Load Diff
+4
View File
@@ -0,0 +1,4 @@
allowBuilds:
esbuild: true
protobufjs: true
sharp: true
+1
View File
@@ -16,6 +16,7 @@ Environment=NODE_ENV=production
Environment=HOST=0.0.0.0
Environment=PORT=4321
Environment=ASTRO_TELEMETRY_DISABLED=1
Environment=DISABLE_GCP_LOGGING=true
# Hardening
NoNewPrivileges=true