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>
This commit is contained in:
2026-02-14 22:39:11 +01:00
parent 0352c22b4f
commit 78881e1b07
15 changed files with 2118 additions and 128 deletions
+398
View File
@@ -0,0 +1,398 @@
# 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.
## Recommended Deployment Architecture
```
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
```yaml
# meta/main.yml
dependencies:
- role: caddy
```
No dependency on `postgresql` or `valkey` — opal doesn't use them.
### Default Variables
```yaml
# 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`:
```yaml
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:
```yaml
- 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
```ini
# 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
```caddyfile
# 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
```bash
# 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
### Option A: Local build + Ansible deploy (recommended for now)
Build on the dev machine, deploy with Ansible:
```bash
# 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
```yaml
# .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:
```bash
# 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):
```yaml
- hosts: your-server
roles:
# ... existing roles ...
- role: opal
tags: [opal]
```
Deploy independently:
```bash
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