Files
gems/docs/infrastructure-integration.md
joakim 78881e1b07 feat: add parse endpoint, refactor recurring tasks, and improve web task completion
Extract CreateRecurringTask into engine package for reuse by both CLI
and API. Add POST /tasks/parse endpoint for CLI-style input parsing.
Remove FK constraint on change_log to preserve history after task
deletion. Update web frontend to filter completed tasks from view and
add mock mode support for development.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 23:49:20 +01:00

12 KiB

Opal Infrastructure Integration Plan

Context

This document describes how to integrate opal-task (Go API) and opal-web (SvelteKit PWA) into the existing your-infra Ansible infrastructure on the your-server homelab server.

Current your-infra Architecture

The VPS (your-server / 203.0.113.10) runs Arch Linux with:

  • Caddy reverse proxy with auto-HTTPS (Let's Encrypt + Cloudflare DNS challenge)
  • PostgreSQL and Valkey as native systemd services, exposed via Unix sockets only
  • Podman with Quadlet for containerized services (Authentik, etc.)
  • Authentik SSO at auth.example.com
  • Services added as Ansible roles in roles/<service>/
  • Secrets managed via Ansible Vault (vault.yml)
  • Caddy configs use sites-enabled pattern (/etc/caddy/sites-enabled/*.caddy)

Opal Service Profile

Property opal-task opal-web
Type Go binary (REST API) Static site (SvelteKit PWA)
Database SQLite (embedded) None (client-side localStorage)
External deps None None
Port :8080 (configurable) N/A (static files)
Auth OAuth2/JWT via Authentik Passes tokens to API
Build output Single binary build/ directory (HTML/JS/CSS)

Key observation: opal has zero external service dependencies — no PostgreSQL, no Valkey, no message queues. SQLite is embedded in the Go binary.

opal.example.com (Caddy, auto-HTTPS)
├── /*       →  file_server /var/www/opal/   (static PWA)
├── /api/*   →  reverse_proxy :8080          (opal-task binary)
└── /auth/*  →  reverse_proxy :8080          (opal-task OAuth endpoints)

auth.example.com (Authentik, already running)
└── OAuth2/OIDC provider for opal

Decision: Native systemd service, not containerized

opal-task should run as a native systemd service, not in a Podman container.

Rationale:

  • Single static binary with no runtime dependencies
  • SQLite is embedded — no database socket mounts needed
  • No benefit from container isolation (no network services to fence off, no complex filesystem needs)
  • Simpler deployment: copy binary, restart service
  • Matches patterns used by other native services in the infrastructure
  • Containerizing would add Quadlet files, image builds, and volume mounts for zero gain

Decision: Static file serving for frontend

opal-web builds to static HTML/JS/CSS via adapter-static. Caddy serves these directly with file_server and SPA fallback. No Node.js runtime on the server.

Rationale:

  • SvelteKit adapter-static produces plain files — no SSR, no server process
  • Caddy already handles compression, caching headers, and HTTP/2
  • Same pattern as other static frontend
  • PWA service worker handles offline caching client-side

Decision: Keep SQLite, skip PostgreSQL

opal-task is a single-user personal task manager with device sync. SQLite is the correct database.

Rationale:

  • Single writer (one API server process) — no write contention
  • Backup is sqlite3 .backup or just cp the file
  • No connection pooling, no socket permissions, no user/role management
  • Moving to PostgreSQL would require rewriting the data layer for no benefit
  • If multi-user support is ever needed, this can be revisited

Ansible Role Design

Create roles/opal/ in your-infra:

roles/opal/
├── defaults/main.yml
├── meta/main.yml
├── tasks/
│   ├── main.yml
│   ├── user.yml
│   ├── backend.yml
│   ├── frontend.yml
│   └── caddy.yml
├── templates/
│   ├── opal.service.j2
│   ├── opal.env.j2
│   └── opal.caddy.j2
├── handlers/
│   └── main.yml
└── files/
    └── (binary + static build copied here during deploy)

Role Dependencies

# meta/main.yml
dependencies:
  - role: caddy

No dependency on postgresql or valkey — opal doesn't use them.

Default Variables

# defaults/main.yml
opal_domain: "opal.example.com"
opal_user: "opal"
opal_group: "opal"

# Paths
opal_binary_path: "/usr/local/bin/opal"
opal_data_dir: "/var/lib/opal"
opal_config_dir: "/etc/opal"
opal_web_root: "/var/www/opal"
opal_db_path: "{{ opal_data_dir }}/opal.db"

# Server
opal_server_addr: ":8080"

# OAuth (secrets from vault)
opal_oauth_enabled: true
opal_oauth_issuer: "https://auth.example.com/application/o/opal/"
opal_oauth_redirect_uri: "https://{{ opal_domain }}/auth/callback"
opal_oauth_client_id: "{{ vault_opal_oauth_client_id }}"
opal_oauth_client_secret: "{{ vault_opal_oauth_client_secret }}"

# JWT (secret from vault)
opal_jwt_secret: "{{ vault_opal_jwt_secret }}"
opal_jwt_expiry: 3600
opal_refresh_token_expiry: 604800

Vault Secrets

Add to host_vars/your-server/vault.yml:

vault_opal_oauth_client_id: "<from Authentik>"
vault_opal_oauth_client_secret: "<from Authentik>"
vault_opal_jwt_secret: "<openssl rand -hex 32>"

Task Breakdown

user.yml — Create dedicated system user:

- name: Create opal system user
  ansible.builtin.user:
    name: "{{ opal_user }}"
    group: "{{ opal_group }}"
    system: true
    shell: /bin/false
    home: "{{ opal_data_dir }}"
    create_home: false

backend.yml — Deploy binary and systemd unit:

  1. Create directories (/var/lib/opal, /etc/opal)
  2. Copy pre-built binary to /usr/local/bin/opal
  3. Template environment file to /etc/opal/opal.env
  4. Template systemd unit to /etc/systemd/system/opal.service
  5. Enable and start the service

frontend.yml — Deploy static build:

  1. Create /var/www/opal/
  2. Synchronize build output to web root
  3. Set ownership to root:root (Caddy reads as root)

caddy.yml — Deploy reverse proxy config:

  1. Template Caddy site config to /etc/caddy/sites-enabled/opal.caddy
  2. Notify Caddy reload handler

systemd Unit Template

# opal.service.j2
[Unit]
Description=Opal Task API Server
After=network.target

[Service]
Type=simple
User={{ opal_user }}
Group={{ opal_group }}
WorkingDirectory={{ opal_data_dir }}
EnvironmentFile={{ opal_config_dir }}/opal.env
ExecStart={{ opal_binary_path }} server start --addr {{ opal_server_addr }}
Restart=always
RestartSec=5

# Hardening
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths={{ opal_data_dir }}
ReadOnlyPaths={{ opal_config_dir }}
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
RestrictSUIDSGID=true
RestrictNamespaces=true
MemoryDenyWriteExecute=true

[Install]
WantedBy=multi-user.target

Caddy Site Template

# opal.caddy.j2
{{ opal_domain }} {
    root * {{ opal_web_root }}

    # API and auth endpoints → Go backend
    handle /api/* {
        uri strip_prefix /api
        reverse_proxy localhost{{ opal_server_addr }}
    }

    handle /auth/* {
        reverse_proxy localhost{{ opal_server_addr }}
    }

    # Static PWA with SPA fallback
    handle {
        try_files {path} /index.html
        file_server
    }

    header {
        Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
        X-Frame-Options "SAMEORIGIN"
        X-Content-Type-Options "nosniff"
        Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self' https://auth.example.com;"
        -Server
    }

    log {
        output file /var/log/caddy/opal.log
        format json
    }

    encode gzip zstd
}

Environment File Template

# opal.env.j2
# Server
SERVER_ADDR={{ opal_server_addr }}
OPAL_CONFIG_DIR={{ opal_config_dir }}
OPAL_DATA_DIR={{ opal_data_dir }}

# OAuth
OAUTH_ENABLED={{ opal_oauth_enabled | lower }}
OAUTH_ISSUER={{ opal_oauth_issuer }}
OAUTH_CLIENT_ID={{ opal_oauth_client_id }}
OAUTH_CLIENT_SECRET={{ opal_oauth_client_secret }}
OAUTH_REDIRECT_URI={{ opal_oauth_redirect_uri }}

# JWT
JWT_SECRET={{ opal_jwt_secret }}
JWT_EXPIRY={{ opal_jwt_expiry }}
REFRESH_TOKEN_EXPIRY={{ opal_refresh_token_expiry }}

Build & Deploy Strategy

Build on the dev machine, deploy with Ansible:

# Build backend (cross-compile for linux/amd64)
cd opal-task
GOOS=linux GOARCH=amd64 CGO_ENABLED=1 go build -o opal main.go

# Build frontend
cd opal-web
npm run build

# Deploy
ansible-playbook playbooks/deploy-opal.yml --ask-vault-pass

The Ansible playbook copies the pre-built binary and static files to the server. This keeps the server clean (no Go toolchain, no Node.js).

Note on CGO: opal-task uses mattn/go-sqlite3 which requires CGO. Cross-compiling with CGO_ENABLED=1 needs a C cross-compiler (x86_64-linux-gnu-gcc or equivalent). If building on the same architecture as the server, this just works. For true cross-compilation, consider using zig cc as the C compiler or building in a Docker container matching the target.

Option B: GitHub Actions CI/CD (future)

Follow the existing CI/CD pattern already in your-infra:

  1. Push to main triggers workflow
  2. Build Go binary in a Linux container (solves CGO cross-compilation)
  3. Build SvelteKit static site
  4. SSH to your-server, copy artifacts, restart service
# .github/workflows/deploy.yml (sketch)
on:
  push:
    branches: [main]
    paths:
      - 'opal-task/**'
      - 'opal-web/**'

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Build backend
        run: |
          cd opal-task
          go build -o opal main.go

      - name: Build frontend
        run: |
          cd opal-web
          npm ci
          npm run build

      - name: Deploy
        run: |
          scp opal-task/opal ${{ secrets.VPS_USER }}@${{ secrets.VPS_HOST }}:/tmp/opal
          rsync -avz --delete opal-web/build/ ${{ secrets.VPS_USER }}@${{ secrets.VPS_HOST }}:/var/www/opal/
          ssh ${{ secrets.VPS_USER }}@${{ secrets.VPS_HOST }} \
            "sudo cp /tmp/opal /usr/local/bin/ && sudo systemctl restart opal"

Backup Strategy

SQLite backup is trivial — one file, atomic snapshots:

# Daily backup via cron or systemd timer
sqlite3 /var/lib/opal/opal.db ".backup /var/backups/opal/opal-$(date +%Y%m%d).db"

# Retention: keep 14 days
find /var/backups/opal/ -name "opal-*.db" -mtime +14 -delete

This can be added as a systemd timer in the Ansible role, or as a cron entry.

Monitoring

opal-task exposes GET /health with no authentication. Add this to the existing metrics stack:

  • Caddy access logs in JSON at /var/log/caddy/opal.log
  • systemd journal for opal service (journalctl -u opal)
  • Health check polling from VictoriaMetrics or a simple uptime probe

Playbook Integration

Add to your-infra.yml (homelab playbook):

- hosts: your-server
  roles:
    # ... existing roles ...
    - role: opal
      tags: [opal]

Deploy independently:

ansible-playbook your-infra.yml --tags opal --ask-vault-pass

Or create a dedicated playbooks/deploy-opal.yml for quick redeploys.

Checklist

  • Create Authentik OAuth application for opal (see docs/authentik-setup.md)
  • Add vault secrets to host_vars/your-server/vault.yml
  • Create roles/opal/ in your-infra
  • Point DNS opal.example.com to your-server IP
  • Build and deploy backend binary
  • Build and deploy frontend static files
  • Verify health check: curl https://opal.example.com/api/health
  • Test OAuth login flow end-to-end
  • Set up SQLite backup timer
  • (Optional) Add GitHub Actions CI/CD workflow