Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
adfe462f8f | ||
|
|
446e5444b8 | ||
|
|
78c20162b4 | ||
|
|
28e2c76215 | ||
|
|
1c4ac7f136 | ||
|
|
037edd905a | ||
|
|
4abb613f35 | ||
|
|
629a534e15 | ||
|
|
1a71ddf1b1 | ||
|
|
f076d22663 |
@@ -12,11 +12,20 @@ yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
|
||||
test-results/
|
||||
|
||||
# environment variables
|
||||
.env
|
||||
.env.production
|
||||
|
||||
# certificates / keys (never commit)
|
||||
*.crt
|
||||
*.key
|
||||
*.pem
|
||||
*.pfx
|
||||
*.p12
|
||||
caddy-local-root.crt
|
||||
|
||||
# macOS-specific files
|
||||
.DS_Store
|
||||
|
||||
|
||||
+3
-1
@@ -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"]
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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.
@@ -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()
|
||||
@@ -0,0 +1,5 @@
|
||||
functions-framework==3.8.1
|
||||
opentelemetry-api
|
||||
opentelemetry-sdk
|
||||
opentelemetry-exporter-otlp
|
||||
opentelemetry-instrumentation-logging
|
||||
@@ -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
|
||||
|
||||
Generated
+24
@@ -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",
|
||||
]
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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>"
|
||||
@@ -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
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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 = []
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
Generated
+1678
-17
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,4 @@
|
||||
allowBuilds:
|
||||
esbuild: true
|
||||
protobufjs: true
|
||||
sharp: true
|
||||
@@ -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
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
{
|
||||
"status": "passed",
|
||||
"failedTests": []
|
||||
}
|
||||
Reference in New Issue
Block a user