mirror of
https://github.com/cole-maxwell1/todo-hetzner.git
synced 2026-10-27 10:52:49 -05:00
feat: add Hetzner-hosted todo application stack
Implement the server-rendered Go todo application with PostgreSQL persistence, Tailwind styling, health checks, and test coverage. Provision production and staging infrastructure with OpenTofu, including private networking, static IPs, Object Storage backups, and load balancing. Configure Rocky Linux hosts with Ansible, rootless Podman, PostgreSQL, WAL-G backup automation, staging restores, and generated inventory.
This commit is contained in:
+15
@@ -0,0 +1,15 @@
|
||||
.DS_Store
|
||||
.env
|
||||
*.tfstate
|
||||
*.tfstate.*
|
||||
*.tfplan
|
||||
.terraform/
|
||||
infra/terraform.tfvars
|
||||
infra/**/*.tfvars
|
||||
ansible/inventory/hosts.yml
|
||||
ansible/group_vars/all/main.yml
|
||||
ansible/group_vars/all/vault.yml
|
||||
ansible/group_vars/prod/vault.yml
|
||||
ansible/group_vars/staging/vault.yml
|
||||
app/bin/
|
||||
app/tailwindcss
|
||||
@@ -0,0 +1,114 @@
|
||||
# AGENTS.md / OpenCode Execution Plan
|
||||
|
||||
## 1. Executive Summary & Objective
|
||||
Build a complete, production-ready, highly maintainable infrastructure and web application stack:
|
||||
1. **Infrastructure:** OpenTofu configs deploying Rocky Linux 10 on Hetzner Cloud, isolated via private networking, configured with S3-compatible Hetzner Object Storage.
|
||||
2. **App:** Lightweight Go To-Do web app using native `html/template`, xorm,and TailwindCSS, running via rootless Podman / systemd containers.
|
||||
3. **Database & Backups:** PostgreSQL running on host/container, using `WAL-G` for continuous archiving and hourly base backups to Hetzner Object Storage.
|
||||
4. **Staging Environment:** Automated daily database sync (6:00 AM and 12:00 PM `America/Chicago`) restoring production S3 state onto a separate Staging instance.
|
||||
5. **Scale Path:** Pre-wired for future multi-instance app scaling behind a Hetzner Cloud Load Balancer.
|
||||
|
||||
---
|
||||
|
||||
## 2. Project Directory Structure
|
||||
Generate the codebase using this strict layout:
|
||||
|
||||
```text
|
||||
.
|
||||
├── infra/
|
||||
│ ├── modules/
|
||||
│ │ ├── compute/ # OpenTofu module for Rocky Linux instances
|
||||
│ │ └── storage/ # OpenTofu module for Hetzner S3 bucket
|
||||
│ ├── environments/
|
||||
│ │ ├── prod/ # Production HCL configs
|
||||
│ │ └── staging/ # Staging HCL configs
|
||||
│ └── main.tf
|
||||
├── ansible/
|
||||
│ ├── playbooks/
|
||||
│ │ ├── setup-node.yml # System setup, Podman, PostgreSQL, WAL-G installation
|
||||
│ │ └── deploy-app.yml # Pull container & reload systemd unit
|
||||
│ ├── roles/
|
||||
│ └── templates/
|
||||
│ ├── wal-g.env.j2 # Hetzner S3 credentials for WAL-G
|
||||
│ ├── db-sync.service.j2
|
||||
│ └── db-sync.timer.j2 # Systemd timer set for America/Chicago
|
||||
├── app/
|
||||
│ ├── cmd/
|
||||
│ │ └── server/
|
||||
│ │ └── main.go
|
||||
│ ├── internal/
|
||||
│ │ ├── db/ # Postgres connections & queries
|
||||
│ │ ├── handler/ # HTTP handlers using html/template
|
||||
│ │ └── model/ # Todo struct definitions
|
||||
│ ├── web/
|
||||
│ │ ├── templates/ # Go HTML templates
|
||||
│ │ │ ├── layouts/
|
||||
│ │ │ └── pages/
|
||||
│ │ └── static/ # Compiled Tailwind CSS output
|
||||
│ ├── Dockerfile # Multi-stage build for Go app + Tailwind CLI
|
||||
│ ├── tailwind.config.js
|
||||
│ ├── go.mod
|
||||
│ └── Makefile
|
||||
└── README.md
|
||||
|
||||
3. Component & Technical Specifications
|
||||
A. Infrastructure (OpenTofu)
|
||||
Use the official hetznercloud/hcloud provider.
|
||||
|
||||
Provision an hcloud_network (private subnet 10.0.1.0/24) and attach both Production and Staging instances.
|
||||
|
||||
Configure hcloud_primary_ip for static public IPv4/IPv6 assignment.
|
||||
|
||||
Include structural outputs for an hcloud_load_balancer to easily attach future app instances.
|
||||
|
||||
B. Application (Golang + TailwindCSS)
|
||||
Standard library net/http router (or lightweight chi).
|
||||
|
||||
UI: Native html/template rendering Todo List operations (Create, Read, Toggle Complete, Delete).
|
||||
|
||||
Styling: Standalone TailwindCSS CLI integrated into a multi-stage Dockerfile (build CSS step -> Go build step -> minimal Scratch/Distroless image).
|
||||
|
||||
Database Driver: jackc/pgx/v5 targeting local PostgreSQL.
|
||||
|
||||
Service Management: App runs containerized via Podman and configured as a systemd user service (app-todo.service).
|
||||
|
||||
C. Database & Continuous Backups (Production)
|
||||
Install PostgreSQL on the Rocky Linux instance.
|
||||
|
||||
Configure WAL streaming and continuous archiving using WAL-G.
|
||||
|
||||
Systemd service executes wal-g backup-push hourly, pushing binary base backups and WAL logs to Hetzner Object Storage (S3 API).
|
||||
|
||||
D. Automated Staging Restore Pipeline
|
||||
Create a systemd timer on the Staging server configured explicitly for America/Chicago timezone:
|
||||
|
||||
db-sync.timer:
|
||||
|
||||
Ini, TOML
|
||||
[Unit]
|
||||
Description=Run DB Restore from S3 Hourly/Daily Snapshot
|
||||
|
||||
[Timer]
|
||||
OnCalendar=*-*-* 06,12:00:00 America/Chicago
|
||||
Persistent=true
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
db-sync.service Execution Steps:
|
||||
|
||||
systemctl stop app-todo
|
||||
|
||||
Execute wal-g backup-fetch LATEST onto PostgreSQL staging instance
|
||||
|
||||
Apply any necessary staging sanitization scripts
|
||||
|
||||
systemctl start app-todo
|
||||
|
||||
4. Execution Rules for OpenCode
|
||||
Idiomatic Code: Write standard, strictly formatted Go code (gofmt).
|
||||
|
||||
Full Implementation: Do not use placeholder code (// TODO: implement later) inside Go handlers or Ansible templates. Generate complete, runnable files.
|
||||
|
||||
No External Framework Bloat: Keep dependencies minimal (xorm for database, standard library for templates/routing).
|
||||
|
||||
Shell Script Verification: Provide a Makefile in the root directory automation tasks: make build-app, make infra-plan, make deploy-ansible.
|
||||
@@ -0,0 +1,39 @@
|
||||
.PHONY: build-app test vet format css infra-init infra-plan infra-validate generate-inventory ansible-syntax deploy-ansible validate
|
||||
|
||||
build-app:
|
||||
$(MAKE) -C app build
|
||||
|
||||
test:
|
||||
cd app && go test ./...
|
||||
|
||||
vet:
|
||||
cd app && go vet ./...
|
||||
|
||||
format:
|
||||
cd app && gofmt -w $$(find . -name '*.go' -not -path './bin/*')
|
||||
tofu -chdir=infra fmt -recursive
|
||||
|
||||
css:
|
||||
$(MAKE) -C app css
|
||||
|
||||
infra-init:
|
||||
tofu -chdir=infra init -backend=false
|
||||
|
||||
infra-plan:
|
||||
tofu -chdir=infra plan -var-file=environments/prod/prod.tfvars -var-file=environments/staging/staging.tfvars
|
||||
|
||||
infra-validate: infra-init
|
||||
tofu -chdir=infra validate
|
||||
|
||||
generate-inventory:
|
||||
tofu -chdir=infra output -raw ansible_inventory > ansible/inventory/hosts.yml
|
||||
|
||||
ansible-syntax:
|
||||
cd ansible && ansible-playbook -i inventory/hosts.example.yml playbooks/setup-node.yml --syntax-check
|
||||
cd ansible && ansible-playbook -i inventory/hosts.example.yml playbooks/deploy-app.yml --syntax-check
|
||||
|
||||
deploy-ansible: generate-inventory
|
||||
cd ansible && ansible-playbook -i inventory/hosts.yml playbooks/setup-node.yml
|
||||
cd ansible && ansible-playbook -i inventory/hosts.yml playbooks/deploy-app.yml
|
||||
|
||||
validate: test vet infra-validate ansible-syntax
|
||||
@@ -0,0 +1,347 @@
|
||||
# Todo on Hetzner
|
||||
|
||||
This repository deploys a server-rendered Go todo application and its supporting infrastructure to Hetzner Cloud. A shared OpenTofu state owns the private network, production and staging Rocky Linux 10 servers, static primary IPs, firewall, Object Storage bucket, and HTTP load balancer. Ansible configures host PostgreSQL, rootless Podman, WAL-G backups, and scheduled staging restores.
|
||||
|
||||
Staging contains an unchanged copy of production todo data. Treat staging hosts, logs, database access, and backups as production-sensitive.
|
||||
|
||||
## Architecture
|
||||
|
||||
- The load balancer accepts HTTP on port 80 and reaches production on `10.0.1.10:8080` over the private network.
|
||||
- The Go application runs as the rootless `todo` user through Quadlet-generated `app-todo.service` with host networking.
|
||||
- PostgreSQL listens only on `127.0.0.1:5432`; the cloud firewall does not expose it.
|
||||
- Production continuously archives WAL and performs an hourly base backup to `s3://<bucket>/prod`.
|
||||
- Staging restores `LATEST` at 06:00 and 12:00 in `America/Chicago`, then resets the application role to the staging-specific password.
|
||||
- Staging PostgreSQL has archiving disabled, and its WAL-G wrapper rejects `wal-push`.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Go 1.22 or newer
|
||||
- OpenTofu 1.8 or newer
|
||||
- Ansible Core 2.16 or newer
|
||||
- Docker or Podman for image builds
|
||||
- `curl` and `make`
|
||||
- A Hetzner Cloud project, API token, SSH key, and Object Storage credentials
|
||||
- A container registry if deploying a non-public image
|
||||
|
||||
The app intentionally uses `xorm.io/xorm` with `github.com/lib/pq`, which is the PostgreSQL driver supported by xorm in this stack.
|
||||
|
||||
## Local Development
|
||||
|
||||
Run a local PostgreSQL database and export a connection URL:
|
||||
|
||||
```sh
|
||||
export DATABASE_URL='postgres://todo_app:local-password@127.0.0.1:5432/todo?sslmode=disable'
|
||||
make css
|
||||
make build-app
|
||||
./app/bin/todo
|
||||
```
|
||||
|
||||
The server listens on `:8080` by default. Override it with `APP_ADDR`. The app runs the additive `todos` table migration at startup.
|
||||
|
||||
Useful checks:
|
||||
|
||||
```sh
|
||||
make test
|
||||
make vet
|
||||
make format
|
||||
make validate
|
||||
docker build -t todo:local app
|
||||
```
|
||||
|
||||
`make css` downloads the pinned standalone Tailwind CLI to `app/tailwindcss`. The Docker build independently downloads the CLI before compiling CSS, so it does not depend on a pre-existing generated stylesheet.
|
||||
|
||||
## Hetzner Credentials And Access
|
||||
|
||||
The stack needs two independent Hetzner credentials:
|
||||
|
||||
- A Hetzner Cloud API token for servers, networks, firewalls, primary IPs, and the load balancer.
|
||||
- An Object Storage access key and secret for the S3 bucket and WAL-G.
|
||||
|
||||
The Cloud API token does not grant Object Storage access.
|
||||
|
||||
### Cloud API Token
|
||||
|
||||
Open the Hetzner Console, select the Cloud project, go to **Security > API Tokens**, and create a **Read & Write** token. Export it as an OpenTofu input:
|
||||
|
||||
```sh
|
||||
export TF_VAR_hcloud_token='replace-with-cloud-api-token'
|
||||
```
|
||||
|
||||
Do not place the token in a tracked file or pass it directly on a command line that is retained in shell history.
|
||||
|
||||
### SSH Key And ID
|
||||
|
||||
Hetzner installs the selected public SSH key on each server. Only upload the public `.pub` file; never upload the corresponding private key.
|
||||
|
||||
Generate an Ed25519 key if one does not already exist:
|
||||
|
||||
```sh
|
||||
ssh-keygen -t ed25519 -f ~/.ssh/id_ed25519
|
||||
```
|
||||
|
||||
Install the Hetzner CLI on macOS, expose the Cloud token to it, and upload the public key:
|
||||
|
||||
```sh
|
||||
brew install hcloud
|
||||
export HCLOUD_TOKEN="$TF_VAR_hcloud_token"
|
||||
hcloud ssh-key create \
|
||||
--name home-mac \
|
||||
--public-key-from-file ~/.ssh/id_ed25519.pub
|
||||
```
|
||||
|
||||
List the project keys and copy the numeric ID from the `ID` column:
|
||||
|
||||
```sh
|
||||
hcloud ssh-key list
|
||||
```
|
||||
|
||||
Configure that value in `prod.tfvars`:
|
||||
|
||||
```hcl
|
||||
ssh_key_ids = [123456]
|
||||
```
|
||||
|
||||
The key can also be uploaded in the Hetzner Console under **Security > SSH Keys**. `hcloud ssh-key list` is the reliable way to obtain its numeric ID.
|
||||
|
||||
### SSH From A Dynamic Home IP
|
||||
|
||||
`ssh_source_cidrs` is required by the current firewall configuration. An empty list blocks SSH and prevents Ansible from connecting. A static address is not required: determine the current public IPv4 address and permit only that address with a `/32` suffix:
|
||||
|
||||
```sh
|
||||
curl -4 https://icanhazip.com
|
||||
```
|
||||
|
||||
For an address such as `198.51.100.25`, configure:
|
||||
|
||||
```hcl
|
||||
ssh_source_cidrs = ["198.51.100.25/32"]
|
||||
```
|
||||
|
||||
If the home address changes, update this value and apply the OpenTofu stack again. The Hetzner Console remains available for firewall recovery if the old address locks out SSH.
|
||||
|
||||
Allowing SSH from the entire internet is possible but not recommended:
|
||||
|
||||
```hcl
|
||||
ssh_source_cidrs = ["0.0.0.0/0", "::/0"]
|
||||
```
|
||||
|
||||
SSH keys still authenticate connections in that configuration, but the hosts receive unnecessary scanning and attack traffic. For stable private administration, use Tailscale or another VPN and remove public SSH after the VPN is operational.
|
||||
|
||||
### Object Storage Credentials
|
||||
|
||||
In the Hetzner Console, open **Object Storage > Access Keys** and generate an access key and secret. Save the secret immediately because it is displayed only once. Export both credentials for OpenTofu:
|
||||
|
||||
```sh
|
||||
export TF_VAR_s3_access_key='replace-with-object-storage-access-key'
|
||||
export TF_VAR_s3_secret_key='replace-with-object-storage-secret-key'
|
||||
```
|
||||
|
||||
Object Storage endpoints use the selected location. For Nuremberg, configure:
|
||||
|
||||
```hcl
|
||||
s3_endpoint = "https://nbg1.your-objectstorage.com"
|
||||
s3_region = "nbg1"
|
||||
```
|
||||
|
||||
The exact endpoint is also shown in the Object Storage access information in the Hetzner Console. The endpoint location and `s3_region` must match. The stack defaults its servers to `nbg1`, but the bucket may use another supported Object Storage location when both values are changed together.
|
||||
|
||||
OpenTofu creates the bucket; do not create it manually. Generate a hard-to-collide lowercase suffix:
|
||||
|
||||
```sh
|
||||
openssl rand -hex 6
|
||||
```
|
||||
|
||||
Use the result in a descriptive bucket name:
|
||||
|
||||
```hcl
|
||||
bucket_name = "cole-todo-prod-backups-a83d109cf241"
|
||||
```
|
||||
|
||||
Bucket names must be 3-63 characters, use lowercase letters, numbers, and hyphens, and start and end with a letter or number. If the provider reports that the name is taken, generate a new suffix. Never include credentials or other secrets in a bucket name.
|
||||
|
||||
Secret values are marked sensitive where the providers permit it, but OpenTofu state can still contain sensitive infrastructure data. Store production state in an encrypted, access-controlled backend.
|
||||
|
||||
## Infrastructure
|
||||
|
||||
The root stack uses one state. The environment directories split input examples for readability; they are not separate root modules or states.
|
||||
|
||||
```sh
|
||||
cp infra/environments/prod/prod.tfvars.example infra/environments/prod/prod.tfvars
|
||||
cp infra/environments/staging/staging.tfvars.example infra/environments/staging/staging.tfvars
|
||||
```
|
||||
|
||||
When using the recommended `TF_VAR_*` environment variables, remove the `hcloud_token`, `s3_access_key`, and `s3_secret_key` placeholder assignments from `prod.tfvars`. Command-line variable files take precedence over environment variables, so leaving the placeholders would override the exported credentials.
|
||||
|
||||
Set the remaining values in `infra/environments/prod/prod.tfvars`:
|
||||
|
||||
```hcl
|
||||
project_name = "todo"
|
||||
network_zone = "eu-central"
|
||||
server_location = "nbg1"
|
||||
server_type = "cx22"
|
||||
ssh_key_ids = [123456]
|
||||
ssh_source_cidrs = ["198.51.100.25/32"]
|
||||
prod_private_ip = "10.0.1.10"
|
||||
|
||||
s3_endpoint = "https://nbg1.your-objectstorage.com"
|
||||
s3_region = "nbg1"
|
||||
bucket_name = "cole-todo-prod-backups-a83d109cf241"
|
||||
```
|
||||
|
||||
Keep the default staging private address in `infra/environments/staging/staging.tfvars` unless it conflicts with another resource:
|
||||
|
||||
```hcl
|
||||
staging_private_ip = "10.0.1.20"
|
||||
```
|
||||
|
||||
Initialize and plan:
|
||||
|
||||
```sh
|
||||
tofu -chdir=infra init
|
||||
tofu -chdir=infra plan \
|
||||
-var-file=environments/prod/prod.tfvars \
|
||||
-var-file=environments/staging/staging.tfvars
|
||||
tofu -chdir=infra apply \
|
||||
-var-file=environments/prod/prod.tfvars \
|
||||
-var-file=environments/staging/staging.tfvars
|
||||
```
|
||||
|
||||
The AWS provider is configured for the Hetzner S3-compatible endpoint with path-style requests. The `hcloud` provider does not create Object Storage buckets.
|
||||
|
||||
The load balancer is provisioned immediately and health-checks `/healthz`. HTTPS termination and DNS are intentionally external operational concerns; add a certificate service to the LB before exposing sensitive traffic on the public internet.
|
||||
|
||||
## Configuration And Deployment
|
||||
|
||||
After a successful OpenTofu apply, generate the inventory directly from the provisioned server names and addresses:
|
||||
|
||||
```sh
|
||||
make generate-inventory
|
||||
```
|
||||
|
||||
This writes `ansible/inventory/hosts.yml`, which is ignored by Git. The generated inventory contains only host names, public and private addresses, the SSH user, and environment labels. It does not contain credentials or database passwords. Inspect it with:
|
||||
|
||||
```sh
|
||||
ANSIBLE_CONFIG=ansible/ansible.cfg \
|
||||
ansible-inventory -i ansible/inventory/hosts.yml --graph
|
||||
```
|
||||
|
||||
Create the shared non-secret Ansible configuration from its example. The destination is ignored by Git:
|
||||
|
||||
```sh
|
||||
cp ansible/group_vars/all/main.yml.example ansible/group_vars/all/main.yml
|
||||
```
|
||||
|
||||
Create one shared Vault for Object Storage credentials and separate environment Vaults for database passwords:
|
||||
|
||||
```sh
|
||||
ansible-vault create ansible/group_vars/all/vault.yml
|
||||
ansible-vault create ansible/group_vars/prod/vault.yml
|
||||
ansible-vault create ansible/group_vars/staging/vault.yml
|
||||
```
|
||||
|
||||
Store the Object Storage credentials needed by WAL-G in `ansible/group_vars/all/vault.yml`:
|
||||
|
||||
```yaml
|
||||
wal_g_access_key: "replace-with-object-storage-access-key"
|
||||
wal_g_secret_key: "replace-with-object-storage-secret-key"
|
||||
```
|
||||
|
||||
Store only the production database password in `ansible/group_vars/prod/vault.yml`:
|
||||
|
||||
```yaml
|
||||
app_db_password: "replace-with-production-database-password"
|
||||
```
|
||||
|
||||
Store a distinct staging password in `ansible/group_vars/staging/vault.yml`:
|
||||
|
||||
```yaml
|
||||
app_db_password: "replace-with-distinct-staging-database-password"
|
||||
```
|
||||
|
||||
Configure the matching non-secret values in `ansible/group_vars/all/main.yml`:
|
||||
|
||||
```yaml
|
||||
wal_g_s3_prefix: s3://cole-todo-prod-backups-a83d109cf241/prod
|
||||
wal_g_s3_endpoint: https://nbg1.your-objectstorage.com
|
||||
wal_g_s3_region: nbg1
|
||||
```
|
||||
|
||||
The bucket name must match `bucket_name` from OpenTofu, and `wal_g_s3_prefix` must end in `/prod`. To run Ansible from the repository root, set `ANSIBLE_CONFIG` explicitly so Ansible can find the project roles:
|
||||
|
||||
```sh
|
||||
ANSIBLE_CONFIG=ansible/ansible.cfg \
|
||||
ansible-playbook \
|
||||
-i ansible/inventory/hosts.yml \
|
||||
ansible/playbooks/setup-node.yml \
|
||||
--ask-vault-pass
|
||||
```
|
||||
|
||||
Alternatively, run from the `ansible` directory:
|
||||
|
||||
```sh
|
||||
cd ansible
|
||||
ansible-playbook -i inventory/hosts.yml playbooks/setup-node.yml --ask-vault-pass
|
||||
```
|
||||
|
||||
The Make deployment target loads `ansible/ansible.cfg` by changing into that directory and regenerates inventory before deployment. Configure `ANSIBLE_VAULT_PASSWORD_FILE` so both playbooks can unlock the Vault files non-interactively:
|
||||
|
||||
```sh
|
||||
export ANSIBLE_VAULT_PASSWORD_FILE="$HOME/.config/ansible/todo-vault-password"
|
||||
make deploy-ansible
|
||||
```
|
||||
|
||||
Build and push the image, then run configuration in this order:
|
||||
|
||||
```sh
|
||||
docker build -t registry.example.com/todo:2026-08-06 app
|
||||
docker push registry.example.com/todo:2026-08-06
|
||||
make deploy-ansible
|
||||
```
|
||||
|
||||
`setup-node.yml` installs and configures the host. `deploy-app.yml` optionally authenticates to a registry, pulls the image, reloads the user systemd manager, restarts `app-todo.service`, and checks health. Omit registry variables for a public image. Run `make generate-inventory` again after OpenTofu replaces a server or changes a public address.
|
||||
|
||||
## Backup Operations
|
||||
|
||||
Inspect production backup status:
|
||||
|
||||
```sh
|
||||
systemctl status wal-g-backup.timer
|
||||
systemctl status wal-g-backup.service
|
||||
journalctl -u wal-g-backup.service
|
||||
sudo -u postgres /usr/local/sbin/wal-g-postgres backup-list
|
||||
```
|
||||
|
||||
The hourly service runs `backup-push` and retains the newest configured number of full backups with their dependent WAL. Object Storage bucket versioning is enabled, and incomplete multipart uploads expire after seven days.
|
||||
|
||||
Trigger an immediate production backup:
|
||||
|
||||
```sh
|
||||
systemctl start wal-g-backup.service
|
||||
```
|
||||
|
||||
## Staging Restore Operations
|
||||
|
||||
Inspect or trigger staging synchronization:
|
||||
|
||||
```sh
|
||||
systemctl list-timers db-sync.timer
|
||||
systemctl start db-sync.service
|
||||
journalctl -u db-sync.service
|
||||
```
|
||||
|
||||
The restore script refuses to delete any directory except `/var/lib/pgsql/data`, requires an initialized PostgreSQL directory before cleanup, and requires a production WAL-G prefix. It stops the user application and PostgreSQL, clears `PGDATA`, fetches `LATEST`, configures WAL recovery, waits for recovery to complete, resets the application role password, and starts the app only after success.
|
||||
|
||||
If restore fails, the app remains stopped. Diagnose and rerun `db-sync.service`; do not manually point staging at a different data directory.
|
||||
|
||||
## Important Paths
|
||||
|
||||
- Application entrypoint: `app/cmd/server/main.go`
|
||||
- HTTP handlers and tests: `app/internal/handler/`
|
||||
- Embedded templates and CSS: `app/web/`
|
||||
- Shared OpenTofu stack: `infra/main.tf`
|
||||
- Generated inventory output: `infra/outputs.tf`
|
||||
- Reusable modules: `infra/modules/compute/`, `infra/modules/storage/`
|
||||
- Node setup: `ansible/playbooks/setup-node.yml`
|
||||
- Rootless Quadlet: `ansible/roles/app/templates/app-todo.container.j2`
|
||||
- Production backup units: `ansible/roles/wal_g/templates/wal-g-backup.*`
|
||||
- Guarded staging restore: `ansible/roles/wal_g/templates/db-sync.sh.j2`
|
||||
@@ -0,0 +1,11 @@
|
||||
[defaults]
|
||||
roles_path = roles
|
||||
interpreter_python = auto_silent
|
||||
host_key_checking = True
|
||||
retry_files_enabled = False
|
||||
|
||||
[ssh_connection]
|
||||
ssh_args = -o StrictHostKeyChecking=accept-new
|
||||
|
||||
[privilege_escalation]
|
||||
become = True
|
||||
@@ -0,0 +1,22 @@
|
||||
app_user: todo
|
||||
app_image: registry.example.com/todo:latest
|
||||
app_port: 8080
|
||||
app_db_name: todo
|
||||
app_db_user: todo_app
|
||||
|
||||
postgres_data_dir: /var/lib/pgsql/data
|
||||
postgres_service: postgresql
|
||||
|
||||
wal_g_version: v3.0.7
|
||||
wal_g_download_url: "https://github.com/wal-g/wal-g/releases/download/{{ wal_g_version }}/wal-g-pg-ubuntu-20.04-amd64.tar.gz"
|
||||
wal_g_s3_prefix: s3://replace-with-backup-bucket/prod
|
||||
wal_g_s3_endpoint: https://nbg1.your-objectstorage.com
|
||||
wal_g_s3_region: nbg1
|
||||
wal_g_access_key: "replace-with-object-storage-access-key"
|
||||
wal_g_secret_key: "replace-with-object-storage-secret-key"
|
||||
wal_g_retention_count: 7
|
||||
|
||||
# Optional private registry credentials. Omit both for public images.
|
||||
# registry_username: registry-user
|
||||
# registry_password: registry-password
|
||||
# registry_server: registry.example.com
|
||||
@@ -0,0 +1 @@
|
||||
app_db_password: "replace-with-production-database-password"
|
||||
@@ -0,0 +1 @@
|
||||
app_db_password: "replace-with-distinct-staging-database-password"
|
||||
@@ -0,0 +1,16 @@
|
||||
all:
|
||||
vars:
|
||||
ansible_user: root
|
||||
children:
|
||||
prod:
|
||||
hosts:
|
||||
todo-prod:
|
||||
ansible_host: 203.0.113.10
|
||||
private_ip: 10.0.1.10
|
||||
deployment_environment: prod
|
||||
staging:
|
||||
hosts:
|
||||
todo-staging:
|
||||
ansible_host: 203.0.113.20
|
||||
private_ip: 10.0.1.20
|
||||
deployment_environment: staging
|
||||
@@ -0,0 +1,43 @@
|
||||
---
|
||||
- name: Deploy todo application
|
||||
hosts: all
|
||||
become: true
|
||||
roles:
|
||||
- app
|
||||
tasks:
|
||||
- name: Pull application image
|
||||
ansible.builtin.command:
|
||||
argv:
|
||||
- podman
|
||||
- pull
|
||||
- "{{ app_image }}"
|
||||
become_user: "{{ app_user }}"
|
||||
environment:
|
||||
XDG_RUNTIME_DIR: "/run/user/{{ app_user_uid }}"
|
||||
register: app_pull
|
||||
changed_when: "'Copying blob' in app_pull.stdout or 'Writing manifest' in app_pull.stdout"
|
||||
|
||||
- name: Reload application user services
|
||||
ansible.builtin.command:
|
||||
argv: [systemctl, --user, daemon-reload]
|
||||
become_user: "{{ app_user }}"
|
||||
environment:
|
||||
XDG_RUNTIME_DIR: "/run/user/{{ app_user_uid }}"
|
||||
changed_when: false
|
||||
|
||||
- name: Restart application
|
||||
ansible.builtin.command:
|
||||
argv: [systemctl, --user, restart, app-todo.service]
|
||||
become_user: "{{ app_user }}"
|
||||
environment:
|
||||
XDG_RUNTIME_DIR: "/run/user/{{ app_user_uid }}"
|
||||
changed_when: true
|
||||
|
||||
- name: Wait for application health endpoint
|
||||
ansible.builtin.uri:
|
||||
url: "http://127.0.0.1:{{ app_port }}/healthz"
|
||||
status_code: 200
|
||||
register: app_health
|
||||
retries: 12
|
||||
delay: 2
|
||||
until: app_health.status == 200
|
||||
@@ -0,0 +1,8 @@
|
||||
---
|
||||
- name: Configure todo nodes
|
||||
hosts: all
|
||||
become: true
|
||||
roles:
|
||||
- common
|
||||
- postgres
|
||||
- wal_g
|
||||
@@ -0,0 +1,4 @@
|
||||
app_user: todo
|
||||
app_port: 8080
|
||||
app_db_name: todo
|
||||
app_db_user: todo_app
|
||||
@@ -0,0 +1,71 @@
|
||||
---
|
||||
- name: Look up application user
|
||||
ansible.builtin.getent:
|
||||
database: passwd
|
||||
key: "{{ app_user }}"
|
||||
|
||||
- name: Store application user ID
|
||||
ansible.builtin.set_fact:
|
||||
app_user_uid: "{{ ansible_facts.getent_passwd[app_user][1] }}"
|
||||
|
||||
- name: Create Quadlet directory
|
||||
ansible.builtin.file:
|
||||
path: "/home/{{ app_user }}/.config/containers/systemd"
|
||||
state: directory
|
||||
owner: "{{ app_user }}"
|
||||
group: "{{ app_user }}"
|
||||
mode: "0700"
|
||||
|
||||
- name: Install protected application environment
|
||||
ansible.builtin.template:
|
||||
src: app.env.j2
|
||||
dest: "/home/{{ app_user }}/.config/todo.env"
|
||||
owner: "{{ app_user }}"
|
||||
group: "{{ app_user }}"
|
||||
mode: "0600"
|
||||
no_log: true
|
||||
|
||||
- name: Authenticate to private registry
|
||||
ansible.builtin.command:
|
||||
argv:
|
||||
- podman
|
||||
- login
|
||||
- --username
|
||||
- "{{ registry_username }}"
|
||||
- --password-stdin
|
||||
- "{{ registry_server }}"
|
||||
stdin: "{{ registry_password }}"
|
||||
become_user: "{{ app_user }}"
|
||||
environment:
|
||||
XDG_RUNTIME_DIR: "/run/user/{{ app_user_uid }}"
|
||||
changed_when: true
|
||||
no_log: true
|
||||
when:
|
||||
- registry_username is defined
|
||||
- registry_password is defined
|
||||
- registry_server is defined
|
||||
|
||||
- name: Install application Quadlet
|
||||
ansible.builtin.template:
|
||||
src: app-todo.container.j2
|
||||
dest: "/home/{{ app_user }}/.config/containers/systemd/app-todo.container"
|
||||
owner: "{{ app_user }}"
|
||||
group: "{{ app_user }}"
|
||||
mode: "0644"
|
||||
|
||||
- name: Reload application user manager
|
||||
ansible.builtin.command:
|
||||
argv: [systemctl, --user, daemon-reload]
|
||||
become_user: "{{ app_user }}"
|
||||
environment:
|
||||
XDG_RUNTIME_DIR: "/run/user/{{ app_user_uid }}"
|
||||
changed_when: false
|
||||
|
||||
- name: Enable application service
|
||||
ansible.builtin.command:
|
||||
argv: [systemctl, --user, enable, --now, app-todo.service]
|
||||
become_user: "{{ app_user }}"
|
||||
environment:
|
||||
XDG_RUNTIME_DIR: "/run/user/{{ app_user_uid }}"
|
||||
register: app_enable
|
||||
changed_when: "'Created symlink' in app_enable.stderr"
|
||||
@@ -0,0 +1,22 @@
|
||||
[Unit]
|
||||
Description=Todo web application
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Container]
|
||||
Image={{ app_image }}
|
||||
ContainerName=app-todo
|
||||
EnvironmentFile=/home/{{ app_user }}/.config/todo.env
|
||||
Network=host
|
||||
Pull=never
|
||||
NoNewPrivileges=true
|
||||
ReadOnly=true
|
||||
DropCapability=all
|
||||
|
||||
[Service]
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
TimeoutStopSec=15
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
@@ -0,0 +1,2 @@
|
||||
APP_ADDR=0.0.0.0:{{ app_port }}
|
||||
DATABASE_URL=postgres://{{ app_db_user | urlencode }}:{{ app_db_password | urlencode }}@127.0.0.1:5432/{{ app_db_name | urlencode }}?sslmode=disable
|
||||
@@ -0,0 +1 @@
|
||||
app_user: todo
|
||||
@@ -0,0 +1,37 @@
|
||||
---
|
||||
- name: Install operating system packages
|
||||
ansible.builtin.dnf:
|
||||
name:
|
||||
- ca-certificates
|
||||
- curl
|
||||
- policycoreutils-python-utils
|
||||
- podman
|
||||
- postgresql-server
|
||||
- postgresql-contrib
|
||||
- sudo
|
||||
- tar
|
||||
state: present
|
||||
update_cache: true
|
||||
|
||||
- name: Create rootless application user
|
||||
ansible.builtin.user:
|
||||
name: "{{ app_user }}"
|
||||
create_home: true
|
||||
shell: /bin/bash
|
||||
state: present
|
||||
register: app_user_result
|
||||
|
||||
- name: Store application user ID
|
||||
ansible.builtin.set_fact:
|
||||
app_user_uid: "{{ app_user_result.uid }}"
|
||||
|
||||
- name: Enable linger for application user
|
||||
ansible.builtin.command:
|
||||
argv: [loginctl, enable-linger, "{{ app_user }}"]
|
||||
args:
|
||||
creates: "/var/lib/systemd/linger/{{ app_user }}"
|
||||
|
||||
- name: Start application user manager
|
||||
ansible.builtin.systemd_service:
|
||||
name: "user@{{ app_user_uid }}.service"
|
||||
state: started
|
||||
@@ -0,0 +1,4 @@
|
||||
postgres_data_dir: /var/lib/pgsql/data
|
||||
postgres_service: postgresql
|
||||
app_db_name: todo
|
||||
app_db_user: todo_app
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
- name: Restart PostgreSQL
|
||||
ansible.builtin.systemd_service:
|
||||
name: "{{ postgres_service }}"
|
||||
state: restarted
|
||||
@@ -0,0 +1,75 @@
|
||||
---
|
||||
- name: Initialize PostgreSQL data directory
|
||||
ansible.builtin.command:
|
||||
argv: [postgresql-setup, --initdb]
|
||||
args:
|
||||
creates: "{{ postgres_data_dir }}/PG_VERSION"
|
||||
|
||||
- name: Create canonical PostgreSQL configuration directory
|
||||
ansible.builtin.file:
|
||||
path: /etc/todo-postgresql
|
||||
state: directory
|
||||
owner: root
|
||||
group: postgres
|
||||
mode: "0750"
|
||||
|
||||
- name: Render canonical PostgreSQL server configuration
|
||||
ansible.builtin.template:
|
||||
src: postgresql.conf.j2
|
||||
dest: /etc/todo-postgresql/postgresql.conf
|
||||
owner: root
|
||||
group: postgres
|
||||
mode: "0640"
|
||||
notify: Restart PostgreSQL
|
||||
|
||||
- name: Render canonical PostgreSQL client authentication
|
||||
ansible.builtin.template:
|
||||
src: pg_hba.conf.j2
|
||||
dest: /etc/todo-postgresql/pg_hba.conf
|
||||
owner: root
|
||||
group: postgres
|
||||
mode: "0640"
|
||||
notify: Restart PostgreSQL
|
||||
|
||||
- name: Install PostgreSQL configuration in data directory
|
||||
ansible.builtin.copy:
|
||||
src: "/etc/todo-postgresql/{{ item }}"
|
||||
dest: "{{ postgres_data_dir }}/{{ item }}"
|
||||
remote_src: true
|
||||
owner: postgres
|
||||
group: postgres
|
||||
mode: "0600"
|
||||
loop:
|
||||
- postgresql.conf
|
||||
- pg_hba.conf
|
||||
notify: Restart PostgreSQL
|
||||
|
||||
- name: Start PostgreSQL
|
||||
ansible.builtin.systemd_service:
|
||||
name: "{{ postgres_service }}"
|
||||
enabled: true
|
||||
state: started
|
||||
|
||||
- name: Apply PostgreSQL configuration before provisioning
|
||||
ansible.builtin.meta: flush_handlers
|
||||
|
||||
- name: Provision application database role
|
||||
ansible.builtin.command:
|
||||
argv: [psql, --set, "role={{ app_db_user }}", --set, "password={{ app_db_password }}", --dbname, postgres]
|
||||
stdin: |
|
||||
SELECT format('CREATE ROLE %I LOGIN PASSWORD %L', :'role', :'password')
|
||||
WHERE NOT EXISTS (SELECT FROM pg_roles WHERE rolname = :'role') \gexec
|
||||
SELECT format('ALTER ROLE %I LOGIN PASSWORD %L', :'role', :'password') \gexec
|
||||
become_user: postgres
|
||||
changed_when: false
|
||||
no_log: true
|
||||
|
||||
- name: Provision application database
|
||||
ansible.builtin.command:
|
||||
argv: [psql, --set, "database={{ app_db_name }}", --set, "role={{ app_db_user }}", --dbname, postgres]
|
||||
stdin: |
|
||||
SELECT format('CREATE DATABASE %I OWNER %I', :'database', :'role')
|
||||
WHERE NOT EXISTS (SELECT FROM pg_database WHERE datname = :'database') \gexec
|
||||
SELECT format('ALTER DATABASE %I OWNER TO %I', :'database', :'role') \gexec
|
||||
become_user: postgres
|
||||
changed_when: false
|
||||
@@ -0,0 +1,4 @@
|
||||
local all postgres peer
|
||||
local all all peer
|
||||
host {{ app_db_name }} {{ app_db_user }} 127.0.0.1/32 scram-sha-256
|
||||
host all all ::1/128 scram-sha-256
|
||||
@@ -0,0 +1,20 @@
|
||||
data_directory = '{{ postgres_data_dir }}'
|
||||
listen_addresses = '127.0.0.1'
|
||||
port = 5432
|
||||
max_connections = 100
|
||||
shared_buffers = 256MB
|
||||
password_encryption = 'scram-sha-256'
|
||||
wal_level = replica
|
||||
max_wal_senders = 5
|
||||
wal_compression = on
|
||||
{% if deployment_environment == 'prod' %}
|
||||
archive_mode = on
|
||||
archive_command = '/usr/local/sbin/wal-g-postgres wal-push %p'
|
||||
archive_timeout = 300
|
||||
{% else %}
|
||||
archive_mode = off
|
||||
archive_command = ''
|
||||
{% endif %}
|
||||
logging_collector = on
|
||||
log_directory = 'log'
|
||||
log_filename = 'postgresql-%a.log'
|
||||
@@ -0,0 +1,2 @@
|
||||
wal_g_version: v3.0.7
|
||||
wal_g_retention_count: 7
|
||||
@@ -0,0 +1,12 @@
|
||||
---
|
||||
- name: Restart WAL-G timer
|
||||
ansible.builtin.systemd_service:
|
||||
name: wal-g-backup.timer
|
||||
daemon_reload: true
|
||||
state: restarted
|
||||
|
||||
- name: Restart DB sync timer
|
||||
ansible.builtin.systemd_service:
|
||||
name: db-sync.timer
|
||||
daemon_reload: true
|
||||
state: restarted
|
||||
@@ -0,0 +1,123 @@
|
||||
---
|
||||
- name: Validate WAL-G configuration
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- wal_g_s3_prefix is match('^s3://.+/prod/?$')
|
||||
- wal_g_access_key | length > 0
|
||||
- wal_g_secret_key | length > 0
|
||||
fail_msg: WAL-G must use the production backup prefix and external credentials.
|
||||
no_log: true
|
||||
|
||||
- name: Create WAL-G temporary directory
|
||||
ansible.builtin.file:
|
||||
path: "/var/tmp/wal-g-install/{{ wal_g_version }}"
|
||||
state: directory
|
||||
mode: "0755"
|
||||
|
||||
- name: Download WAL-G
|
||||
ansible.builtin.unarchive:
|
||||
src: "{{ wal_g_download_url }}"
|
||||
dest: "/var/tmp/wal-g-install/{{ wal_g_version }}"
|
||||
remote_src: true
|
||||
creates: "/var/tmp/wal-g-install/{{ wal_g_version }}/wal-g-pg-ubuntu-20.04-amd64"
|
||||
|
||||
- name: Install WAL-G executable
|
||||
ansible.builtin.copy:
|
||||
src: "/var/tmp/wal-g-install/{{ wal_g_version }}/wal-g-pg-ubuntu-20.04-amd64"
|
||||
dest: /usr/local/bin/wal-g
|
||||
remote_src: true
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0755"
|
||||
|
||||
- name: Create WAL-G configuration directory
|
||||
ansible.builtin.file:
|
||||
path: /etc/wal-g
|
||||
state: directory
|
||||
owner: root
|
||||
group: postgres
|
||||
mode: "0750"
|
||||
|
||||
- name: Install protected WAL-G environment
|
||||
ansible.builtin.template:
|
||||
src: wal-g.env.j2
|
||||
dest: /etc/wal-g/wal-g.env
|
||||
owner: postgres
|
||||
group: postgres
|
||||
mode: "0600"
|
||||
no_log: true
|
||||
|
||||
- name: Install PostgreSQL WAL-G wrapper
|
||||
ansible.builtin.template:
|
||||
src: wal-g-postgres.j2
|
||||
dest: /usr/local/sbin/wal-g-postgres
|
||||
owner: root
|
||||
group: postgres
|
||||
mode: "0750"
|
||||
|
||||
- name: Install production backup service
|
||||
ansible.builtin.template:
|
||||
src: wal-g-backup.service.j2
|
||||
dest: /etc/systemd/system/wal-g-backup.service
|
||||
mode: "0644"
|
||||
when: deployment_environment == 'prod'
|
||||
notify: Restart WAL-G timer
|
||||
|
||||
- name: Install production backup timer
|
||||
ansible.builtin.template:
|
||||
src: wal-g-backup.timer.j2
|
||||
dest: /etc/systemd/system/wal-g-backup.timer
|
||||
mode: "0644"
|
||||
when: deployment_environment == 'prod'
|
||||
notify: Restart WAL-G timer
|
||||
|
||||
- name: Enable production backup timer
|
||||
ansible.builtin.systemd_service:
|
||||
name: wal-g-backup.timer
|
||||
enabled: true
|
||||
daemon_reload: true
|
||||
state: started
|
||||
when: deployment_environment == 'prod'
|
||||
|
||||
- name: Install staging restore configuration
|
||||
ansible.builtin.template:
|
||||
src: db-sync.env.j2
|
||||
dest: /etc/wal-g/db-sync.env
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0600"
|
||||
when: deployment_environment == 'staging'
|
||||
no_log: true
|
||||
|
||||
- name: Install staging restore script
|
||||
ansible.builtin.template:
|
||||
src: db-sync.sh.j2
|
||||
dest: /usr/local/sbin/db-sync
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0700"
|
||||
when: deployment_environment == 'staging'
|
||||
|
||||
- name: Install staging restore service
|
||||
ansible.builtin.template:
|
||||
src: db-sync.service.j2
|
||||
dest: /etc/systemd/system/db-sync.service
|
||||
mode: "0644"
|
||||
when: deployment_environment == 'staging'
|
||||
notify: Restart DB sync timer
|
||||
|
||||
- name: Install staging restore timer
|
||||
ansible.builtin.template:
|
||||
src: db-sync.timer.j2
|
||||
dest: /etc/systemd/system/db-sync.timer
|
||||
mode: "0644"
|
||||
when: deployment_environment == 'staging'
|
||||
notify: Restart DB sync timer
|
||||
|
||||
- name: Enable staging restore timer
|
||||
ansible.builtin.systemd_service:
|
||||
name: db-sync.timer
|
||||
enabled: true
|
||||
daemon_reload: true
|
||||
state: started
|
||||
when: deployment_environment == 'staging'
|
||||
@@ -0,0 +1,12 @@
|
||||
AWS_ACCESS_KEY_ID={{ wal_g_access_key }}
|
||||
AWS_SECRET_ACCESS_KEY={{ wal_g_secret_key }}
|
||||
AWS_ENDPOINT={{ wal_g_s3_endpoint }}
|
||||
AWS_REGION={{ wal_g_s3_region }}
|
||||
AWS_S3_FORCE_PATH_STYLE=true
|
||||
WALG_S3_PREFIX={{ wal_g_s3_prefix | regex_replace('/$', '') }}
|
||||
WALG_COMPRESSION_METHOD=lz4
|
||||
PGDATA={{ postgres_data_dir }}
|
||||
POSTGRES_SERVICE={{ postgres_service }}
|
||||
APP_USER={{ app_user }}
|
||||
APP_DB_USER={{ app_db_user }}
|
||||
APP_DB_PASSWORD={{ app_db_password }}
|
||||
@@ -0,0 +1,14 @@
|
||||
[Unit]
|
||||
Description=Restore production PostgreSQL backup into staging
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
ConditionPathExists=/etc/wal-g/db-sync.env
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
EnvironmentFile=/etc/wal-g/db-sync.env
|
||||
ExecStart=/usr/local/sbin/db-sync
|
||||
TimeoutStartSec=45min
|
||||
Nice=10
|
||||
IOSchedulingClass=best-effort
|
||||
IOSchedulingPriority=7
|
||||
@@ -0,0 +1,54 @@
|
||||
#!/bin/bash
|
||||
set -Eeuo pipefail
|
||||
source /etc/wal-g/db-sync.env
|
||||
|
||||
expected_pgdata="/var/lib/pgsql/data"
|
||||
if [[ "$PGDATA" != "$expected_pgdata" || ! -d "$PGDATA" || "$(stat -c %U "$PGDATA")" != "postgres" ]]; then
|
||||
echo "Refusing destructive restore for unexpected PGDATA: $PGDATA" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ "$WALG_S3_PREFIX" != s3://*/prod ]]; then
|
||||
echo "Refusing restore from a non-production prefix" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
systemctl --user --machine="${APP_USER}@" stop app-todo.service || true
|
||||
systemctl stop "$POSTGRES_SERVICE"
|
||||
|
||||
shopt -s dotglob nullglob
|
||||
contents=("$PGDATA"/*)
|
||||
if (( ${#contents[@]} > 0 )); then
|
||||
rm -rf --one-file-system -- "${contents[@]}"
|
||||
fi
|
||||
|
||||
sudo -u postgres --preserve-env=AWS_ACCESS_KEY_ID,AWS_SECRET_ACCESS_KEY,AWS_ENDPOINT,AWS_REGION,AWS_S3_FORCE_PATH_STYLE,WALG_S3_PREFIX,WALG_COMPRESSION_METHOD \
|
||||
/usr/local/bin/wal-g backup-fetch "$PGDATA" LATEST
|
||||
chown -R postgres:postgres "$PGDATA"
|
||||
chmod 0700 "$PGDATA"
|
||||
install -o postgres -g postgres -m 0600 /etc/todo-postgresql/postgresql.conf "$PGDATA/postgresql.conf"
|
||||
install -o postgres -g postgres -m 0600 /etc/todo-postgresql/pg_hba.conf "$PGDATA/pg_hba.conf"
|
||||
|
||||
cat >"$PGDATA/postgresql.auto.conf" <<'RECOVERY'
|
||||
restore_command = '/usr/local/sbin/wal-g-postgres wal-fetch %f %p'
|
||||
recovery_target_timeline = 'latest'
|
||||
RECOVERY
|
||||
touch "$PGDATA/recovery.signal"
|
||||
chown postgres:postgres "$PGDATA/postgresql.auto.conf" "$PGDATA/recovery.signal"
|
||||
|
||||
systemctl start "$POSTGRES_SERVICE"
|
||||
for _ in {1..120}; do
|
||||
if sudo -u postgres psql --dbname=postgres --tuples-only --no-align --command="SELECT NOT pg_is_in_recovery()" 2>/dev/null | grep -qx t; then
|
||||
break
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
if ! sudo -u postgres psql --dbname=postgres --tuples-only --no-align --command="SELECT NOT pg_is_in_recovery()" | grep -qx t; then
|
||||
echo "PostgreSQL did not complete recovery" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
sudo -u postgres psql --dbname=postgres --set="role=$APP_DB_USER" --set="password=$APP_DB_PASSWORD" <<'SQL'
|
||||
SELECT format('ALTER ROLE %I LOGIN PASSWORD %L', :'role', :'password') \gexec
|
||||
SQL
|
||||
|
||||
systemctl --user --machine="${APP_USER}@" start app-todo.service
|
||||
@@ -0,0 +1,9 @@
|
||||
[Unit]
|
||||
Description=Run staging database restore at 06:00 and 12:00 America/Chicago
|
||||
|
||||
[Timer]
|
||||
OnCalendar=*-*-* 06,12:00:00 America/Chicago
|
||||
Persistent=true
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
@@ -0,0 +1,17 @@
|
||||
[Unit]
|
||||
Description=Push PostgreSQL base backup to object storage
|
||||
After={{ postgres_service }}.service network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
User=postgres
|
||||
Group=postgres
|
||||
EnvironmentFile=/etc/wal-g/wal-g.env
|
||||
ExecStart=/usr/local/bin/wal-g backup-push {{ postgres_data_dir }}
|
||||
ExecStartPost=/usr/local/bin/wal-g delete retain FULL {{ wal_g_retention_count }} --confirm
|
||||
Nice=10
|
||||
IOSchedulingClass=best-effort
|
||||
IOSchedulingPriority=7
|
||||
PrivateTmp=true
|
||||
NoNewPrivileges=true
|
||||
@@ -0,0 +1,10 @@
|
||||
[Unit]
|
||||
Description=Run hourly PostgreSQL base backup
|
||||
|
||||
[Timer]
|
||||
OnCalendar=hourly
|
||||
Persistent=true
|
||||
RandomizedDelaySec=5m
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
@@ -0,0 +1,12 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
{% if deployment_environment == 'staging' %}
|
||||
if [[ "${1:-}" == "wal-push" ]]; then
|
||||
echo "WAL uploads are disabled on staging" >&2
|
||||
exit 1
|
||||
fi
|
||||
{% endif %}
|
||||
set -a
|
||||
source /etc/wal-g/wal-g.env
|
||||
set +a
|
||||
exec /usr/local/bin/wal-g "$@"
|
||||
@@ -0,0 +1,9 @@
|
||||
AWS_ACCESS_KEY_ID={{ wal_g_access_key }}
|
||||
AWS_SECRET_ACCESS_KEY={{ wal_g_secret_key }}
|
||||
AWS_ENDPOINT={{ wal_g_s3_endpoint }}
|
||||
AWS_REGION={{ wal_g_s3_region }}
|
||||
AWS_S3_FORCE_PATH_STYLE=true
|
||||
WALG_S3_PREFIX={{ wal_g_s3_prefix | regex_replace('/$', '') }}
|
||||
WALG_COMPRESSION_METHOD=lz4
|
||||
WALG_UPLOAD_CONCURRENCY=4
|
||||
WALG_DOWNLOAD_CONCURRENCY=4
|
||||
@@ -0,0 +1,14 @@
|
||||
[Unit]
|
||||
Description=Restore production PostgreSQL backup into staging
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
ConditionPathExists=/etc/wal-g/db-sync.env
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
EnvironmentFile=/etc/wal-g/db-sync.env
|
||||
ExecStart=/usr/local/sbin/db-sync
|
||||
TimeoutStartSec=45min
|
||||
Nice=10
|
||||
IOSchedulingClass=best-effort
|
||||
IOSchedulingPriority=7
|
||||
@@ -0,0 +1,9 @@
|
||||
[Unit]
|
||||
Description=Run staging database restore at 06:00 and 12:00 America/Chicago
|
||||
|
||||
[Timer]
|
||||
OnCalendar=*-*-* 06,12:00:00 America/Chicago
|
||||
Persistent=true
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
@@ -0,0 +1,9 @@
|
||||
AWS_ACCESS_KEY_ID={{ wal_g_access_key }}
|
||||
AWS_SECRET_ACCESS_KEY={{ wal_g_secret_key }}
|
||||
AWS_ENDPOINT={{ wal_g_s3_endpoint }}
|
||||
AWS_REGION={{ wal_g_s3_region }}
|
||||
AWS_S3_FORCE_PATH_STYLE=true
|
||||
WALG_S3_PREFIX={{ wal_g_s3_prefix | regex_replace('/$', '') }}
|
||||
WALG_COMPRESSION_METHOD=lz4
|
||||
WALG_UPLOAD_CONCURRENCY=4
|
||||
WALG_DOWNLOAD_CONCURRENCY=4
|
||||
@@ -0,0 +1,3 @@
|
||||
bin
|
||||
tailwindcss
|
||||
.git
|
||||
@@ -0,0 +1,27 @@
|
||||
# syntax=docker/dockerfile:1.7
|
||||
FROM alpine:3.21 AS css
|
||||
ARG TARGETARCH
|
||||
ARG TAILWIND_VERSION=v3.4.17
|
||||
WORKDIR /src
|
||||
RUN apk add --no-cache ca-certificates curl \
|
||||
&& case "$TARGETARCH" in amd64) tw_arch=x64 ;; arm64) tw_arch=arm64 ;; *) exit 1 ;; esac \
|
||||
&& curl -fsSL -o /usr/local/bin/tailwindcss "https://github.com/tailwindlabs/tailwindcss/releases/download/${TAILWIND_VERSION}/tailwindcss-linux-${tw_arch}" \
|
||||
&& chmod +x /usr/local/bin/tailwindcss
|
||||
COPY tailwind.config.js ./
|
||||
COPY web/input.css web/input.css
|
||||
COPY web/templates web/templates
|
||||
RUN mkdir -p web/static && tailwindcss -c tailwind.config.js -i web/input.css -o web/static/app.css --minify
|
||||
|
||||
FROM golang:1.22-bookworm AS build
|
||||
WORKDIR /src
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
COPY . .
|
||||
COPY --from=css /src/web/static/app.css web/static/app.css
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /todo ./cmd/server
|
||||
|
||||
FROM gcr.io/distroless/static-debian12:nonroot
|
||||
COPY --from=build /todo /todo
|
||||
EXPOSE 8080
|
||||
USER nonroot:nonroot
|
||||
ENTRYPOINT ["/todo"]
|
||||
@@ -0,0 +1,27 @@
|
||||
TAILWIND_VERSION ?= v3.4.17
|
||||
TAILWIND_BIN := ./tailwindcss
|
||||
|
||||
.PHONY: build css test vet clean
|
||||
|
||||
build: css
|
||||
mkdir -p bin
|
||||
CGO_ENABLED=0 go build -trimpath -ldflags='-s -w' -o bin/todo ./cmd/server
|
||||
|
||||
$(TAILWIND_BIN):
|
||||
@os=$$(uname -s | tr '[:upper:]' '[:lower:]'); arch=$$(uname -m); \
|
||||
case "$$os" in darwin) os=macos ;; linux) ;; *) exit 1 ;; esac; \
|
||||
case "$$arch" in x86_64) arch=x64 ;; arm64|aarch64) arch=arm64 ;; *) exit 1 ;; esac; \
|
||||
curl -fsSL -o $(TAILWIND_BIN) "https://github.com/tailwindlabs/tailwindcss/releases/download/$(TAILWIND_VERSION)/tailwindcss-$$os-$$arch"
|
||||
chmod +x $(TAILWIND_BIN)
|
||||
|
||||
css: $(TAILWIND_BIN)
|
||||
$(TAILWIND_BIN) -c tailwind.config.js -i web/input.css -o web/static/app.css --minify
|
||||
|
||||
test:
|
||||
go test ./...
|
||||
|
||||
vet:
|
||||
go vet ./...
|
||||
|
||||
clean:
|
||||
rm -rf bin $(TAILWIND_BIN)
|
||||
@@ -0,0 +1,77 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"todo-hetzner/app/internal/db"
|
||||
"todo-hetzner/app/internal/handler"
|
||||
)
|
||||
|
||||
func main() {
|
||||
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
|
||||
if err := run(logger); err != nil {
|
||||
logger.Error("server stopped", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func run(logger *slog.Logger) error {
|
||||
databaseURL := os.Getenv("DATABASE_URL")
|
||||
if databaseURL == "" {
|
||||
return errors.New("DATABASE_URL is required")
|
||||
}
|
||||
addr := os.Getenv("APP_ADDR")
|
||||
if addr == "" {
|
||||
addr = ":8080"
|
||||
}
|
||||
|
||||
store, err := db.Open(databaseURL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
if err := store.Ping(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
h, err := handler.New(store, logger)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
server := &http.Server{
|
||||
Addr: addr,
|
||||
Handler: h.Routes(),
|
||||
ReadHeaderTimeout: 5 * time.Second,
|
||||
ReadTimeout: 10 * time.Second,
|
||||
WriteTimeout: 15 * time.Second,
|
||||
IdleTimeout: 60 * time.Second,
|
||||
MaxHeaderBytes: 1 << 20,
|
||||
}
|
||||
|
||||
shutdownCtx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
go func() {
|
||||
<-shutdownCtx.Done()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
if err := server.Shutdown(ctx); err != nil {
|
||||
logger.Error("graceful shutdown failed", "error", err)
|
||||
}
|
||||
}()
|
||||
|
||||
logger.Info("server listening", "address", addr)
|
||||
if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
module todo-hetzner/app
|
||||
|
||||
go 1.22.0
|
||||
|
||||
require (
|
||||
github.com/lib/pq v1.10.9
|
||||
xorm.io/xorm v1.3.9
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/goccy/go-json v0.8.1 // indirect
|
||||
github.com/golang/snappy v0.0.4 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/syndtr/goleveldb v1.0.0 // indirect
|
||||
xorm.io/builder v0.3.11-0.20220531020008-1bd24a7dc978 // indirect
|
||||
)
|
||||
+96
@@ -0,0 +1,96 @@
|
||||
gitea.com/xorm/sqlfiddle v0.0.0-20180821085327-62ce714f951a h1:lSA0F4e9A2NcQSqGqTOXqu2aRi/XEQxDCBwM8yJtE6s=
|
||||
gitea.com/xorm/sqlfiddle v0.0.0-20180821085327-62ce714f951a/go.mod h1:EXuID2Zs0pAQhH8yz+DNjUbjppKQzKFAn28TMYPB6IU=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dustin/go-humanize v1.0.0 h1:VSnTsYCnlFHaM2/igO1h6X3HA71jcobQuxemgkq4zYo=
|
||||
github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
|
||||
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
|
||||
github.com/go-sql-driver/mysql v1.7.0 h1:ueSltNNllEqE3qcWBTD0iQd3IpL/6U+mJxLkazJ7YPc=
|
||||
github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
|
||||
github.com/goccy/go-json v0.8.1 h1:4/Wjm0JIJaTDm8K1KcGrLHJoa8EsJ13YWeX+6Kfq6uI=
|
||||
github.com/goccy/go-json v0.8.1/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
||||
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/snappy v0.0.0-20180518054509-2e65f85255db/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
||||
github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM=
|
||||
github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
|
||||
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/hpcloud/tail v1.0.0 h1:nfCOvKYfkgYP8hkirhJocXT2+zOD8yUNjXaWfTlyFKI=
|
||||
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs=
|
||||
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8=
|
||||
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
||||
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||
github.com/mattn/go-isatty v0.0.16 h1:bq3VjFmv/sOjHtdEhmkEV4x1AJtvUvOJ2PFAZ5+peKQ=
|
||||
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||
github.com/mattn/go-sqlite3 v1.14.16 h1:yOQRA0RpS5PFz/oikGwBEqvAWhWg5ufRz4ETLjwpU1Y=
|
||||
github.com/mattn/go-sqlite3 v1.14.16/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OHLH3mGKHDcjJRFFRrJa6eAM5H+CtDdOsPc=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
||||
github.com/onsi/ginkgo v1.7.0 h1:WSHQ+IS43OoUrWtD1/bbclrwK8TTH5hzp+umCiuxHgs=
|
||||
github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
||||
github.com/onsi/gomega v1.4.3 h1:RE1xgDvH7imwFD45h+u2SgIfERHlS2yNG4DObb5BSKU=
|
||||
github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0 h1:OdAsTTz6OkFY5QxjkYwrChwuRruF69c169dPK26NUlk=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/syndtr/goleveldb v1.0.0 h1:fBdIW9lB4Iz0n9khmH8w27SJ3QEJ7+IgjPEwGSZiFdE=
|
||||
github.com/syndtr/goleveldb v1.0.0/go.mod h1:ZVVdQEZoIme9iO1Ch2Jdy24qqXrMMOU6lpPAyBWyWuQ=
|
||||
golang.org/x/mod v0.8.0 h1:LUYupSeNrTNCGzR/hVBk2NHZO4hXcVaW1k4Qx7rjPx8=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd h1:nTDtHvHSdCn1m6ITfMRqtOd/9+7a3s8RBNOZ3eYZzJA=
|
||||
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.11.0 h1:eG7RXZHdqOJ1i+0lgLgCpSXAp6M3LYlAo6osgSi0xOM=
|
||||
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.12.0 h1:k+n5B8goJNdU7hSvEtMUz3d1Q6D/XW4COJSJR6fN0mc=
|
||||
golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||
golang.org/x/tools v0.6.0 h1:BOw41kyTf3PuCW1pVQf8+Cyg8pMlkYB1oo9iJ6D/lKM=
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/fsnotify.v1 v1.4.7 h1:xOHLXZwVvI9hhs+cLKq5+I5onOuwQLhQwiu63xxlHs4=
|
||||
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
|
||||
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=
|
||||
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
|
||||
gopkg.in/yaml.v2 v2.2.1 h1:mUhvW9EsL+naU5Q3cakzfE91YhliOondGd6ZrsDBHQE=
|
||||
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
lukechampine.com/uint128 v1.2.0 h1:mBi/5l91vocEN8otkC5bDLhi2KdCticRiwbdB0O+rjI=
|
||||
lukechampine.com/uint128 v1.2.0/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk=
|
||||
modernc.org/cc/v3 v3.40.0 h1:P3g79IUS/93SYhtoeaHW+kRCIrYaxJ27MFPv+7kaTOw=
|
||||
modernc.org/cc/v3 v3.40.0/go.mod h1:/bTg4dnWkSXowUO6ssQKnOV0yMVxDYNIsIrzqTFDGH0=
|
||||
modernc.org/ccgo/v3 v3.16.13 h1:Mkgdzl46i5F/CNR/Kj80Ri59hC8TKAhZrYSaqvkwzUw=
|
||||
modernc.org/ccgo/v3 v3.16.13/go.mod h1:2Quk+5YgpImhPjv2Qsob1DnZ/4som1lJTodubIcoUkY=
|
||||
modernc.org/libc v1.22.2 h1:4U7v51GyhlWqQmwCHj28Rdq2Yzwk55ovjFrdPjs8Hb0=
|
||||
modernc.org/libc v1.22.2/go.mod h1:uvQavJ1pZ0hIoC/jfqNoMLURIMhKzINIWypNM17puug=
|
||||
modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ=
|
||||
modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
|
||||
modernc.org/memory v1.4.0 h1:crykUfNSnMAXaOJnnxcSzbUGMqkLWjklJKkBK2nwZwk=
|
||||
modernc.org/memory v1.4.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
|
||||
modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4=
|
||||
modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
|
||||
modernc.org/sqlite v1.20.4 h1:J8+m2trkN+KKoE7jglyHYYYiaq5xmz2HoHJIiBlRzbE=
|
||||
modernc.org/sqlite v1.20.4/go.mod h1:zKcGyrICaxNTMEHSr1HQ2GUraP0j+845GYw37+EyT6A=
|
||||
modernc.org/strutil v1.1.3 h1:fNMm+oJklMGYfU9Ylcywl0CO5O6nTfaowNsh2wpPjzY=
|
||||
modernc.org/strutil v1.1.3/go.mod h1:MEHNA7PdEnEwLvspRMtWTNnp2nnyvMfkimT1NKNAGbw=
|
||||
modernc.org/token v1.0.1 h1:A3qvTqOwexpfZZeyI0FeGPDlSWX5pjZu9hF4lU+EKWg=
|
||||
modernc.org/token v1.0.1/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||
xorm.io/builder v0.3.11-0.20220531020008-1bd24a7dc978 h1:bvLlAPW1ZMTWA32LuZMBEGHAUOcATZjzHcotf3SWweM=
|
||||
xorm.io/builder v0.3.11-0.20220531020008-1bd24a7dc978/go.mod h1:aUW0S9eb9VCaPohFCH3j7czOx1PMW3i1HrSzbLYGBSE=
|
||||
xorm.io/xorm v1.3.9 h1:TUovzS0ko+IQ1XnNLfs5dqK1cJl1H5uHpWbWqAQ04nU=
|
||||
xorm.io/xorm v1.3.9/go.mod h1:LsCCffeeYp63ssk0pKumP6l96WZcHix7ChpurcLNuMw=
|
||||
@@ -0,0 +1,64 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
_ "github.com/lib/pq"
|
||||
"xorm.io/xorm"
|
||||
|
||||
"todo-hetzner/app/internal/model"
|
||||
)
|
||||
|
||||
type Store struct {
|
||||
engine *xorm.Engine
|
||||
}
|
||||
|
||||
func Open(databaseURL string) (*Store, error) {
|
||||
engine, err := xorm.NewEngine("postgres", databaseURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create database engine: %w", err)
|
||||
}
|
||||
engine.SetMaxOpenConns(10)
|
||||
engine.SetMaxIdleConns(5)
|
||||
if err := engine.Sync2(new(model.Todo)); err != nil {
|
||||
engine.Close()
|
||||
return nil, fmt.Errorf("migrate database: %w", err)
|
||||
}
|
||||
return &Store{engine: engine}, nil
|
||||
}
|
||||
|
||||
func (s *Store) Ping(ctx context.Context) error {
|
||||
return s.engine.DB().PingContext(ctx)
|
||||
}
|
||||
|
||||
func (s *Store) Close() error {
|
||||
return s.engine.Close()
|
||||
}
|
||||
|
||||
func (s *Store) List(ctx context.Context) ([]model.Todo, error) {
|
||||
var todos []model.Todo
|
||||
err := s.engine.Context(ctx).Desc("created_at", "id").Find(&todos)
|
||||
return todos, err
|
||||
}
|
||||
|
||||
func (s *Store) Create(ctx context.Context, title string) error {
|
||||
_, err := s.engine.Context(ctx).Insert(&model.Todo{Title: title})
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) Toggle(ctx context.Context, id int64) (bool, error) {
|
||||
var todo model.Todo
|
||||
found, err := s.engine.Context(ctx).ID(id).Get(&todo)
|
||||
if err != nil || !found {
|
||||
return found, err
|
||||
}
|
||||
todo.Completed = !todo.Completed
|
||||
_, err = s.engine.Context(ctx).ID(id).Cols("completed", "updated_at").Update(&todo)
|
||||
return true, err
|
||||
}
|
||||
|
||||
func (s *Store) Delete(ctx context.Context, id int64) (bool, error) {
|
||||
affected, err := s.engine.Context(ctx).ID(id).Delete(new(model.Todo))
|
||||
return affected > 0, err
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestStoreLifecycle(t *testing.T) {
|
||||
databaseURL := os.Getenv("TEST_DATABASE_URL")
|
||||
if databaseURL == "" {
|
||||
t.Skip("TEST_DATABASE_URL is not set")
|
||||
}
|
||||
store, err := Open(databaseURL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = store.Close() })
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
title := "store-test-" + strconv.FormatInt(time.Now().UnixNano(), 10)
|
||||
if err := store.Create(ctx, title); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
todos, err := store.List(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var id int64
|
||||
for _, todo := range todos {
|
||||
if todo.Title == title {
|
||||
id = todo.ID
|
||||
break
|
||||
}
|
||||
}
|
||||
if id == 0 {
|
||||
t.Fatalf("created todo %q not found", title)
|
||||
}
|
||||
t.Cleanup(func() { _, _ = store.Delete(context.Background(), id) })
|
||||
|
||||
found, err := store.Toggle(ctx, id)
|
||||
if err != nil || !found {
|
||||
t.Fatalf("toggle found = %v, error = %v", found, err)
|
||||
}
|
||||
found, err = store.Delete(ctx, id)
|
||||
if err != nil || !found {
|
||||
t.Fatalf("delete found = %v, error = %v", found, err)
|
||||
}
|
||||
found, err = store.Delete(ctx, id)
|
||||
if err != nil || found {
|
||||
t.Fatalf("second delete found = %v, error = %v", found, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"html/template"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"todo-hetzner/app/internal/model"
|
||||
"todo-hetzner/app/web"
|
||||
)
|
||||
|
||||
const maxFormBytes = 4096
|
||||
|
||||
type TodoStore interface {
|
||||
List(context.Context) ([]model.Todo, error)
|
||||
Create(context.Context, string) error
|
||||
Toggle(context.Context, int64) (bool, error)
|
||||
Delete(context.Context, int64) (bool, error)
|
||||
Ping(context.Context) error
|
||||
}
|
||||
|
||||
type Handler struct {
|
||||
store TodoStore
|
||||
logger *slog.Logger
|
||||
templates *template.Template
|
||||
}
|
||||
|
||||
func New(store TodoStore, logger *slog.Logger) (*Handler, error) {
|
||||
tmpl, err := template.ParseFS(web.Assets, "templates/layouts/*.html", "templates/pages/*.html")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Handler{store: store, logger: logger, templates: tmpl}, nil
|
||||
}
|
||||
|
||||
func (h *Handler) Routes() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET /", h.list)
|
||||
mux.HandleFunc("POST /todos", h.create)
|
||||
mux.HandleFunc("POST /todos/{id}/toggle", h.toggle)
|
||||
mux.HandleFunc("POST /todos/{id}/delete", h.delete)
|
||||
mux.HandleFunc("GET /healthz", h.health)
|
||||
mux.Handle("GET /static/", http.FileServerFS(web.Assets))
|
||||
return h.security(h.accessLog(mux))
|
||||
}
|
||||
|
||||
func (h *Handler) list(w http.ResponseWriter, r *http.Request) {
|
||||
todos, err := h.store.List(r.Context())
|
||||
if err != nil {
|
||||
h.serverError(w, r, err)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
if err := h.templates.ExecuteTemplate(w, "layout", struct{ Todos []model.Todo }{todos}); err != nil {
|
||||
h.logger.Error("render response", "error", err, "request_id", requestID(r))
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) create(w http.ResponseWriter, r *http.Request) {
|
||||
if !sameOrigin(r) {
|
||||
http.Error(w, "invalid origin", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
if err := parseForm(w, r); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
title := strings.TrimSpace(r.FormValue("title"))
|
||||
if title == "" || len([]rune(title)) > 200 {
|
||||
http.Error(w, "title must contain 1 to 200 characters", http.StatusUnprocessableEntity)
|
||||
return
|
||||
}
|
||||
if err := h.store.Create(r.Context(), title); err != nil {
|
||||
h.serverError(w, r, err)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (h *Handler) toggle(w http.ResponseWriter, r *http.Request) {
|
||||
h.mutateByID(w, r, h.store.Toggle)
|
||||
}
|
||||
|
||||
func (h *Handler) delete(w http.ResponseWriter, r *http.Request) {
|
||||
h.mutateByID(w, r, h.store.Delete)
|
||||
}
|
||||
|
||||
func (h *Handler) mutateByID(w http.ResponseWriter, r *http.Request, mutate func(context.Context, int64) (bool, error)) {
|
||||
if !sameOrigin(r) {
|
||||
http.Error(w, "invalid origin", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||
if err != nil || id < 1 {
|
||||
http.Error(w, "invalid todo ID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
found, err := mutate(r.Context(), id)
|
||||
if err != nil {
|
||||
h.serverError(w, r, err)
|
||||
return
|
||||
}
|
||||
if !found {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (h *Handler) health(w http.ResponseWriter, r *http.Request) {
|
||||
if err := h.store.Ping(r.Context()); err != nil {
|
||||
http.Error(w, "unhealthy", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = io.WriteString(w, "ok\n")
|
||||
}
|
||||
|
||||
func (h *Handler) serverError(w http.ResponseWriter, r *http.Request, err error) {
|
||||
h.logger.Error("request failed", "error", err, "request_id", requestID(r))
|
||||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
func (h *Handler) security(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Security-Policy", "default-src 'self'; style-src 'self'; form-action 'self'; frame-ancestors 'none'; base-uri 'self'")
|
||||
w.Header().Set("Referrer-Policy", "no-referrer")
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
w.Header().Set("X-Frame-Options", "DENY")
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) accessLog(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
h.logger.Info("request", "method", r.Method, "path", r.URL.Path, "remote_addr", r.RemoteAddr, "request_id", requestID(r))
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func parseForm(w http.ResponseWriter, r *http.Request) error {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxFormBytes)
|
||||
if err := r.ParseForm(); err != nil {
|
||||
return errors.New("invalid form data")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func sameOrigin(r *http.Request) bool {
|
||||
origin := r.Header.Get("Origin")
|
||||
if origin == "" {
|
||||
return true
|
||||
}
|
||||
u, err := url.Parse(origin)
|
||||
return err == nil && strings.EqualFold(u.Host, r.Host)
|
||||
}
|
||||
|
||||
func requestID(r *http.Request) string {
|
||||
return r.Header.Get("X-Request-ID")
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"todo-hetzner/app/internal/model"
|
||||
)
|
||||
|
||||
type fakeStore struct {
|
||||
todos []model.Todo
|
||||
created string
|
||||
toggled int64
|
||||
deleted int64
|
||||
pingErr error
|
||||
}
|
||||
|
||||
func (s *fakeStore) List(context.Context) ([]model.Todo, error) { return s.todos, nil }
|
||||
func (s *fakeStore) Create(_ context.Context, title string) error {
|
||||
s.created = title
|
||||
return nil
|
||||
}
|
||||
func (s *fakeStore) Toggle(_ context.Context, id int64) (bool, error) {
|
||||
s.toggled = id
|
||||
return id != 404, nil
|
||||
}
|
||||
func (s *fakeStore) Delete(_ context.Context, id int64) (bool, error) {
|
||||
s.deleted = id
|
||||
return id != 404, nil
|
||||
}
|
||||
func (s *fakeStore) Ping(context.Context) error { return s.pingErr }
|
||||
|
||||
func testHandler(t *testing.T, store *fakeStore) http.Handler {
|
||||
t.Helper()
|
||||
h, err := New(store, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return h.Routes()
|
||||
}
|
||||
|
||||
func TestListRendersEscapedTodos(t *testing.T) {
|
||||
router := testHandler(t, &fakeStore{todos: []model.Todo{{ID: 1, Title: "<script>alert(1)</script>"}}})
|
||||
recorder := httptest.NewRecorder()
|
||||
router.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/", nil))
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d", recorder.Code)
|
||||
}
|
||||
if strings.Contains(recorder.Body.String(), "<script>") || !strings.Contains(recorder.Body.String(), "<script>") {
|
||||
t.Fatalf("title was not escaped: %s", recorder.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateValidatesAndTrimsTitle(t *testing.T) {
|
||||
store := &fakeStore{}
|
||||
router := testHandler(t, store)
|
||||
form := url.Values{"title": {" ship it "}}
|
||||
req := httptest.NewRequest(http.MethodPost, "/todos", strings.NewReader(form.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
recorder := httptest.NewRecorder()
|
||||
router.ServeHTTP(recorder, req)
|
||||
if recorder.Code != http.StatusSeeOther || store.created != "ship it" {
|
||||
t.Fatalf("status = %d, created = %q", recorder.Code, store.created)
|
||||
}
|
||||
|
||||
req = httptest.NewRequest(http.MethodPost, "/todos", strings.NewReader("title=++"))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
recorder = httptest.NewRecorder()
|
||||
router.ServeHTTP(recorder, req)
|
||||
if recorder.Code != http.StatusUnprocessableEntity {
|
||||
t.Fatalf("empty title status = %d", recorder.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMutationsAndNotFound(t *testing.T) {
|
||||
store := &fakeStore{}
|
||||
router := testHandler(t, store)
|
||||
for _, tc := range []struct {
|
||||
path string
|
||||
want int
|
||||
}{
|
||||
{"/todos/12/toggle", http.StatusSeeOther},
|
||||
{"/todos/13/delete", http.StatusSeeOther},
|
||||
{"/todos/404/delete", http.StatusNotFound},
|
||||
{"/todos/nope/toggle", http.StatusBadRequest},
|
||||
} {
|
||||
recorder := httptest.NewRecorder()
|
||||
router.ServeHTTP(recorder, httptest.NewRequest(http.MethodPost, tc.path, nil))
|
||||
if recorder.Code != tc.want {
|
||||
t.Errorf("%s status = %d, want %d", tc.path, recorder.Code, tc.want)
|
||||
}
|
||||
}
|
||||
if store.toggled != 12 || store.deleted != 404 {
|
||||
t.Fatalf("toggled = %d, deleted = %d", store.toggled, store.deleted)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHealth(t *testing.T) {
|
||||
store := &fakeStore{}
|
||||
router := testHandler(t, store)
|
||||
for _, tc := range []struct {
|
||||
err error
|
||||
want int
|
||||
}{{nil, http.StatusOK}, {errors.New("down"), http.StatusServiceUnavailable}} {
|
||||
store.pingErr = tc.err
|
||||
recorder := httptest.NewRecorder()
|
||||
router.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/healthz", nil))
|
||||
if recorder.Code != tc.want {
|
||||
t.Errorf("status = %d, want %d", recorder.Code, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRejectsCrossOriginMutation(t *testing.T) {
|
||||
router := testHandler(t, &fakeStore{})
|
||||
req := httptest.NewRequest(http.MethodPost, "/todos/1/delete", nil)
|
||||
req.Header.Set("Origin", "https://attacker.example")
|
||||
recorder := httptest.NewRecorder()
|
||||
router.ServeHTTP(recorder, req)
|
||||
if recorder.Code != http.StatusForbidden {
|
||||
t.Fatalf("status = %d", recorder.Code)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
type Todo struct {
|
||||
ID int64 `xorm:"pk autoincr 'id'"`
|
||||
Title string `xorm:"varchar(200) notnull 'title'"`
|
||||
Completed bool `xorm:"notnull default false 'completed'"`
|
||||
CreatedAt time.Time `xorm:"created 'created_at'"`
|
||||
UpdatedAt time.Time `xorm:"created updated 'updated_at'"`
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
content: ["./web/templates/**/*.html"],
|
||||
theme: { extend: {} },
|
||||
plugins: [],
|
||||
};
|
||||
@@ -0,0 +1,8 @@
|
||||
package web
|
||||
|
||||
import "embed"
|
||||
|
||||
// Assets contains server-rendered templates and compiled static files.
|
||||
//
|
||||
//go:embed templates static
|
||||
var Assets embed.FS
|
||||
@@ -0,0 +1,3 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,16 @@
|
||||
{{define "layout"}}
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Field Notes</title>
|
||||
<link rel="stylesheet" href="/static/app.css">
|
||||
</head>
|
||||
<body class="min-h-screen bg-stone-950 text-stone-100 antialiased">
|
||||
<main class="mx-auto max-w-3xl px-5 py-12 sm:px-8 sm:py-20">
|
||||
{{template "content" .}}
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
@@ -0,0 +1,37 @@
|
||||
{{define "content"}}
|
||||
<header class="mb-12 border-l-4 border-amber-400 pl-5">
|
||||
<p class="mb-2 font-mono text-xs uppercase tracking-[0.3em] text-amber-400">Daily ledger</p>
|
||||
<h1 class="text-4xl font-black tracking-tight sm:text-6xl">Field Notes</h1>
|
||||
<p class="mt-3 text-stone-400">Small commitments, made concrete.</p>
|
||||
</header>
|
||||
|
||||
<form action="/todos" method="post" class="mb-10 flex flex-col gap-3 sm:flex-row">
|
||||
<label for="title" class="sr-only">New task</label>
|
||||
<input id="title" name="title" required maxlength="200" autocomplete="off" placeholder="What needs doing?" class="min-w-0 flex-1 rounded-none border border-stone-700 bg-stone-900 px-4 py-3 text-base outline-none transition focus:border-amber-400 focus:ring-1 focus:ring-amber-400">
|
||||
<button class="bg-amber-400 px-6 py-3 font-bold text-stone-950 transition hover:bg-amber-300 focus:outline-none focus:ring-2 focus:ring-amber-200">Add note</button>
|
||||
</form>
|
||||
|
||||
<section aria-label="Tasks">
|
||||
{{if .Todos}}
|
||||
<ul class="divide-y divide-stone-800 border-y border-stone-800">
|
||||
{{range .Todos}}
|
||||
<li class="group flex items-center gap-4 py-5">
|
||||
<form action="/todos/{{.ID}}/toggle" method="post">
|
||||
<button aria-label="Toggle {{.Title}}" class="flex h-7 w-7 items-center justify-center border-2 border-stone-600 text-stone-950 transition hover:border-amber-400 {{if .Completed}}border-amber-400 bg-amber-400{{end}}">
|
||||
{{if .Completed}}<span aria-hidden="true">✓</span>{{end}}
|
||||
</button>
|
||||
</form>
|
||||
<span class="min-w-0 flex-1 break-words text-lg {{if .Completed}}text-stone-500 line-through{{end}}">{{.Title}}</span>
|
||||
<form action="/todos/{{.ID}}/delete" method="post">
|
||||
<button class="px-2 py-1 font-mono text-xs uppercase tracking-widest text-stone-500 transition hover:text-red-400" aria-label="Delete {{.Title}}">Delete</button>
|
||||
</form>
|
||||
</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
{{else}}
|
||||
<div class="border border-dashed border-stone-700 px-6 py-14 text-center text-stone-500">
|
||||
<p class="font-mono text-sm uppercase tracking-widest">The page is clear</p>
|
||||
</div>
|
||||
{{end}}
|
||||
</section>
|
||||
{{end}}
|
||||
Generated
+62
@@ -0,0 +1,62 @@
|
||||
# This file is maintained automatically by "tofu init".
|
||||
# Manual edits may be lost in future updates.
|
||||
|
||||
provider "registry.opentofu.org/hashicorp/aws" {
|
||||
version = "5.69.0"
|
||||
constraints = "~> 5.0, ~> 5.69.0"
|
||||
hashes = [
|
||||
"h1:24yeNWqOfM+nPkJHlg86rR1axakHRJyMOjVzVZ5UARk=",
|
||||
"h1:4rhzgp2XTwv8kDEUIWl7yUCk5fYu9eVH7VNheRTgp5Y=",
|
||||
"h1:8tg9jRiB0LiFrnkSEjvPh1YKTu41dl9wwR5+7bC54zg=",
|
||||
"h1:JSsC5+jtHtpuJKgbE8Nu+LB0GTcUj44P9UEdCTD6WBU=",
|
||||
"h1:U2T/EeTZPLdDn34j4XD5VUnTrhNbTRqM6/XZypO8aDY=",
|
||||
"h1:VCpHaMUt5w0IUw2VVhdIX6rU8qcHCECufnoAbavonyw=",
|
||||
"h1:Y8SU7HBR9jjaw+xQOso505ym+VHfW+gm/MFNfTcZtpg=",
|
||||
"h1:bmyh6fXoWQJVLEPPb/jmd5qCWlSnVEdAoxQADqrKqUc=",
|
||||
"h1:cNxrGkDy64UQ9GiAT+MvlDq1oUxnVMSXP2xLKL8+7sY=",
|
||||
"h1:oG99aP+wUZ2A1V3fGu/1wwhTdkDq8ByqbF5jtSCDeKo=",
|
||||
"zh:120dffdf0c32d412a9b1f06810a863950c4e95b5438e35fd102cb0f893bfb0ba",
|
||||
"zh:22c42d0e42b0edaf45ccc080825cba0ee332d0b29ca5858ba35d4a042d1a5c83",
|
||||
"zh:2b53841bed8787d914d687b0d295d7c5bce2adea6e306f2cacbef1f418abcdee",
|
||||
"zh:338e4491a2136c48de3de5e7c1aad1e59c3cb029caebbc4d5bf9a27e5cc3882d",
|
||||
"zh:4cec5ab455647659ac766ae5dcd576f2c527a977b285fac864cf2eaffa035874",
|
||||
"zh:7146550606696505b32bd9db0ce1096f6738773063ba0c3e61063333e71b1204",
|
||||
"zh:afbd454623d10c1b1e2f47c77689c72a8d845e321874549766fbb4eb082cfc77",
|
||||
"zh:c28cf51df765fa939d343fae99210896b590b5f58c63c824f3a3e5f4f2fa1405",
|
||||
"zh:c2dada7c25ab277573d57736041066df6f5eadbc6459a4302ff9f72cfdd4c287",
|
||||
"zh:eca3546b97e3df0dc860b99534a32bc3b6f44c123545b03b6733adbf4f150466",
|
||||
]
|
||||
}
|
||||
|
||||
provider "registry.opentofu.org/hetznercloud/hcloud" {
|
||||
version = "1.68.0"
|
||||
constraints = "~> 1.52"
|
||||
hashes = [
|
||||
"h1:226Z1Y5A30w6lKA7/EG6vzPn8inyZ9XYbnmpIzZcA58=",
|
||||
"h1:2MU8gDe67KjqALpvzzRfWONH2c/8djL+yxTnym9/FjY=",
|
||||
"h1:6PW+TynH6Po3bSEZ+3+qSeKN/KvuH3N3fWrm9t8HNcc=",
|
||||
"h1:8zYaD/LRDEAJDwyZx/gbbBL01b9oeGr1mhQeBCNmuhM=",
|
||||
"h1:BrgHGuh9WEMd8CQefgdSqhcePf4CZPdovwlHNdo3h70=",
|
||||
"h1:CPBOIltDqRCFo99PHVgt/8z5r+P3JyyiJygsbIozZC4=",
|
||||
"h1:KOFp1JbzZ6Xj2K80QL7HGJM6oG+oEo7tx3lIx3d5POM=",
|
||||
"h1:KvAWG1HTh3EO8PNymwQQ9+zpAQa3wRvzLvRjRM5jxq4=",
|
||||
"h1:Zxdt/lmDDS+Uty9kViYoYNxYRlC3CUk2v4l3+XPcibo=",
|
||||
"h1:gWVxkXIPIJcwAl74VMTz0hgaiJ/2feZIo4KMRQ+l9io=",
|
||||
"h1:oxpWvl0qfZplr759glEx537EqkkAgtagBtNsI8V13H8=",
|
||||
"h1:vXPWFLMNZzSEutr1Z66zicXVx4QAzUXqm3LCYXQH81U=",
|
||||
"h1:ynTUfNuk5ppK9aniyVQi9HcQ+pdYmoGFX9U2LJgfzd0=",
|
||||
"zh:0c3c2eb5bad6e2853da46dd83ed9c93c547b79f2abe977d492da9cf9d7236a62",
|
||||
"zh:0ccae6bb2daee7c30b55672645b31c50920839a8aadac502d187f105352c1b37",
|
||||
"zh:11559d821030e5f4657b81153419f3c5e4f6389e8cc6f8086db2ebeb7aade6bc",
|
||||
"zh:164012cca257951da2870e9a0a0eb0be2ebde26fd27e30081c8d7e8729467a6e",
|
||||
"zh:1fc7fea548dc8a0211ee832b0f1be489301f431cdfb153effd2fda7f63c02073",
|
||||
"zh:73855025066ec55230559296b5c92d441b1b6dc2ce27a1102ab1e17f60cdcf0e",
|
||||
"zh:78bfcca5d613f3285c2e326e92865d08e24d0574e29201ce9c1df612aa912a10",
|
||||
"zh:84d6972ef2d6e03dc416ab34c8e266e0958ff390c7ad87761180706658b31614",
|
||||
"zh:a04f8e12811e000657151cd6eed5eb80ae1042b8123bfea28343147ceda40f96",
|
||||
"zh:b825f6500592d30004b6de8547ca1fa7841e16da0571cee8a67782cb84ec65cd",
|
||||
"zh:d6bc5f6f27453248889e5df967111a187405f1434175a90221853226d47484af",
|
||||
"zh:e052ad7a40cf1f63588c2b435ce7a9baca5ef0e1f9dcb321a06756eb3d38751a",
|
||||
"zh:ea1497399246a51f16e3ff4a13dfba4c95a49997c2da7850703a086e211b7435",
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
hcloud_token = "replace-with-hcloud-token"
|
||||
project_name = "todo"
|
||||
network_zone = "eu-central"
|
||||
server_location = "nbg1"
|
||||
server_type = "cx22"
|
||||
ssh_key_ids = [123456]
|
||||
ssh_source_cidrs = ["203.0.113.10/32"]
|
||||
prod_private_ip = "10.0.1.10"
|
||||
|
||||
s3_access_key = "replace-with-object-storage-access-key"
|
||||
s3_secret_key = "replace-with-object-storage-secret-key"
|
||||
s3_endpoint = "https://nbg1.your-objectstorage.com"
|
||||
s3_region = "nbg1"
|
||||
bucket_name = "replace-with-globally-unique-backup-bucket"
|
||||
@@ -0,0 +1 @@
|
||||
staging_private_ip = "10.0.1.20"
|
||||
+140
@@ -0,0 +1,140 @@
|
||||
provider "hcloud" {
|
||||
token = var.hcloud_token
|
||||
}
|
||||
|
||||
provider "aws" {
|
||||
access_key = var.s3_access_key
|
||||
secret_key = var.s3_secret_key
|
||||
region = var.s3_region
|
||||
skip_credentials_validation = true
|
||||
skip_metadata_api_check = true
|
||||
skip_region_validation = true
|
||||
skip_requesting_account_id = true
|
||||
s3_use_path_style = true
|
||||
|
||||
endpoints {
|
||||
s3 = var.s3_endpoint
|
||||
}
|
||||
}
|
||||
|
||||
resource "hcloud_network" "main" {
|
||||
name = "${var.project_name}-network"
|
||||
ip_range = "10.0.0.0/16"
|
||||
}
|
||||
|
||||
resource "hcloud_network_subnet" "main" {
|
||||
network_id = hcloud_network.main.id
|
||||
type = "cloud"
|
||||
network_zone = var.network_zone
|
||||
ip_range = "10.0.1.0/24"
|
||||
}
|
||||
|
||||
resource "hcloud_firewall" "nodes" {
|
||||
name = "${var.project_name}-nodes"
|
||||
|
||||
rule {
|
||||
direction = "in"
|
||||
protocol = "tcp"
|
||||
port = "22"
|
||||
source_ips = var.ssh_source_cidrs
|
||||
}
|
||||
|
||||
rule {
|
||||
direction = "in"
|
||||
protocol = "tcp"
|
||||
port = "8080"
|
||||
source_ips = [hcloud_network.main.ip_range]
|
||||
}
|
||||
|
||||
rule {
|
||||
direction = "in"
|
||||
protocol = "icmp"
|
||||
source_ips = [
|
||||
"0.0.0.0/0",
|
||||
"::/0",
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
module "prod" {
|
||||
source = "./modules/compute"
|
||||
|
||||
name = "${var.project_name}-prod"
|
||||
server_type = var.server_type
|
||||
location = var.server_location
|
||||
ssh_key_ids = var.ssh_key_ids
|
||||
firewall_ids = [hcloud_firewall.nodes.id]
|
||||
network_id = hcloud_network.main.id
|
||||
private_ip = var.prod_private_ip
|
||||
environment = "prod"
|
||||
depends_on = [hcloud_network_subnet.main]
|
||||
}
|
||||
|
||||
module "staging" {
|
||||
source = "./modules/compute"
|
||||
|
||||
name = "${var.project_name}-staging"
|
||||
server_type = var.server_type
|
||||
location = var.server_location
|
||||
ssh_key_ids = var.ssh_key_ids
|
||||
firewall_ids = [hcloud_firewall.nodes.id]
|
||||
network_id = hcloud_network.main.id
|
||||
private_ip = var.staging_private_ip
|
||||
environment = "staging"
|
||||
depends_on = [hcloud_network_subnet.main]
|
||||
}
|
||||
|
||||
module "storage" {
|
||||
source = "./modules/storage"
|
||||
|
||||
bucket_name = var.bucket_name
|
||||
}
|
||||
|
||||
resource "hcloud_load_balancer" "app" {
|
||||
name = "${var.project_name}-lb"
|
||||
load_balancer_type = "lb11"
|
||||
location = var.server_location
|
||||
algorithm {
|
||||
type = "round_robin"
|
||||
}
|
||||
}
|
||||
|
||||
resource "hcloud_load_balancer_network" "app" {
|
||||
load_balancer_id = hcloud_load_balancer.app.id
|
||||
network_id = hcloud_network.main.id
|
||||
depends_on = [hcloud_network_subnet.main]
|
||||
}
|
||||
|
||||
resource "hcloud_load_balancer_target" "prod" {
|
||||
type = "server"
|
||||
load_balancer_id = hcloud_load_balancer.app.id
|
||||
server_id = module.prod.server_id
|
||||
use_private_ip = true
|
||||
depends_on = [
|
||||
hcloud_load_balancer_network.app,
|
||||
module.prod,
|
||||
]
|
||||
}
|
||||
|
||||
resource "hcloud_load_balancer_service" "http" {
|
||||
load_balancer_id = hcloud_load_balancer.app.id
|
||||
protocol = "http"
|
||||
listen_port = 80
|
||||
destination_port = 8080
|
||||
|
||||
http {
|
||||
sticky_sessions = false
|
||||
}
|
||||
|
||||
health_check {
|
||||
protocol = "http"
|
||||
port = 8080
|
||||
interval = 15
|
||||
timeout = 10
|
||||
retries = 3
|
||||
http {
|
||||
path = "/healthz"
|
||||
status_codes = ["200"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
resource "hcloud_primary_ip" "ipv4" {
|
||||
name = "${var.name}-ipv4"
|
||||
type = "ipv4"
|
||||
auto_delete = false
|
||||
location = var.location
|
||||
}
|
||||
|
||||
resource "hcloud_primary_ip" "ipv6" {
|
||||
name = "${var.name}-ipv6"
|
||||
type = "ipv6"
|
||||
auto_delete = false
|
||||
location = var.location
|
||||
}
|
||||
|
||||
resource "hcloud_server" "this" {
|
||||
name = var.name
|
||||
image = "rocky-10"
|
||||
server_type = var.server_type
|
||||
location = var.location
|
||||
ssh_keys = var.ssh_key_ids
|
||||
firewall_ids = var.firewall_ids
|
||||
labels = {
|
||||
environment = var.environment
|
||||
application = "todo"
|
||||
}
|
||||
|
||||
public_net {
|
||||
ipv4 = hcloud_primary_ip.ipv4.id
|
||||
ipv4_enabled = true
|
||||
ipv6 = hcloud_primary_ip.ipv6.id
|
||||
ipv6_enabled = true
|
||||
}
|
||||
}
|
||||
|
||||
resource "hcloud_server_network" "this" {
|
||||
server_id = hcloud_server.this.id
|
||||
network_id = var.network_id
|
||||
ip = var.private_ip
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
output "server_id" { value = hcloud_server.this.id }
|
||||
output "name" { value = hcloud_server.this.name }
|
||||
output "private_ip" { value = hcloud_server_network.this.ip }
|
||||
output "ipv4" { value = hcloud_primary_ip.ipv4.ip_address }
|
||||
output "ipv6" { value = hcloud_primary_ip.ipv6.ip_address }
|
||||
@@ -0,0 +1,8 @@
|
||||
variable "name" { type = string }
|
||||
variable "server_type" { type = string }
|
||||
variable "location" { type = string }
|
||||
variable "ssh_key_ids" { type = list(number) }
|
||||
variable "firewall_ids" { type = list(number) }
|
||||
variable "network_id" { type = number }
|
||||
variable "private_ip" { type = string }
|
||||
variable "environment" { type = string }
|
||||
@@ -0,0 +1,8 @@
|
||||
terraform {
|
||||
required_providers {
|
||||
hcloud = {
|
||||
source = "hetznercloud/hcloud"
|
||||
version = "~> 1.52"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
resource "aws_s3_bucket" "backups" {
|
||||
bucket = var.bucket_name
|
||||
force_destroy = false
|
||||
}
|
||||
|
||||
resource "aws_s3_bucket_versioning" "backups" {
|
||||
bucket = aws_s3_bucket.backups.id
|
||||
versioning_configuration {
|
||||
status = "Enabled"
|
||||
}
|
||||
}
|
||||
|
||||
resource "aws_s3_bucket_lifecycle_configuration" "backups" {
|
||||
bucket = aws_s3_bucket.backups.id
|
||||
|
||||
rule {
|
||||
id = "abort-incomplete-uploads"
|
||||
status = "Enabled"
|
||||
filter {}
|
||||
abort_incomplete_multipart_upload {
|
||||
days_after_initiation = 7
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
output "bucket" {
|
||||
value = {
|
||||
id = aws_s3_bucket.backups.id
|
||||
name = aws_s3_bucket.backups.bucket
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
variable "bucket_name" { type = string }
|
||||
@@ -0,0 +1,8 @@
|
||||
terraform {
|
||||
required_providers {
|
||||
aws = {
|
||||
source = "hashicorp/aws"
|
||||
version = "~> 5.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
output "network" {
|
||||
value = {
|
||||
id = hcloud_network.main.id
|
||||
subnet_id = hcloud_network_subnet.main.id
|
||||
subnet = hcloud_network_subnet.main.ip_range
|
||||
prod_ip = module.prod.private_ip
|
||||
staging_ip = module.staging.private_ip
|
||||
}
|
||||
}
|
||||
|
||||
output "instances" {
|
||||
value = {
|
||||
prod = module.prod
|
||||
staging = module.staging
|
||||
}
|
||||
}
|
||||
|
||||
output "load_balancer" {
|
||||
value = {
|
||||
id = hcloud_load_balancer.app.id
|
||||
ipv4 = hcloud_load_balancer.app.ipv4
|
||||
ipv6 = hcloud_load_balancer.app.ipv6
|
||||
hostname = hcloud_load_balancer.app.name
|
||||
}
|
||||
}
|
||||
|
||||
output "backup_bucket" {
|
||||
value = module.storage.bucket
|
||||
}
|
||||
|
||||
output "ansible_inventory" {
|
||||
description = "Secret-free Ansible inventory for the provisioned nodes."
|
||||
value = yamlencode({
|
||||
all = {
|
||||
vars = {
|
||||
ansible_user = "root"
|
||||
}
|
||||
children = {
|
||||
prod = {
|
||||
hosts = {
|
||||
(module.prod.name) = {
|
||||
ansible_host = module.prod.ipv4
|
||||
private_ip = module.prod.private_ip
|
||||
deployment_environment = "prod"
|
||||
}
|
||||
}
|
||||
}
|
||||
staging = {
|
||||
hosts = {
|
||||
(module.staging.name) = {
|
||||
ansible_host = module.staging.ipv4
|
||||
private_ip = module.staging.private_ip
|
||||
deployment_environment = "staging"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
variable "hcloud_token" {
|
||||
type = string
|
||||
sensitive = true
|
||||
}
|
||||
|
||||
variable "project_name" {
|
||||
type = string
|
||||
default = "todo"
|
||||
}
|
||||
|
||||
variable "network_zone" {
|
||||
type = string
|
||||
default = "eu-central"
|
||||
}
|
||||
|
||||
variable "server_location" {
|
||||
type = string
|
||||
default = "nbg1"
|
||||
}
|
||||
|
||||
variable "server_type" {
|
||||
type = string
|
||||
default = "cx22"
|
||||
}
|
||||
|
||||
variable "ssh_key_ids" {
|
||||
type = list(number)
|
||||
}
|
||||
|
||||
variable "ssh_source_cidrs" {
|
||||
type = list(string)
|
||||
description = "Trusted public CIDRs permitted to SSH to nodes."
|
||||
}
|
||||
|
||||
variable "prod_private_ip" {
|
||||
type = string
|
||||
default = "10.0.1.10"
|
||||
}
|
||||
|
||||
variable "staging_private_ip" {
|
||||
type = string
|
||||
default = "10.0.1.20"
|
||||
}
|
||||
|
||||
variable "s3_access_key" {
|
||||
type = string
|
||||
sensitive = true
|
||||
}
|
||||
|
||||
variable "s3_secret_key" {
|
||||
type = string
|
||||
sensitive = true
|
||||
}
|
||||
|
||||
variable "s3_endpoint" {
|
||||
type = string
|
||||
description = "Hetzner Object Storage endpoint, for example https://nbg1.your-objectstorage.com."
|
||||
}
|
||||
|
||||
variable "s3_region" {
|
||||
type = string
|
||||
default = "nbg1"
|
||||
}
|
||||
|
||||
variable "bucket_name" {
|
||||
type = string
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
terraform {
|
||||
required_version = ">= 1.8.0"
|
||||
required_providers {
|
||||
hcloud = {
|
||||
source = "hetznercloud/hcloud"
|
||||
version = "~> 1.52"
|
||||
}
|
||||
aws = {
|
||||
source = "hashicorp/aws"
|
||||
/*
|
||||
Pinned to version due to versions v5.70.0
|
||||
and newer wait for an AWS-specific response
|
||||
header that Hetzner's S3-compatible service
|
||||
does not return.
|
||||
https://github.com/hashicorp/terraform-provider-aws/issues/49019
|
||||
https://github.com/hashicorp/terraform-provider-aws/issues/43333
|
||||
*/
|
||||
version = "~> 5.69.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user