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:
cole-maxwell1
2026-08-06 21:34:56 -05:00
commit 1382c42803
68 changed files with 2380 additions and 0 deletions
+15
View File
@@ -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
+114
View File
@@ -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.
+39
View File
@@ -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
+347
View File
@@ -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`
+11
View File
@@ -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
+22
View File
@@ -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"
+16
View File
@@ -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
+43
View File
@@ -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
+8
View File
@@ -0,0 +1,8 @@
---
- name: Configure todo nodes
hosts: all
become: true
roles:
- common
- postgres
- wal_g
+4
View File
@@ -0,0 +1,4 @@
app_user: todo
app_port: 8080
app_db_name: todo
app_db_user: todo_app
+71
View File
@@ -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
+2
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
app_user: todo
+37
View File
@@ -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
+4
View File
@@ -0,0 +1,4 @@
postgres_data_dir: /var/lib/pgsql/data
postgres_service: postgresql
app_db_name: todo
app_db_user: todo_app
+5
View File
@@ -0,0 +1,5 @@
---
- name: Restart PostgreSQL
ansible.builtin.systemd_service:
name: "{{ postgres_service }}"
state: restarted
+75
View File
@@ -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'
+2
View File
@@ -0,0 +1,2 @@
wal_g_version: v3.0.7
wal_g_retention_count: 7
+12
View File
@@ -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
+123
View File
@@ -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
+14
View File
@@ -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
+9
View File
@@ -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
+9
View File
@@ -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
+3
View File
@@ -0,0 +1,3 @@
bin
tailwindcss
.git
+27
View File
@@ -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"]
+27
View File
@@ -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)
+77
View File
@@ -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
View File
@@ -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
View File
@@ -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=
+64
View File
@@ -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
}
+57
View File
@@ -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)
}
}
+167
View File
@@ -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")
}
+130
View File
@@ -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(), "&lt;script&gt;") {
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)
}
}
+11
View File
@@ -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'"`
}
+6
View File
@@ -0,0 +1,6 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: ["./web/templates/**/*.html"],
theme: { extend: {} },
plugins: [],
};
+8
View File
@@ -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
+3
View File
@@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
File diff suppressed because one or more lines are too long
+16
View File
@@ -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}}
+37
View File
@@ -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}}
+62
View File
@@ -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
View File
@@ -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"]
}
}
}
+39
View File
@@ -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
}
+5
View File
@@ -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 }
+8
View File
@@ -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 }
+8
View File
@@ -0,0 +1,8 @@
terraform {
required_providers {
hcloud = {
source = "hetznercloud/hcloud"
version = "~> 1.52"
}
}
}
+24
View File
@@ -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
}
}
}
+6
View File
@@ -0,0 +1,6 @@
output "bucket" {
value = {
id = aws_s3_bucket.backups.id
name = aws_s3_bucket.backups.bucket
}
}
+1
View File
@@ -0,0 +1 @@
variable "bucket_name" { type = string }
+8
View File
@@ -0,0 +1,8 @@
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
+60
View File
@@ -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"
}
}
}
}
}
})
}
+67
View File
@@ -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
}
+21
View File
@@ -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"
}
}
}