78881e1b07
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>
399 lines
12 KiB
Markdown
399 lines
12 KiB
Markdown
# 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
|