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:
@@ -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
|
||||
Reference in New Issue
Block a user