Compare commits
13 Commits
6e77c1ada5
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
763ac922d5 | ||
|
|
1b3200a5f1 | ||
|
|
dc5518b57f | ||
|
|
bb5a76b864 | ||
|
|
219a28bc65 | ||
|
|
387838819b | ||
|
|
c80d8dc411 | ||
|
|
4d983819c9 | ||
|
|
9f5057dff8 | ||
|
|
d9f10716b0 | ||
|
|
74570413fc | ||
|
|
7aa3e04829 | ||
|
|
368e3a8455 |
@@ -1,254 +0,0 @@
|
|||||||
# Adding Apps - Notes & Documentation Improvements
|
|
||||||
|
|
||||||
## Documentation Improvement Candidates
|
|
||||||
|
|
||||||
Issues and gaps discovered while adding apps. These should be folded back into ADDING-APPS.md.
|
|
||||||
|
|
||||||
### 1. Node.js app memory requirements
|
|
||||||
Node.js apps (NocoDB, Outline, etc.) need more than 512Mi memory. NocoDB crashed with OOM at 512Mi.
|
|
||||||
- Recommended: 1Gi limit, 512Mi request for Node.js apps
|
|
||||||
- Set `NODE_OPTIONS=--max-old-space-size=768` to keep heap within the limit
|
|
||||||
- **Doc suggestion**: Add a section in ADDING-APPS.md about runtime-specific resource defaults:
|
|
||||||
- Node.js apps: 1Gi limit, 512Mi request; add NODE_OPTIONS env var
|
|
||||||
- JVM apps (Java/Kotlin): 1Gi+ limit
|
|
||||||
- Python/Ruby apps: 512Mi typically sufficient
|
|
||||||
- Go/Rust apps: 256-512Mi typically sufficient
|
|
||||||
|
|
||||||
### 2. db-init-job is repeated boilerplate
|
|
||||||
The db-init-job.yaml for PostgreSQL apps is nearly identical across all apps. Only the app name, namespace, and secret name change.
|
|
||||||
- **Doc suggestion**: Provide a copy-paste template for the standard PostgreSQL db-init-job with placeholders marked clearly.
|
|
||||||
|
|
||||||
### 4. Apps requiring hex-encoded secrets
|
|
||||||
Some apps (Outline) require secrets in specific formats (e.g., exactly 64 hex chars). The default Wild Cloud `GenerateSecret` produces alphanumeric (not hex) strings.
|
|
||||||
- **Solution**: Use `crypto.SHA256` gomplate function in the manifest `default` field to generate a valid 64-char hex string from another secret:
|
|
||||||
```yaml
|
|
||||||
defaultSecrets:
|
|
||||||
- key: utilsSecret
|
|
||||||
- key: secretKey
|
|
||||||
default: '{{ crypto.SHA256 .secrets.utilsSecret }}'
|
|
||||||
```
|
|
||||||
- **Doc suggestion**: Add this pattern to ADDING-APPS.md with examples of apps that need it. Note that the order of `defaultSecrets` matters — referenced secrets must come first.
|
|
||||||
|
|
||||||
### 6. Always use `?sslmode=disable` in PostgreSQL connection strings
|
|
||||||
The Wild Cloud internal PostgreSQL does not have SSL enabled. Apps that construct PostgreSQL connection strings must include `?sslmode=disable`, and deployments should set `PGSSLMODE=disable`. Without it, apps crash with "The server does not support SSL connections."
|
|
||||||
- Already done correctly in: listmonk, gitea, discourse
|
|
||||||
- Missing from: outline (fixed), any new app
|
|
||||||
- **Doc suggestion**: Add to the ADDING-APPS.md dbUrl template examples: always append `?sslmode=disable` to postgres connection strings.
|
|
||||||
|
|
||||||
### 5. Re-running `wild app add` is required after template changes
|
|
||||||
When you modify files in wild-directory (e.g., fix a template), you must re-run `wild app add <app>` before deploying. The compiled templates in the instance `apps/` directory are NOT automatically updated when wild-directory changes. Only then will `wild app deploy` use the updated templates.
|
|
||||||
- **Doc suggestion**: Add to ADDING-APPS.md: "After modifying templates in wild-directory, always re-run `wild app add <app>` to recompile before deploying."
|
|
||||||
|
|
||||||
### 7. WriteFreely requires root user (jrasanen/writefreely image)
|
|
||||||
The `jrasanen/writefreely` image startup script creates `config.ini` in `/writefreely/` which is a root-owned directory. The image's default user is non-root, causing "Permission denied" errors at startup.
|
|
||||||
- **Fix**: Set `runAsUser: 0` and `runAsNonRoot: false` in the pod securityContext
|
|
||||||
- Still safe with `allowPrivilegeEscalation: false` and `capabilities.drop: ALL`
|
|
||||||
- **Doc suggestion**: Note that some community images require root. When an image fails with permission denied on startup, check if it needs root and add the security context override with capabilities dropped.
|
|
||||||
|
|
||||||
### 8. Redis URL must include password for authenticated Redis
|
|
||||||
Wild Cloud's Redis requires authentication. Apps that take a Redis URL must include the password in the URL: `redis://:password@host:6379`.
|
|
||||||
- **Pattern**: Add `redis.password` to `requiredSecrets`, then use K8s env var expansion:
|
|
||||||
```yaml
|
|
||||||
- name: REDIS_PASSWORD
|
|
||||||
valueFrom:
|
|
||||||
secretKeyRef:
|
|
||||||
name: myapp-secrets
|
|
||||||
key: redis.password
|
|
||||||
- name: REDIS_URL
|
|
||||||
value: redis://:$(REDIS_PASSWORD)@{{ .redis.host }}:6379
|
|
||||||
```
|
|
||||||
- The `$(VAR_NAME)` syntax is supported by Kubernetes in container env values (evaluated at pod start)
|
|
||||||
- **Doc suggestion**: Add this pattern to ADDING-APPS.md under "Redis Authentication" section.
|
|
||||||
|
|
||||||
### 3. Consistent health check paths across apps
|
|
||||||
Different apps use different health check paths (`/health`, `/healthz`, `/_health`, `/api/v1/health`). This should be documented per app type or noted that you must verify the actual path from the app's Docker docs.
|
|
||||||
- For apps where no valid HTTP health path exists (e.g., Typebot viewer which only serves chatbot URLs), use `tcpSocket` probe instead of `httpGet`.
|
|
||||||
|
|
||||||
### 9. Linuxserver.io and s6-overlay images require root + capabilities
|
|
||||||
Images using s6-overlay init system (most linuxserver.io images) must run as root and need full capabilities to switch to their internal "abc" user.
|
|
||||||
- Set `runAsUser: 0, runAsNonRoot: false` in pod securityContext
|
|
||||||
- Do NOT set `allowPrivilegeEscalation: false` or `capabilities.drop: ALL` in container securityContext
|
|
||||||
- Only keep `readOnlyRootFilesystem: false`
|
|
||||||
- Same applies to Nextcloud (uses rsync/chown during setup)
|
|
||||||
- **Affects**: BookStack (linuxserver.io), any linuxserver.io image
|
|
||||||
|
|
||||||
### 10. CryptPad needs emptyDir + initContainer for config directory
|
|
||||||
The CryptPad image's `/cryptpad/config/` directory is in the image overlay filesystem and not writable by the container process even when running as root.
|
|
||||||
- **Fix**: Mount an emptyDir at `/cryptpad/config`, and use an initContainer to pre-seed `config.example.js` from the image into the emptyDir.
|
|
||||||
- The initContainer mounts the emptyDir at a different path (e.g., `/config-dest`) and copies the file.
|
|
||||||
- **CPAD_CONF** env var must be set to `/cryptpad/config/config.js` (startup script copies config.example.js to config.js if config.js doesn't exist).
|
|
||||||
|
|
||||||
### 11. Pixelfed image requires authentication (ghcr.io access restriction)
|
|
||||||
~~`ghcr.io/pixelfed/pixelfed` returns 403 Forbidden for anonymous pulls.~~
|
|
||||||
- **Resolved**: Use `ghcr.io/mattlqx/docker-pixelfed:v0.12.7-nginx` (community image, publicly accessible)
|
|
||||||
- Note: the mattlqx image uses port 80 (nginx), not 8080. Update `containerPort` and service `targetPort` accordingly.
|
|
||||||
|
|
||||||
### 12. Bitnami images no longer on Docker Hub
|
|
||||||
Bitnami moved their images from Docker Hub (`bitnami/appname`) to their own OCI registry. Apps that were packaged using Bitnami images (e.g., Ghost) need to be updated to use the official upstream image or Bitnami's new registry.
|
|
||||||
- **Ghost fix**: Switched from `bitnami/ghost:5.x` to official `ghost:5.130.6-alpine`
|
|
||||||
- The official ghost image uses different env vars (`database__connection__host` instead of `GHOST_DATABASE_HOST`) and a different mount path (`/var/lib/ghost/content` instead of `/bitnami/ghost`)
|
|
||||||
- Check image availability before packaging: `docker manifest inspect docker.io/bitnami/appname:tag`
|
|
||||||
|
|
||||||
### 13. Required secrets belong in app-secrets, not dep-secrets
|
|
||||||
Apps that use `requiredSecrets` (e.g., `mysql.rootPassword`) receive those secrets copied into their own `<app>-secrets` K8s Secret, under the key `<dep>.<key>` (e.g., `mysql.rootPassword`). db-init jobs and deployments must reference `<app>-secrets`, NOT `mysql-secrets`.
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
# WRONG - mysql-secrets doesn't exist in the app namespace
|
|
||||||
secretKeyRef:
|
|
||||||
name: mysql-secrets
|
|
||||||
key: rootPassword
|
|
||||||
|
|
||||||
# CORRECT - the required secret is copied into ghost-secrets
|
|
||||||
secretKeyRef:
|
|
||||||
name: ghost-secrets
|
|
||||||
key: mysql.rootPassword
|
|
||||||
```
|
|
||||||
|
|
||||||
### 15. mattlqx/docker-pixelfed requires APP_PORT env var
|
|
||||||
The `ghcr.io/mattlqx/docker-pixelfed` community image generates nginx config from env vars using `sed`. The `listen` directive uses `APP_PORT`, which must be set explicitly or nginx crashes with "invalid number of arguments in 'listen' directive".
|
|
||||||
- Add `APP_PORT: "80"` to the deployment env vars
|
|
||||||
- The image listens on port 80 (nginx), not 8080 as the original pixelfed image did
|
|
||||||
- Both the web and worker deployments must mount the shared storage PVC — use ReadWriteMany access mode, not ReadWriteOnce, since both pods need it simultaneously
|
|
||||||
|
|
||||||
### 14. Your Priorities and Polis have no public Docker images
|
|
||||||
- `ghcr.io/citizensfoundation/your-priorities-app` — 403 Forbidden, images require auth
|
|
||||||
- `compdemocracy/polis-server` — private, no public Docker Hub or ghcr.io images
|
|
||||||
- Both apps require building custom images from source before they can be packaged
|
|
||||||
- Attempting to add these to wild-directory results in ImagePullBackOff
|
|
||||||
|
|
||||||
### 16. PHP/Laravel and other heavy-framework apps need extended probe delays
|
|
||||||
Laravel's startup process runs package discovery, autoload optimization, and encryption key generation before the HTTP server is ready. This commonly takes 2–3 minutes on cluster restart or first boot.
|
|
||||||
- The default `initialDelaySeconds: 60` is too short — the liveness probe fires before the app is ready, kills the container, and a restart loop begins
|
|
||||||
- Set `initialDelaySeconds: 120` and `failureThreshold: 6` for liveness probes on Laravel/Symfony apps
|
|
||||||
- Set `initialDelaySeconds: 60` for readiness probes
|
|
||||||
- The symptom is: pod shows `Running` for ~2 minutes then gets `Killing` due to liveness probe failure, followed by a new pod starting the same cycle
|
|
||||||
- The same applies to other frameworks with heavy startup initialization: Rails (`bundle exec`, asset precompilation), Spring Boot (JVM + Spring context loading), Django with migrations
|
|
||||||
- **Affects**: Ushahidi API (Laravel) — fixed with 120s initial delay + failureThreshold: 6
|
|
||||||
|
|
||||||
### 17. MySQL db-init `CREATE USER IF NOT EXISTS` doesn't update passwords
|
|
||||||
The common db-init pattern `CREATE USER IF NOT EXISTS 'user'@'%' IDENTIFIED BY '${PASSWORD}'` only sets the password at creation time. If the MySQL user already exists from a previous deployment (e.g., after `wild app delete` + `wild app add` without dropping the database), the password is silently left unchanged and the new deployment fails with "Access denied for user".
|
|
||||||
- **Symptom**: App starts, connects to MySQL, gets "Access denied" even though the db-init job completed successfully
|
|
||||||
- **Quick fix**: Exec into the MySQL pod and run: `ALTER USER 'user'@'%' IDENTIFIED BY 'current_password'; FLUSH PRIVILEGES;`
|
|
||||||
- **Better pattern** — make db-init jobs truly idempotent for both user creation and password:
|
|
||||||
```sql
|
|
||||||
CREATE USER IF NOT EXISTS '${DB_USERNAME}'@'%' IDENTIFIED BY '${DB_PASSWORD}';
|
|
||||||
ALTER USER '${DB_USERNAME}'@'%' IDENTIFIED BY '${DB_PASSWORD}';
|
|
||||||
GRANT ALL PRIVILEGES ON ${DB_DATABASE_NAME}.* TO '${DB_USERNAME}'@'%';
|
|
||||||
FLUSH PRIVILEGES;
|
|
||||||
```
|
|
||||||
- This `CREATE ... IF NOT EXISTS` + `ALTER USER` pattern is safe to run repeatedly regardless of whether the user existed before
|
|
||||||
- **Affects**: Ghost (MySQL backend) — hit this when redeploying after a previous partial test run
|
|
||||||
|
|
||||||
### 18. Gancio "Non empty db" crash on re-deploy
|
|
||||||
Gancio stores its setup state in `config.json` on the data PVC. If the PVC is lost or the app is deleted and redeployed with an existing database, gancio finds a non-empty DB but no `config.json` and refuses to start with "Non empty db! Please move your current db elsewhere than retry."
|
|
||||||
- **Fix**: Drop the database schema and let gancio recreate it:
|
|
||||||
```bash
|
|
||||||
kubectl exec -n postgres <postgres-pod> -- psql -U postgres gancio \
|
|
||||||
-c "DROP SCHEMA public CASCADE; CREATE SCHEMA public; GRANT ALL ON SCHEMA public TO gancio; GRANT ALL ON SCHEMA public TO public;"
|
|
||||||
kubectl scale deployment -n gancio gancio --replicas=0
|
|
||||||
# (wait for pod to terminate)
|
|
||||||
kubectl scale deployment -n gancio gancio --replicas=1
|
|
||||||
```
|
|
||||||
- Scale down first to release the DB connection before dropping the schema
|
|
||||||
- This only affects re-deployments; fresh installs work fine
|
|
||||||
|
|
||||||
### 19. Odoo requires explicit database name and `-i base` on first run
|
|
||||||
The official Odoo Docker image does not auto-initialize the database. Without specifying the database name and installing the base module, Odoo starts in multi-database manager mode and health checks fail with "Database not initialized".
|
|
||||||
- Add `args: ["-d", "DATABASE_NAME", "-i", "base"]` to the container spec
|
|
||||||
- The `-i base` flag installs Odoo's base module and creates all database tables on first run
|
|
||||||
- Subsequent runs with `-i base` are safe (idempotent) — it updates rather than reinstalls
|
|
||||||
- Use a `startupProbe` with `failureThreshold: 40, periodSeconds: 30` (20 minutes) since first-run initialization takes 10–15 minutes
|
|
||||||
- Once startup probe passes, the regular liveness/readiness probes with `initialDelaySeconds: 0` take over
|
|
||||||
|
|
||||||
### 20. Headscale v0.29+ CLI: inconsistent user flag types
|
|
||||||
In headscale v0.29, different commands accept different user identifiers:
|
|
||||||
- `preauthkeys create --user <id>` — requires numeric ID
|
|
||||||
- `auth register --user <username>` — accepts username string
|
|
||||||
- `users destroy --identifier <id>` — requires numeric ID, not a positional arg
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Always list users first to get the ID
|
|
||||||
headscale users list
|
|
||||||
|
|
||||||
headscale preauthkeys create --user 1 # numeric ID
|
|
||||||
headscale auth register --auth-id <id> --user payne # username string OK
|
|
||||||
headscale users destroy --identifier 1 # numeric ID
|
|
||||||
```
|
|
||||||
|
|
||||||
Node registration flow: when a Tailscale client connects via browser (not pre-auth key), headscale
|
|
||||||
shows a command like `headscale auth register --auth-id hskey-authreq-XXXX --user USERNAME`.
|
|
||||||
Run it via `kubectl exec -n headscale deploy/headscale -- headscale auth register --auth-id <id> --user <username>`.
|
|
||||||
|
|
||||||
## App Status Tracking
|
|
||||||
|
|
||||||
| App | Status | Notes |
|
|
||||||
|-----|--------|-------|
|
|
||||||
| Firefly III | ✅ working | |
|
|
||||||
| Wiki.js | ✅ working | |
|
|
||||||
| Outline | ✅ working | Redis URL needs password via K8s env var expansion |
|
|
||||||
| WriteFreely | ✅ working | Needs runAsUser: 0 (community image quirk) |
|
|
||||||
| NocoDB | ✅ working | Node.js needs 2Gi memory |
|
|
||||||
| Mattermost | ✅ working | |
|
|
||||||
| Etherpad | ✅ working | |
|
|
||||||
| CryptPad | ✅ working | Needs emptyDir + initContainer for config seeding |
|
|
||||||
| MediaWiki | ✅ working | Post-deploy web installer required for LocalSettings.php |
|
|
||||||
| Nextcloud | ✅ working | |
|
|
||||||
| PeerTube | ✅ working | |
|
|
||||||
| BookStack | ✅ working | linuxserver.io needs root + no capabilities.drop |
|
|
||||||
| Typebot | ✅ working | Builder: /signin health path; Viewer: tcpSocket probe |
|
|
||||||
| Pixelfed | ✅ working | Use ghcr.io/mattlqx/docker-pixelfed:v0.12.7-nginx (community), port 80 not 8080 |
|
|
||||||
| Wekan | pending | needs MongoDB |
|
|
||||||
| Mautic | ✅ working | |
|
|
||||||
| Taiga | ✅ working | |
|
|
||||||
| Pol.is | ❌ blocked | Private AWS ECR images, no public alternative |
|
|
||||||
| Zulip | ✅ working | Bundles own postgres/rabbitmq/memcached; first-run migrations take ~10min |
|
|
||||||
| Rocket.Chat | pending | needs MongoDB |
|
|
||||||
| LimeSurvey | ✅ working | |
|
|
||||||
| Gancio | ✅ working | Re-deploy: must drop DB schema first if prior data exists (see note 18) |
|
|
||||||
| Mobilizon | ✅ working | |
|
|
||||||
| Formbricks | ✅ working | |
|
|
||||||
| Jitsi | ✅ working | |
|
|
||||||
| Mastodon | already done | |
|
|
||||||
| Chamilo | ✅ working | |
|
|
||||||
| Moodle | ✅ working | |
|
|
||||||
| Open edX | pending | complex |
|
|
||||||
| Pretix | ✅ working | |
|
|
||||||
| Indico | ✅ working | |
|
|
||||||
| Eventyay | ✅ working | No versioned Docker tags upstream — main tag is intentional |
|
|
||||||
| Leihs | pending | |
|
|
||||||
| LibreBooking | ✅ working | |
|
|
||||||
| uMap | ✅ working | Host header needed in health probes (Django ALLOWED_HOSTS) |
|
|
||||||
| MapComplete | pending | |
|
|
||||||
| Terrastories | pending | |
|
|
||||||
| Karrot | already done | |
|
|
||||||
| LiquidFeedback | pending | |
|
|
||||||
| Your Priorities | ❌ blocked | No public Docker images (ghcr.io requires auth) |
|
|
||||||
| Helios Voting | pending | |
|
|
||||||
| Belenios | pending | |
|
|
||||||
| Decidim | already done | |
|
|
||||||
| Alaveteli | pending | |
|
|
||||||
| FixMyStreet | pending | |
|
|
||||||
| Ghost | ✅ working | Bitnami image gone; use official ghost:alpine; different env vars + mount path (see note 12) |
|
|
||||||
| Ushahidi | ✅ working | Client: runAsUser:0 + CHOWN/SETUID/SETGID; API: liveness initialDelay 120s (Laravel slow start) |
|
|
||||||
| CiviCRM | pending | |
|
|
||||||
| Open Collective | pending | |
|
|
||||||
| Open Food Network | pending | |
|
|
||||||
| Resonate | pending | |
|
|
||||||
| BookWyrm | ✅ working | |
|
|
||||||
| Lemmy | already done | |
|
|
||||||
| Akaunting | ✅ working | |
|
|
||||||
| GnuCash | skip | desktop app, not containerizable |
|
|
||||||
| OhMyForm | ✅ working | |
|
|
||||||
| Odoo | ✅ working | Needs `-d DATABASE -i base` args; uses startupProbe (20min window) for first-run DB init |
|
|
||||||
| Headscale | ✅ working | SQLite, ConfigMap-based config, no secrets; v0.29 CLI uses numeric user IDs (see note 20) |
|
|
||||||
| Baserow | ✅ working | |
|
|
||||||
| Keila | already done | |
|
|
||||||
| Listmonk | already done | |
|
|
||||||
| Discourse | already done | |
|
|
||||||
| Gitea | already done | |
|
|
||||||
| Immich | already done | |
|
|
||||||
| Loomio | already done | |
|
|
||||||
| Synapse | already done | App renamed from matrix to synapse |
|
|
||||||
| Open WebUI | already done | |
|
|
||||||
| OpenProject | already done | |
|
|
||||||
| vLLM | already done | |
|
|
||||||
1162
ADDING-APPS.md
1162
ADDING-APPS.md
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,7 @@
|
|||||||
- @README.md
|
- @README.md
|
||||||
- @ADDING-APPS.md
|
|
||||||
|
- ADDING-APPS.md is the expert advice you need when adding new apps to the Wild Cloud Directory.
|
||||||
|
- docs/ contains deep-dive guides — runtime-specific (nodejs, python, php, ruby, jvm, nginx, linuxserver) and topic-specific (database, redis, traefik, scripts).
|
||||||
|
|
||||||
## Finding good sources of documentation for adding a new app to the Wild Cloud Directory
|
## Finding good sources of documentation for adding a new app to the Wild Cloud Directory
|
||||||
|
|
||||||
|
|||||||
32
admin/scripts/validate-app.sh
Executable file
32
admin/scripts/validate-app.sh
Executable file
@@ -0,0 +1,32 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Validate a Wild Cloud app package before submitting.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# From your app directory: ../../admin/scripts/validate-app.sh
|
||||||
|
# From wild-directory root: admin/scripts/validate-app.sh <app-name>
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
WILD_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
||||||
|
|
||||||
|
if [ $# -ge 1 ]; then
|
||||||
|
APP_NAME="$1"
|
||||||
|
else
|
||||||
|
CURRENT="$(pwd)"
|
||||||
|
if [[ "$CURRENT" == "$WILD_DIR"/* ]]; then
|
||||||
|
RELATIVE="${CURRENT#"$WILD_DIR"/}"
|
||||||
|
APP_NAME="${RELATIVE%%/*}"
|
||||||
|
else
|
||||||
|
echo "Usage: validate-app.sh <app-name>" >&2
|
||||||
|
echo " or run from inside your app directory." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ ! -d "$WILD_DIR/$APP_NAME" ]; then
|
||||||
|
echo "App not found: $APP_NAME" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Validating: $APP_NAME"
|
||||||
|
python3 "$SCRIPT_DIR/validate-apps.py" "$APP_NAME"
|
||||||
573
admin/scripts/validate-apps.py
Executable file
573
admin/scripts/validate-apps.py
Executable file
@@ -0,0 +1,573 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Validate Wild Directory app packages for common authoring errors.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python3 admin/scripts/validate-apps.py [app-name ...]
|
||||||
|
python3 admin/scripts/validate-apps.py --list-rules
|
||||||
|
|
||||||
|
With no arguments, validates all apps. Pass app names to check specific ones.
|
||||||
|
Exit code 1 if any errors are found.
|
||||||
|
|
||||||
|
To suppress specific rules for an app, add ignoreRules to its app.yaml:
|
||||||
|
ignoreRules:
|
||||||
|
- WC-HELM
|
||||||
|
- WC-SEL
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import re
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
REQUIRED_APP_YAML_FIELDS = ["name", "is", "description", "category", "latest"]
|
||||||
|
REQUIRED_MANIFEST_FIELDS = ["version"]
|
||||||
|
STANDARD_LABEL_PAIRS = {"managedBy": "kustomize", "partOf": "wild-cloud"}
|
||||||
|
HELM_LABELS = {"app.kubernetes.io/name", "app.kubernetes.io/instance", "app.kubernetes.io/component"}
|
||||||
|
MUTABLE_IMAGE_TAGS = {"latest", "main", "master", "edge", "develop", "dev", "nightly", "stable"}
|
||||||
|
GLOBAL_TEMPLATE_NAMESPACES = {"cloud", "cluster", "operator", "apps", "secrets", "app"}
|
||||||
|
|
||||||
|
|
||||||
|
def load_rules():
|
||||||
|
"""Load rule definitions from validation-rules.yaml in the same directory."""
|
||||||
|
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
rules_path = os.path.join(script_dir, "validation-rules.yaml")
|
||||||
|
with open(rules_path) as f:
|
||||||
|
data = yaml.safe_load(f)
|
||||||
|
rules = {}
|
||||||
|
remediation = {}
|
||||||
|
severity = {}
|
||||||
|
for rule_id, info in (data or {}).items():
|
||||||
|
if isinstance(info, dict):
|
||||||
|
rules[rule_id] = info.get("description", "")
|
||||||
|
severity[rule_id] = info.get("severity", "warning")
|
||||||
|
rem = info.get("remediation", "")
|
||||||
|
if rem:
|
||||||
|
remediation[rule_id] = rem.strip()
|
||||||
|
return rules, remediation, severity
|
||||||
|
|
||||||
|
|
||||||
|
RULES, REMEDIATION, SEVERITY = load_rules()
|
||||||
|
|
||||||
|
|
||||||
|
def add_error(errors, ignored_rules, rule_id, message):
|
||||||
|
if rule_id not in ignored_rules:
|
||||||
|
errors.append((rule_id, message))
|
||||||
|
|
||||||
|
|
||||||
|
def add_warning(warnings, ignored_rules, rule_id, message):
|
||||||
|
if rule_id not in ignored_rules:
|
||||||
|
warnings.append((rule_id, message))
|
||||||
|
|
||||||
|
|
||||||
|
def strip_gomplate(text):
|
||||||
|
"""Replace gomplate expressions with a placeholder so the file parses as valid YAML."""
|
||||||
|
return re.sub(r"\{\{.*?\}\}", "PLACEHOLDER", text, flags=re.DOTALL)
|
||||||
|
|
||||||
|
|
||||||
|
def load_yaml(path):
|
||||||
|
with open(path) as f:
|
||||||
|
content = f.read()
|
||||||
|
return yaml.safe_load(strip_gomplate(content))
|
||||||
|
|
||||||
|
|
||||||
|
def load_yaml_all(path):
|
||||||
|
"""Yield all YAML documents in a file (handles multi-doc files)."""
|
||||||
|
with open(path) as f:
|
||||||
|
content = f.read()
|
||||||
|
yield from yaml.safe_load_all(strip_gomplate(content))
|
||||||
|
|
||||||
|
|
||||||
|
def resource_docs(kustomize_dir, resources):
|
||||||
|
"""Yield (filepath, doc) for each YAML document in listed resource files."""
|
||||||
|
for resource in resources:
|
||||||
|
path = os.path.join(kustomize_dir, resource)
|
||||||
|
if not os.path.isfile(path):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
for doc in load_yaml_all(path):
|
||||||
|
if doc:
|
||||||
|
yield path, doc
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def resources_have_workloads(kustomize_dir, resources):
|
||||||
|
"""Return True if any listed resource file contains a Deployment or StatefulSet."""
|
||||||
|
for _, doc in resource_docs(kustomize_dir, resources):
|
||||||
|
if doc.get("kind") in ("Deployment", "StatefulSet"):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def check_security_context(spec, resource_name, errors, warnings, ignored_rules):
|
||||||
|
"""Check pod-level and container-level security contexts."""
|
||||||
|
pod_spec = spec.get("template", {}).get("spec", {}) if "template" in spec else spec
|
||||||
|
if not pod_spec:
|
||||||
|
return
|
||||||
|
|
||||||
|
pod_sc = pod_spec.get("securityContext")
|
||||||
|
if not pod_sc:
|
||||||
|
add_warning(warnings, ignored_rules, "WC-SC-POD",
|
||||||
|
f"{resource_name}: no pod-level securityContext")
|
||||||
|
|
||||||
|
for container in pod_spec.get("containers", []) + pod_spec.get("initContainers", []):
|
||||||
|
cname = container.get("name", "?")
|
||||||
|
csc = container.get("securityContext")
|
||||||
|
if not csc:
|
||||||
|
add_warning(warnings, ignored_rules, "WC-SC-CTR",
|
||||||
|
f"{resource_name}/{cname}: no container-level securityContext")
|
||||||
|
|
||||||
|
|
||||||
|
def check_mysql_db_init(kustomize_dir, resources, errors, warnings, ignored_rules):
|
||||||
|
"""Check MySQL db-init SQL for the idempotent password pattern (WC-DBIN)."""
|
||||||
|
for resource in resources:
|
||||||
|
path = os.path.join(kustomize_dir, resource)
|
||||||
|
if not os.path.isfile(path):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
with open(path) as f:
|
||||||
|
content = f.read()
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
# Only files that contain MySQL-style SQL (IDENTIFIED BY is MySQL-specific)
|
||||||
|
if "CREATE USER" not in content or "IDENTIFIED BY" not in content:
|
||||||
|
continue
|
||||||
|
if "ALTER USER" not in content:
|
||||||
|
add_error(errors, ignored_rules, "WC-DBIN",
|
||||||
|
f"{os.path.basename(path)}: MySQL CREATE USER IF NOT EXISTS without ALTER USER"
|
||||||
|
" — passwords won't update on redeploy (see note #17)")
|
||||||
|
|
||||||
|
|
||||||
|
def check_docker_dns(kustomize_dir, resources, errors, warnings, ignored_rules):
|
||||||
|
"""Check for Docker DNS resolver (127.0.0.11) which doesn't work in Kubernetes (WC-DKRDNS)."""
|
||||||
|
for resource in resources:
|
||||||
|
path = os.path.join(kustomize_dir, resource)
|
||||||
|
if not os.path.isfile(path):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
with open(path) as f:
|
||||||
|
content = f.read()
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
if "127.0.0.11" in content:
|
||||||
|
add_error(errors, ignored_rules, "WC-DKRDNS",
|
||||||
|
f"{os.path.basename(path)}: contains Docker DNS resolver (127.0.0.11)"
|
||||||
|
" — use CoreDNS instead (see note #24)")
|
||||||
|
|
||||||
|
|
||||||
|
def check_resource_parse_errors(kustomize_dir, resources, errors, warnings, ignored_rules):
|
||||||
|
"""Check that all listed resource files parse as valid YAML (WC-RES-PARSE)."""
|
||||||
|
for resource in resources:
|
||||||
|
path = os.path.join(kustomize_dir, resource)
|
||||||
|
if not os.path.isfile(path):
|
||||||
|
continue # WC-RES already handles missing files
|
||||||
|
try:
|
||||||
|
list(load_yaml_all(path))
|
||||||
|
except Exception as e:
|
||||||
|
add_error(errors, ignored_rules, "WC-RES-PARSE",
|
||||||
|
f"{resource}: YAML parse error: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def check_namespace_resource(kustomize_dir, resources, errors, warnings, ignored_rules):
|
||||||
|
"""Check that a Namespace resource exists when workloads are present (WC-NSFILE)."""
|
||||||
|
if not resources_have_workloads(kustomize_dir, resources):
|
||||||
|
return
|
||||||
|
for _, doc in resource_docs(kustomize_dir, resources):
|
||||||
|
if doc.get("kind") == "Namespace":
|
||||||
|
return
|
||||||
|
add_warning(warnings, ignored_rules, "WC-NSFILE",
|
||||||
|
"no Namespace resource in kustomization.yaml — add namespace.yaml")
|
||||||
|
|
||||||
|
|
||||||
|
def check_template_vars(kustomize_dir, default_config, errors, warnings, ignored_rules):
|
||||||
|
"""Check top-level template variables in resource files against defaultConfig (WC-TVAR)."""
|
||||||
|
if not default_config:
|
||||||
|
return
|
||||||
|
defined_keys = set(default_config.keys())
|
||||||
|
for fname in sorted(os.listdir(kustomize_dir)):
|
||||||
|
if not fname.endswith(".yaml") or fname == "manifest.yaml":
|
||||||
|
continue
|
||||||
|
path = os.path.join(kustomize_dir, fname)
|
||||||
|
try:
|
||||||
|
with open(path) as f:
|
||||||
|
content = f.read()
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
seen_undefined = set()
|
||||||
|
for block in re.finditer(r"\{\{-?\s*(.*?)\s*-?\}\}", content, re.DOTALL):
|
||||||
|
for m in re.finditer(r"(?<![.\w])\.([a-z]\w*)", block.group(1)):
|
||||||
|
var_name = m.group(1)
|
||||||
|
if var_name in GLOBAL_TEMPLATE_NAMESPACES:
|
||||||
|
continue
|
||||||
|
if var_name not in defined_keys and var_name not in seen_undefined:
|
||||||
|
seen_undefined.add(var_name)
|
||||||
|
add_warning(warnings, ignored_rules, "WC-TVAR",
|
||||||
|
f"{fname}: template variable .{var_name!r} not in defaultConfig")
|
||||||
|
|
||||||
|
|
||||||
|
def validate_resources(kustomize_dir, resources, errors, warnings, ignored_rules):
|
||||||
|
"""Run per-document checks on all resource files."""
|
||||||
|
rwo_pvc_names = set()
|
||||||
|
|
||||||
|
# First pass: collect RWO PVC claim names
|
||||||
|
for _, doc in resource_docs(kustomize_dir, resources):
|
||||||
|
if doc.get("kind") == "PersistentVolumeClaim":
|
||||||
|
access_modes = doc.get("spec", {}).get("accessModes", [])
|
||||||
|
if "ReadWriteOnce" in access_modes:
|
||||||
|
rwo_pvc_names.add(doc.get("metadata", {}).get("name", ""))
|
||||||
|
|
||||||
|
# Second pass: validate each document
|
||||||
|
for fpath, doc in resource_docs(kustomize_dir, resources):
|
||||||
|
fname = os.path.basename(fpath)
|
||||||
|
kind = doc.get("kind", "")
|
||||||
|
name = doc.get("metadata", {}).get("name", fname)
|
||||||
|
label_ref = f"{fname}:{name}"
|
||||||
|
|
||||||
|
if kind in ("Deployment", "StatefulSet", "DaemonSet"):
|
||||||
|
spec = doc.get("spec", {})
|
||||||
|
|
||||||
|
# Security context
|
||||||
|
check_security_context(spec, label_ref, errors, warnings, ignored_rules)
|
||||||
|
|
||||||
|
# RWO PVC → must use Recreate strategy
|
||||||
|
if kind == "Deployment":
|
||||||
|
volumes = spec.get("template", {}).get("spec", {}).get("volumes", [])
|
||||||
|
uses_rwo = any(
|
||||||
|
v.get("persistentVolumeClaim", {}).get("claimName") in rwo_pvc_names
|
||||||
|
for v in volumes
|
||||||
|
)
|
||||||
|
if uses_rwo:
|
||||||
|
strategy = spec.get("strategy", {}).get("type", "RollingUpdate")
|
||||||
|
if strategy != "Recreate":
|
||||||
|
add_error(errors, ignored_rules, "WC-RWO",
|
||||||
|
f"{label_ref}: ReadWriteOnce PVC requires strategy: Recreate (got {strategy!r})")
|
||||||
|
|
||||||
|
# No Helm-style labels in pod template
|
||||||
|
pod_labels = spec.get("template", {}).get("metadata", {}).get("labels", {}) or {}
|
||||||
|
for helm_label in HELM_LABELS:
|
||||||
|
if helm_label in pod_labels:
|
||||||
|
add_error(errors, ignored_rules, "WC-HELM",
|
||||||
|
f"{label_ref}: Helm-style label {helm_label!r} found — use simple component labels instead")
|
||||||
|
|
||||||
|
# Image tags and sources
|
||||||
|
pod_spec = spec.get("template", {}).get("spec", {}) or {}
|
||||||
|
for container in pod_spec.get("containers", []) + pod_spec.get("initContainers", []):
|
||||||
|
image = container.get("image", "")
|
||||||
|
cname = container.get("name", "?")
|
||||||
|
tag = image.split(":")[-1] if ":" in image else ""
|
||||||
|
if tag.lower() in MUTABLE_IMAGE_TAGS:
|
||||||
|
add_error(errors, ignored_rules, "WC-IMG",
|
||||||
|
f"{label_ref}/{cname}: image tag {tag!r} is mutable — use a specific version tag")
|
||||||
|
elif not tag:
|
||||||
|
add_warning(warnings, ignored_rules, "WC-IMG",
|
||||||
|
f"{label_ref}/{cname}: image has no tag — specify a version")
|
||||||
|
if re.search(r"(?:^|/)bitnami", image.split(":")[0]):
|
||||||
|
add_warning(warnings, ignored_rules, "WC-BITNAMI",
|
||||||
|
f"{label_ref}/{cname}: uses a Bitnami image ({image.split(':')[0]!r})"
|
||||||
|
" — prefer an official image")
|
||||||
|
|
||||||
|
elif kind == "Ingress":
|
||||||
|
annotations = doc.get("metadata", {}).get("annotations", {}) or {}
|
||||||
|
spec = doc.get("spec", {}) or {}
|
||||||
|
|
||||||
|
# Must use spec.ingressClassName, not annotation
|
||||||
|
if "kubernetes.io/ingress.class" in annotations:
|
||||||
|
add_error(errors, ignored_rules, "WC-ING-ANN",
|
||||||
|
f"{label_ref}: use spec.ingressClassName: traefik, not kubernetes.io/ingress.class annotation")
|
||||||
|
if spec.get("ingressClassName") != "traefik":
|
||||||
|
add_warning(warnings, ignored_rules, "WC-ING-CLS",
|
||||||
|
f"{label_ref}: spec.ingressClassName should be 'traefik' (got {spec.get('ingressClassName')!r})")
|
||||||
|
|
||||||
|
# No cert-manager annotation (wildcard cert is pre-distributed)
|
||||||
|
if "cert-manager.io/cluster-issuer" in annotations:
|
||||||
|
add_error(errors, ignored_rules, "WC-CERT",
|
||||||
|
f"{label_ref}: do not use cert-manager.io/cluster-issuer — wildcard TLS cert is pre-distributed to namespaces")
|
||||||
|
|
||||||
|
# External-dns target annotation
|
||||||
|
if "external-dns.alpha.kubernetes.io/target" not in annotations:
|
||||||
|
add_warning(warnings, ignored_rules, "WC-DNS",
|
||||||
|
f"{label_ref}: missing external-dns.alpha.kubernetes.io/target annotation")
|
||||||
|
|
||||||
|
|
||||||
|
def validate_kustomization(kustomize_dir, app_name, default_config, errors, warnings, ignored_rules):
|
||||||
|
path = os.path.join(kustomize_dir, "kustomization.yaml")
|
||||||
|
if not os.path.exists(path):
|
||||||
|
add_error(errors, ignored_rules, "WC-KUS-MISS", "MISSING kustomization.yaml")
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = load_yaml(path)
|
||||||
|
except Exception as e:
|
||||||
|
add_error(errors, ignored_rules, "WC-KUS-PARSE", f"PARSE ERROR in kustomization.yaml: {e}")
|
||||||
|
return
|
||||||
|
|
||||||
|
if not data:
|
||||||
|
add_error(errors, ignored_rules, "WC-KUS-EMPTY", "EMPTY kustomization.yaml")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Check all listed resources exist on disk
|
||||||
|
resources = data.get("resources") or []
|
||||||
|
for resource in resources:
|
||||||
|
resource_path = os.path.join(kustomize_dir, resource)
|
||||||
|
if not os.path.exists(resource_path):
|
||||||
|
add_error(errors, ignored_rules, "WC-RES", f"resource not found: {resource}")
|
||||||
|
|
||||||
|
has_workloads = resources_have_workloads(kustomize_dir, resources)
|
||||||
|
|
||||||
|
if has_workloads:
|
||||||
|
if "namespace" not in data:
|
||||||
|
add_warning(warnings, ignored_rules, "WC-NS",
|
||||||
|
"no namespace set in kustomization.yaml (has Deployment/StatefulSet)")
|
||||||
|
|
||||||
|
# Require includeSelectors: true
|
||||||
|
labels = data.get("labels") or []
|
||||||
|
include_selectors_entries = [
|
||||||
|
lbl for lbl in labels
|
||||||
|
if isinstance(lbl, dict) and lbl.get("includeSelectors") is True
|
||||||
|
]
|
||||||
|
if not include_selectors_entries:
|
||||||
|
add_error(errors, ignored_rules, "WC-SEL",
|
||||||
|
"labels.includeSelectors: true missing (required when Deployment/StatefulSet present)")
|
||||||
|
else:
|
||||||
|
pairs = include_selectors_entries[0].get("pairs") or {}
|
||||||
|
for key, expected in STANDARD_LABEL_PAIRS.items():
|
||||||
|
if pairs.get(key) != expected:
|
||||||
|
add_warning(warnings, ignored_rules, "WC-PAIRS",
|
||||||
|
f"label pair {key}: {expected!r} missing or wrong")
|
||||||
|
# app label should match the app directory name
|
||||||
|
if pairs.get("app") and pairs.get("app") != app_name:
|
||||||
|
add_warning(warnings, ignored_rules, "WC-LBL",
|
||||||
|
f"label pair app: {pairs['app']!r} doesn't match app directory name {app_name!r}")
|
||||||
|
|
||||||
|
# Per-resource checks
|
||||||
|
check_resource_parse_errors(kustomize_dir, resources, errors, warnings, ignored_rules)
|
||||||
|
validate_resources(kustomize_dir, resources, errors, warnings, ignored_rules)
|
||||||
|
check_mysql_db_init(kustomize_dir, resources, errors, warnings, ignored_rules)
|
||||||
|
check_docker_dns(kustomize_dir, resources, errors, warnings, ignored_rules)
|
||||||
|
check_namespace_resource(kustomize_dir, resources, errors, warnings, ignored_rules)
|
||||||
|
check_template_vars(kustomize_dir, default_config, errors, warnings, ignored_rules)
|
||||||
|
|
||||||
|
|
||||||
|
def validate_manifest(version_dir, errors, warnings, ignored_rules):
|
||||||
|
manifest_path = os.path.join(version_dir, "manifest.yaml")
|
||||||
|
if not os.path.exists(manifest_path):
|
||||||
|
add_error(errors, ignored_rules, "WC-MAN-MISS", "MISSING manifest.yaml")
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
manifest = load_yaml(manifest_path)
|
||||||
|
except Exception as e:
|
||||||
|
add_error(errors, ignored_rules, "WC-MAN-PARSE", f"PARSE ERROR in manifest.yaml: {e}")
|
||||||
|
return
|
||||||
|
|
||||||
|
if not manifest:
|
||||||
|
add_error(errors, ignored_rules, "WC-MAN-EMPTY", "EMPTY manifest.yaml")
|
||||||
|
return
|
||||||
|
|
||||||
|
for field in REQUIRED_MANIFEST_FIELDS:
|
||||||
|
if field not in manifest:
|
||||||
|
add_error(errors, ignored_rules, "WC-MAN-REQ",
|
||||||
|
f"manifest.yaml missing field: {field}")
|
||||||
|
|
||||||
|
# defaultConfig should have a namespace field
|
||||||
|
default_config = manifest.get("defaultConfig") or {}
|
||||||
|
if not default_config:
|
||||||
|
add_warning(warnings, ignored_rules, "WC-CFG",
|
||||||
|
"manifest.yaml: defaultConfig is empty or missing")
|
||||||
|
elif "namespace" not in default_config:
|
||||||
|
add_warning(warnings, ignored_rules, "WC-CFG-NS",
|
||||||
|
"manifest.yaml: defaultConfig missing 'namespace' field")
|
||||||
|
|
||||||
|
# Check postgres dbUrl secrets contain ?sslmode=disable
|
||||||
|
for secret in manifest.get("defaultSecrets") or []:
|
||||||
|
if not isinstance(secret, dict):
|
||||||
|
continue
|
||||||
|
default = str(secret.get("default", ""))
|
||||||
|
if ("postgres" in default or "postgresql" in default) and "sslmode" not in default:
|
||||||
|
add_error(errors, ignored_rules, "WC-SSL",
|
||||||
|
f"manifest.yaml: defaultSecret {secret.get('key')!r} has postgres URL without ?sslmode=disable")
|
||||||
|
|
||||||
|
# requiredSecrets should use <app-ref>.<key> format
|
||||||
|
for rs in manifest.get("requiredSecrets") or []:
|
||||||
|
if isinstance(rs, str) and "." not in rs:
|
||||||
|
add_error(errors, ignored_rules, "WC-DOT",
|
||||||
|
f"manifest.yaml: requiredSecret {rs!r} missing dot notation (<app-ref>.<key>)")
|
||||||
|
|
||||||
|
|
||||||
|
def validate_version(version_dir, app_name, errors, warnings, ignored_rules):
|
||||||
|
validate_manifest(version_dir, errors, warnings, ignored_rules)
|
||||||
|
|
||||||
|
# Load defaultConfig for template variable checking
|
||||||
|
default_config = {}
|
||||||
|
manifest_path = os.path.join(version_dir, "manifest.yaml")
|
||||||
|
if os.path.exists(manifest_path):
|
||||||
|
try:
|
||||||
|
manifest_data = load_yaml(manifest_path) or {}
|
||||||
|
default_config = manifest_data.get("defaultConfig") or {}
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Config-only apps (e.g. smtp) have no kubernetes resources — skip kustomization check
|
||||||
|
kustomize_path = os.path.join(version_dir, "kustomization.yaml")
|
||||||
|
resource_yamls = [
|
||||||
|
f for f in os.listdir(version_dir)
|
||||||
|
if f.endswith(".yaml") and f not in ("manifest.yaml", "kustomization.yaml")
|
||||||
|
]
|
||||||
|
if os.path.exists(kustomize_path) or resource_yamls:
|
||||||
|
validate_kustomization(version_dir, app_name, default_config, errors, warnings, ignored_rules)
|
||||||
|
|
||||||
|
|
||||||
|
def validate_app(app_dir):
|
||||||
|
errors = []
|
||||||
|
warnings = []
|
||||||
|
app_name = os.path.basename(app_dir)
|
||||||
|
|
||||||
|
app_yaml_path = os.path.join(app_dir, "app.yaml")
|
||||||
|
if not os.path.exists(app_yaml_path):
|
||||||
|
return [("WC-APP-MISS", "MISSING app.yaml")], []
|
||||||
|
|
||||||
|
try:
|
||||||
|
app_data = load_yaml(app_yaml_path)
|
||||||
|
except Exception as e:
|
||||||
|
return [("WC-APP-PARSE", f"PARSE ERROR in app.yaml: {e}")], []
|
||||||
|
|
||||||
|
if not app_data:
|
||||||
|
return [("WC-APP-EMPTY", "EMPTY app.yaml")], []
|
||||||
|
|
||||||
|
ignored_rules = set(app_data.get("ignoreRules") or [])
|
||||||
|
|
||||||
|
# Flag any ignoreRules entries that don't correspond to a known rule
|
||||||
|
for rule_id in ignored_rules:
|
||||||
|
if rule_id not in RULES:
|
||||||
|
errors.append(("WC-IGN", f"app.yaml ignoreRules contains unknown rule: {rule_id!r}"))
|
||||||
|
|
||||||
|
for field in REQUIRED_APP_YAML_FIELDS:
|
||||||
|
if not app_data.get(field):
|
||||||
|
add_error(errors, ignored_rules, "WC-REQ",
|
||||||
|
f"app.yaml missing required field: {field}")
|
||||||
|
|
||||||
|
# icon is cosmetic — warn rather than error
|
||||||
|
if not app_data.get("icon"):
|
||||||
|
add_warning(warnings, ignored_rules, "WC-ICON", "app.yaml missing field: icon")
|
||||||
|
|
||||||
|
# name must match directory name
|
||||||
|
if app_data.get("name") and app_data["name"] != app_name:
|
||||||
|
add_error(errors, ignored_rules, "WC-NAME",
|
||||||
|
f"app.yaml name {app_data['name']!r} doesn't match directory name {app_name!r}")
|
||||||
|
|
||||||
|
latest = str(app_data.get("latest", ""))
|
||||||
|
versions_dir = os.path.join(app_dir, "versions")
|
||||||
|
|
||||||
|
if not os.path.isdir(versions_dir):
|
||||||
|
add_error(errors, ignored_rules, "WC-VER", "MISSING versions/ directory")
|
||||||
|
return errors, warnings
|
||||||
|
|
||||||
|
# Check slot naming convention and validate each slot
|
||||||
|
for slot_name in sorted(os.listdir(versions_dir)):
|
||||||
|
slot_path = os.path.join(versions_dir, slot_name)
|
||||||
|
if not os.path.isdir(slot_path):
|
||||||
|
continue
|
||||||
|
# Flag full version strings (two or more dots) or packaging revision suffix (-N)
|
||||||
|
if re.search(r"\.\d+\.\d+", slot_name) or re.search(r"-\d+$", slot_name):
|
||||||
|
add_warning(warnings, ignored_rules, "WC-SLOT",
|
||||||
|
f"versions/{slot_name}: slot name looks like a full version or revision"
|
||||||
|
" — use the major version only (e.g. '1', 'v1')")
|
||||||
|
|
||||||
|
if latest:
|
||||||
|
latest_dir = os.path.join(versions_dir, latest)
|
||||||
|
if not os.path.isdir(latest_dir):
|
||||||
|
add_error(errors, ignored_rules, "WC-LATEST",
|
||||||
|
f"latest version directory not found: versions/{latest}")
|
||||||
|
else:
|
||||||
|
ver_errors, ver_warnings = [], []
|
||||||
|
validate_version(latest_dir, app_name, ver_errors, ver_warnings, ignored_rules)
|
||||||
|
errors.extend((rule_id, f"versions/{latest}: {msg}") for rule_id, msg in ver_errors)
|
||||||
|
warnings.extend((rule_id, f"versions/{latest}: {msg}") for rule_id, msg in ver_warnings)
|
||||||
|
|
||||||
|
# Check that upgrade waypoint slots exist
|
||||||
|
upgrade = app_data.get("upgrade") or {}
|
||||||
|
for rule in upgrade.get("from") or []:
|
||||||
|
via = rule.get("via")
|
||||||
|
if via:
|
||||||
|
via_dir = os.path.join(versions_dir, str(via))
|
||||||
|
if not os.path.isdir(via_dir):
|
||||||
|
add_error(errors, ignored_rules, "WC-VIA",
|
||||||
|
f"app.yaml upgrade.from[].via={via!r} references a non-existent slot")
|
||||||
|
|
||||||
|
return errors, warnings
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
args = sys.argv[1:]
|
||||||
|
|
||||||
|
if "--list-rules" in args:
|
||||||
|
print("Rule IDs for use in app.yaml ignoreRules:\n")
|
||||||
|
for rule_id in RULES:
|
||||||
|
sev = SEVERITY.get(rule_id, "warning")
|
||||||
|
print(f" {rule_id:<14} {sev:<8} {RULES[rule_id]}")
|
||||||
|
if rule_id in REMEDIATION:
|
||||||
|
lines = REMEDIATION[rule_id].split("\n")
|
||||||
|
print(f" {'':<14} Fix: {lines[0]}")
|
||||||
|
for line in lines[1:]:
|
||||||
|
print(f" {'':<14} {line}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
requested = [a for a in args if not a.startswith("--")]
|
||||||
|
|
||||||
|
wild_dir = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
|
||||||
|
if requested:
|
||||||
|
app_dirs = []
|
||||||
|
for name in requested:
|
||||||
|
path = os.path.join(wild_dir, name)
|
||||||
|
if not os.path.isdir(path):
|
||||||
|
print(f"ERROR: {name} not found in wild-directory", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
app_dirs.append(path)
|
||||||
|
else:
|
||||||
|
app_dirs = sorted(
|
||||||
|
os.path.join(wild_dir, d)
|
||||||
|
for d in os.listdir(wild_dir)
|
||||||
|
if os.path.isdir(os.path.join(wild_dir, d))
|
||||||
|
and not d.startswith(".")
|
||||||
|
and os.path.exists(os.path.join(wild_dir, d, "app.yaml"))
|
||||||
|
)
|
||||||
|
|
||||||
|
total_errors = 0
|
||||||
|
total_warnings = 0
|
||||||
|
|
||||||
|
for app_dir in app_dirs:
|
||||||
|
app_name = os.path.basename(app_dir)
|
||||||
|
errors, warnings = validate_app(app_dir)
|
||||||
|
|
||||||
|
if errors or warnings:
|
||||||
|
print(f"\n{app_name}:")
|
||||||
|
shown_remediation = set()
|
||||||
|
for rule_id, msg in errors:
|
||||||
|
print(f" ERROR [{rule_id}] {msg}")
|
||||||
|
if rule_id in REMEDIATION and rule_id not in shown_remediation:
|
||||||
|
lines = REMEDIATION[rule_id].split("\n")
|
||||||
|
print(f" Fix: {lines[0]}")
|
||||||
|
for line in lines[1:]:
|
||||||
|
print(f" {line}")
|
||||||
|
shown_remediation.add(rule_id)
|
||||||
|
for rule_id, msg in warnings:
|
||||||
|
print(f" WARN [{rule_id}] {msg}")
|
||||||
|
if rule_id in REMEDIATION and rule_id not in shown_remediation:
|
||||||
|
lines = REMEDIATION[rule_id].split("\n")
|
||||||
|
print(f" Fix: {lines[0]}")
|
||||||
|
for line in lines[1:]:
|
||||||
|
print(f" {line}")
|
||||||
|
shown_remediation.add(rule_id)
|
||||||
|
|
||||||
|
total_errors += len(errors)
|
||||||
|
total_warnings += len(warnings)
|
||||||
|
|
||||||
|
print(f"\n{'─'*50}")
|
||||||
|
print(f"checked {len(app_dirs)} apps • {total_errors} errors • {total_warnings} warnings")
|
||||||
|
|
||||||
|
return 1 if total_errors > 0 else 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
284
admin/scripts/validation-rules.yaml
Normal file
284
admin/scripts/validation-rules.yaml
Normal file
@@ -0,0 +1,284 @@
|
|||||||
|
# Wild Cloud App Validation Rules
|
||||||
|
#
|
||||||
|
# Loaded by validate-apps.py at runtime.
|
||||||
|
# Run `validate-apps.py --list-rules` for a formatted summary.
|
||||||
|
#
|
||||||
|
# To suppress a rule for an app, add its ID to ignoreRules in app.yaml:
|
||||||
|
# ignoreRules:
|
||||||
|
# - WC-HELM # upstream Helm-rendered manifests
|
||||||
|
#
|
||||||
|
# Fields:
|
||||||
|
# severity: error (must fix before submitting) | warning (strong suggestion)
|
||||||
|
# description: What the rule checks for
|
||||||
|
# remediation: How to fix the issue
|
||||||
|
|
||||||
|
# ── app.yaml ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
WC-APP-MISS:
|
||||||
|
severity: error
|
||||||
|
description: app.yaml file is missing
|
||||||
|
remediation: Create app.yaml with name, description, category, latest, and icon fields
|
||||||
|
|
||||||
|
WC-IGN:
|
||||||
|
severity: error
|
||||||
|
description: app.yaml ignoreRules contains an unknown rule ID
|
||||||
|
remediation: Remove the stale entry from ignoreRules, or check for a typo in the rule ID (run --list-rules to see valid IDs)
|
||||||
|
|
||||||
|
WC-APP-PARSE:
|
||||||
|
severity: error
|
||||||
|
description: app.yaml could not be parsed as valid YAML
|
||||||
|
remediation: Fix YAML syntax errors in app.yaml
|
||||||
|
|
||||||
|
WC-APP-EMPTY:
|
||||||
|
severity: error
|
||||||
|
description: app.yaml is empty
|
||||||
|
remediation: Populate app.yaml — it is empty
|
||||||
|
|
||||||
|
WC-REQ:
|
||||||
|
severity: error
|
||||||
|
description: app.yaml missing one or more required fields
|
||||||
|
remediation: "Add the missing fields: name, is, description, category, and latest are all required"
|
||||||
|
|
||||||
|
WC-NAME:
|
||||||
|
severity: error
|
||||||
|
description: app.yaml name does not match the directory name
|
||||||
|
remediation: "Set 'name:' in app.yaml to exactly match the directory name"
|
||||||
|
|
||||||
|
WC-ICON:
|
||||||
|
severity: warning
|
||||||
|
description: app.yaml missing icon field
|
||||||
|
remediation: "Add 'icon: <url>' to app.yaml pointing to the app's logo"
|
||||||
|
|
||||||
|
# ── versions ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
WC-VER:
|
||||||
|
severity: error
|
||||||
|
description: versions/ directory is missing
|
||||||
|
remediation: Create a versions/ subdirectory with at least one version slot directory inside it
|
||||||
|
|
||||||
|
WC-LATEST:
|
||||||
|
severity: error
|
||||||
|
description: latest version directory does not exist
|
||||||
|
remediation: "Create the version slot directory named in 'latest:', or update 'latest:' to an existing slot"
|
||||||
|
|
||||||
|
WC-VIA:
|
||||||
|
severity: error
|
||||||
|
description: upgrade.from[].via references a slot directory that does not exist
|
||||||
|
remediation: "Create the waypoint slot directory, or fix the via: value to match an existing slot"
|
||||||
|
|
||||||
|
WC-SLOT:
|
||||||
|
severity: warning
|
||||||
|
description: version slot directory name looks like a full version string or packaging revision
|
||||||
|
remediation: |
|
||||||
|
Rename the slot to the major version only (e.g. '1', 'v1', 'v0').
|
||||||
|
Full versions ('1.2.3') and packaging revisions ('1-2') do not belong in directory names.
|
||||||
|
The actual version string lives in manifest.yaml.
|
||||||
|
|
||||||
|
# ── manifest.yaml ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
WC-NSFILE:
|
||||||
|
severity: warning
|
||||||
|
description: no Namespace resource defined — workloads will deploy into an unmanaged namespace
|
||||||
|
remediation: |
|
||||||
|
Add a namespace.yaml listing it in kustomization.yaml resources:
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Namespace
|
||||||
|
metadata:
|
||||||
|
name: {{ .namespace }}
|
||||||
|
|
||||||
|
# ── manifest.yaml ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
WC-MAN-MISS:
|
||||||
|
severity: error
|
||||||
|
description: manifest.yaml is missing
|
||||||
|
remediation: "Create manifest.yaml with at minimum: version, defaultConfig.namespace"
|
||||||
|
|
||||||
|
WC-MAN-PARSE:
|
||||||
|
severity: error
|
||||||
|
description: manifest.yaml could not be parsed
|
||||||
|
remediation: Fix YAML syntax errors in manifest.yaml
|
||||||
|
|
||||||
|
WC-MAN-EMPTY:
|
||||||
|
severity: error
|
||||||
|
description: manifest.yaml is empty
|
||||||
|
remediation: Populate manifest.yaml — it is empty
|
||||||
|
|
||||||
|
WC-MAN-REQ:
|
||||||
|
severity: error
|
||||||
|
description: manifest.yaml missing a required field
|
||||||
|
remediation: "Add the missing field to manifest.yaml (at minimum: version)"
|
||||||
|
|
||||||
|
WC-TVAR:
|
||||||
|
severity: warning
|
||||||
|
description: template variable in resource file not found in defaultConfig
|
||||||
|
remediation: |
|
||||||
|
Add the missing variable to defaultConfig in manifest.yaml, or check for a typo in the template.
|
||||||
|
Only top-level variable names are checked — e.g. .db in {{ .db.host }} must appear as a key under defaultConfig.
|
||||||
|
Global namespaces (cloud, cluster, operator, apps, secrets, app) are always available and exempt.
|
||||||
|
|
||||||
|
WC-CFG:
|
||||||
|
severity: warning
|
||||||
|
description: manifest.yaml defaultConfig is empty or missing
|
||||||
|
remediation: "Add a 'defaultConfig:' block to manifest.yaml"
|
||||||
|
|
||||||
|
WC-CFG-NS:
|
||||||
|
severity: warning
|
||||||
|
description: manifest.yaml defaultConfig missing namespace field
|
||||||
|
remediation: "Add 'namespace: <app-name>' under defaultConfig in manifest.yaml"
|
||||||
|
|
||||||
|
WC-SSL:
|
||||||
|
severity: error
|
||||||
|
description: postgres URL missing ?sslmode=disable
|
||||||
|
remediation: |
|
||||||
|
Append ?sslmode=disable to the postgres connection URL:
|
||||||
|
postgresql://user:pass@host:5432/db?sslmode=disable
|
||||||
|
|
||||||
|
WC-DOT:
|
||||||
|
severity: error
|
||||||
|
description: requiredSecret does not use <app-ref>.<key> dot notation
|
||||||
|
remediation: "Use <app-ref>.<key> format in requiredSecrets (e.g. postgres.password, not just 'password')"
|
||||||
|
|
||||||
|
# ── kustomization.yaml ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
WC-KUS-MISS:
|
||||||
|
severity: error
|
||||||
|
description: kustomization.yaml is missing
|
||||||
|
remediation: Create kustomization.yaml listing all resource files with Wild Cloud labels
|
||||||
|
|
||||||
|
WC-KUS-PARSE:
|
||||||
|
severity: error
|
||||||
|
description: kustomization.yaml could not be parsed
|
||||||
|
remediation: Fix YAML syntax errors in kustomization.yaml
|
||||||
|
|
||||||
|
WC-KUS-EMPTY:
|
||||||
|
severity: error
|
||||||
|
description: kustomization.yaml is empty
|
||||||
|
remediation: "Add a resources: list and labels: block to kustomization.yaml"
|
||||||
|
|
||||||
|
WC-RES:
|
||||||
|
severity: error
|
||||||
|
description: a resource listed in kustomization.yaml does not exist on disk
|
||||||
|
remediation: "Create the missing file, or remove it from resources: in kustomization.yaml"
|
||||||
|
|
||||||
|
WC-RES-PARSE:
|
||||||
|
severity: error
|
||||||
|
description: a resource file listed in kustomization.yaml could not be parsed as valid YAML
|
||||||
|
remediation: Fix YAML syntax errors in the resource file
|
||||||
|
|
||||||
|
WC-NS:
|
||||||
|
severity: warning
|
||||||
|
description: no namespace in kustomization.yaml (has Deployment/StatefulSet)
|
||||||
|
remediation: "Add 'namespace: {{ .namespace }}' to kustomization.yaml"
|
||||||
|
|
||||||
|
WC-SEL:
|
||||||
|
severity: error
|
||||||
|
description: labels.includeSelectors true missing (required for Deployment/StatefulSet)
|
||||||
|
remediation: |
|
||||||
|
Add to kustomization.yaml:
|
||||||
|
labels:
|
||||||
|
- includeSelectors: true
|
||||||
|
pairs:
|
||||||
|
app: <app-name>
|
||||||
|
managedBy: kustomize
|
||||||
|
partOf: wild-cloud
|
||||||
|
|
||||||
|
WC-PAIRS:
|
||||||
|
severity: warning
|
||||||
|
description: a standard label pair is missing or has the wrong value
|
||||||
|
remediation: "Ensure labels.pairs includes managedBy: kustomize and partOf: wild-cloud"
|
||||||
|
|
||||||
|
WC-LBL:
|
||||||
|
severity: warning
|
||||||
|
description: label pair 'app' does not match the app directory name
|
||||||
|
remediation: "Set 'app:' in label pairs to match the app directory name"
|
||||||
|
|
||||||
|
# ── workloads ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
WC-SC-POD:
|
||||||
|
severity: warning
|
||||||
|
description: no pod-level securityContext defined
|
||||||
|
remediation: |
|
||||||
|
Add to the pod spec:
|
||||||
|
securityContext:
|
||||||
|
runAsNonRoot: true
|
||||||
|
runAsUser: <uid>
|
||||||
|
seccompProfile:
|
||||||
|
type: RuntimeDefault
|
||||||
|
|
||||||
|
WC-SC-CTR:
|
||||||
|
severity: warning
|
||||||
|
description: no container-level securityContext defined
|
||||||
|
remediation: |
|
||||||
|
Add to each container spec:
|
||||||
|
securityContext:
|
||||||
|
allowPrivilegeEscalation: false
|
||||||
|
capabilities:
|
||||||
|
drop: [ALL]
|
||||||
|
|
||||||
|
WC-RWO:
|
||||||
|
severity: error
|
||||||
|
description: Deployment using a ReadWriteOnce PVC must use strategy Recreate
|
||||||
|
remediation: "Set strategy: type: Recreate on the Deployment — RWO volumes cannot attach to two pods at once"
|
||||||
|
|
||||||
|
WC-HELM:
|
||||||
|
severity: error
|
||||||
|
description: Helm-style app.kubernetes.io/* labels found in pod template
|
||||||
|
remediation: "Remove app.kubernetes.io/* labels from pod template; use simple labels (e.g. component: web) instead"
|
||||||
|
|
||||||
|
WC-IMG:
|
||||||
|
severity: error
|
||||||
|
description: container image uses a mutable or missing version tag
|
||||||
|
remediation: "Pin the image to a specific release tag (e.g. myimage:1.2.3), not latest/main/stable/etc."
|
||||||
|
|
||||||
|
WC-BITNAMI:
|
||||||
|
severity: warning
|
||||||
|
description: container image is from Bitnami — prefer an official image
|
||||||
|
remediation: |
|
||||||
|
Replace the Bitnami image with the official upstream image.
|
||||||
|
Bitnami images require Docker Hub authentication since 2023, use non-standard filesystem layouts,
|
||||||
|
and are often a sign of a Helm chart that wasn't fully adapted. Check Docker Hub or the project's
|
||||||
|
docs for the official image (e.g. postgres:15, redis:alpine, mariadb:11).
|
||||||
|
|
||||||
|
# ── ingress ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
WC-ING-ANN:
|
||||||
|
severity: error
|
||||||
|
description: ingress uses deprecated kubernetes.io/ingress.class annotation
|
||||||
|
remediation: "Replace the annotation with spec.ingressClassName: traefik in the Ingress spec"
|
||||||
|
|
||||||
|
WC-ING-CLS:
|
||||||
|
severity: warning
|
||||||
|
description: ingress spec.ingressClassName should be traefik
|
||||||
|
remediation: "Add 'ingressClassName: traefik' under spec: in the Ingress"
|
||||||
|
|
||||||
|
WC-CERT:
|
||||||
|
severity: error
|
||||||
|
description: ingress uses cert-manager annotation (wildcard cert is pre-distributed)
|
||||||
|
remediation: "Remove the cert-manager.io/cluster-issuer annotation — wildcard TLS is pre-distributed to namespaces"
|
||||||
|
|
||||||
|
WC-DNS:
|
||||||
|
severity: warning
|
||||||
|
description: ingress missing external-dns.alpha.kubernetes.io/target annotation
|
||||||
|
remediation: |
|
||||||
|
Add to Ingress metadata.annotations:
|
||||||
|
external-dns.alpha.kubernetes.io/target: '{{ .externalDnsDomain }}'
|
||||||
|
|
||||||
|
# ── db-init ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
WC-DKRDNS:
|
||||||
|
severity: error
|
||||||
|
description: Docker DNS resolver (127.0.0.11) used — not available in Kubernetes
|
||||||
|
remediation: |
|
||||||
|
Remove `resolver 127.0.0.11` and avoid nginx variables in proxy_pass.
|
||||||
|
Use `proxy_pass http://backend-name:port;` (no variable) so nginx resolves via CoreDNS at startup.
|
||||||
|
If a runtime-resolved variable is unavoidable, use the cluster DNS resolver instead:
|
||||||
|
resolver kube-dns.kube-system.svc.cluster.local valid=30s;
|
||||||
|
See docs/nginx.md for the full pattern.
|
||||||
|
|
||||||
|
WC-DBIN:
|
||||||
|
severity: error
|
||||||
|
description: MySQL db-init uses CREATE USER without ALTER USER (passwords won't update on redeploy)
|
||||||
|
remediation: |
|
||||||
|
Add ALTER USER after CREATE USER so passwords update on redeploy:
|
||||||
|
CREATE USER IF NOT EXISTS '${USER}'@'%' IDENTIFIED BY '${PASSWORD}';
|
||||||
|
ALTER USER '${USER}'@'%' IDENTIFIED BY '${PASSWORD}';
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
name: akaunting
|
name: akaunting
|
||||||
is: akaunting
|
is: akaunting
|
||||||
description: Akaunting is a free, open-source, and online accounting software for small businesses and freelancers.
|
description: Akaunting is a free, open-source, and online accounting software for small businesses and freelancers.
|
||||||
|
category: business
|
||||||
icon: https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/akaunting.svg
|
icon: https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/akaunting.svg
|
||||||
latest: "3"
|
latest: "3"
|
||||||
|
|||||||
@@ -3,10 +3,6 @@ kind: Ingress
|
|||||||
metadata:
|
metadata:
|
||||||
name: akaunting
|
name: akaunting
|
||||||
namespace: akaunting
|
namespace: akaunting
|
||||||
annotations:
|
|
||||||
external-dns.alpha.kubernetes.io/target: {{ .externalDnsDomain }}
|
|
||||||
external-dns.alpha.kubernetes.io/cloudflare-proxied: "false"
|
|
||||||
external-dns.alpha.kubernetes.io/ttl: "60"
|
|
||||||
spec:
|
spec:
|
||||||
ingressClassName: traefik
|
ingressClassName: traefik
|
||||||
rules:
|
rules:
|
||||||
@@ -20,7 +16,3 @@ spec:
|
|||||||
name: akaunting
|
name: akaunting
|
||||||
port:
|
port:
|
||||||
number: 80
|
number: 80
|
||||||
tls:
|
|
||||||
- hosts:
|
|
||||||
- {{ .domain }}
|
|
||||||
secretName: {{ .tlsSecretName }}
|
|
||||||
|
|||||||
@@ -1,12 +1,10 @@
|
|||||||
version: 3.1.21-1
|
version: 3.1.21-2
|
||||||
requires:
|
requires:
|
||||||
- name: mysql
|
- name: mysql
|
||||||
- name: smtp
|
- name: smtp
|
||||||
defaultConfig:
|
defaultConfig:
|
||||||
namespace: akaunting
|
namespace: akaunting
|
||||||
externalDnsDomain: '{{ .cloud.domain }}'
|
|
||||||
domain: akaunting.{{ .cloud.domain }}
|
domain: akaunting.{{ .cloud.domain }}
|
||||||
tlsSecretName: wildcard-wild-cloud-tls
|
|
||||||
storage: 2Gi
|
storage: 2Gi
|
||||||
locale: en-US
|
locale: en-US
|
||||||
dbPrefix: aka_
|
dbPrefix: aka_
|
||||||
@@ -15,7 +13,7 @@ defaultConfig:
|
|||||||
adminEmail: '{{ .operator.email }}'
|
adminEmail: '{{ .operator.email }}'
|
||||||
db:
|
db:
|
||||||
host: '{{ .apps.mysql.host }}'
|
host: '{{ .apps.mysql.host }}'
|
||||||
port: "3306"
|
port: '3306'
|
||||||
name: akaunting
|
name: akaunting
|
||||||
user: akaunting
|
user: akaunting
|
||||||
smtp:
|
smtp:
|
||||||
@@ -24,8 +22,8 @@ defaultConfig:
|
|||||||
from: '{{ .apps.smtp.from }}'
|
from: '{{ .apps.smtp.from }}'
|
||||||
user: '{{ .apps.smtp.user }}'
|
user: '{{ .apps.smtp.user }}'
|
||||||
defaultSecrets:
|
defaultSecrets:
|
||||||
- key: dbPassword
|
- key: dbPassword
|
||||||
- key: adminPassword
|
- key: adminPassword
|
||||||
- key: smtpPassword
|
- key: smtpPassword
|
||||||
requiredSecrets:
|
requiredSecrets:
|
||||||
- mysql.rootPassword
|
- mysql.rootPassword
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
name: aptly
|
name: aptly
|
||||||
is: aptly
|
is: aptly
|
||||||
description: Aptly is a Debian/Ubuntu APT repository management tool for mirroring, snapshotting, and publishing package repositories.
|
description: Aptly is a Debian/Ubuntu APT repository management tool for mirroring, snapshotting, and publishing package repositories.
|
||||||
|
category: developer
|
||||||
icon: https://github.com/aptly-dev.png
|
icon: https://github.com/aptly-dev.png
|
||||||
latest: "1"
|
latest: "1"
|
||||||
|
|||||||
@@ -2,10 +2,6 @@ apiVersion: networking.k8s.io/v1
|
|||||||
kind: Ingress
|
kind: Ingress
|
||||||
metadata:
|
metadata:
|
||||||
name: aptly
|
name: aptly
|
||||||
annotations:
|
|
||||||
external-dns.alpha.kubernetes.io/target: {{ .externalDnsDomain }}
|
|
||||||
external-dns.alpha.kubernetes.io/cloudflare-proxied: "false"
|
|
||||||
external-dns.alpha.kubernetes.io/ttl: "60"
|
|
||||||
spec:
|
spec:
|
||||||
ingressClassName: traefik
|
ingressClassName: traefik
|
||||||
rules:
|
rules:
|
||||||
@@ -19,7 +15,3 @@ spec:
|
|||||||
name: aptly
|
name: aptly
|
||||||
port:
|
port:
|
||||||
number: 80
|
number: 80
|
||||||
tls:
|
|
||||||
- hosts:
|
|
||||||
- {{ .domain }}
|
|
||||||
secretName: {{ .tlsSecretName }}
|
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
version: 1.6.2-3
|
version: 1.6.2-4
|
||||||
defaultConfig:
|
defaultConfig:
|
||||||
namespace: aptly
|
namespace: aptly
|
||||||
externalDnsDomain: "{{ .cloud.domain }}"
|
domain: aptly.{{ .cloud.domain }}
|
||||||
domain: "aptly.{{ .cloud.domain }}"
|
|
||||||
tlsSecretName: wildcard-wild-cloud-tls
|
|
||||||
storage: 5Gi
|
storage: 5Gi
|
||||||
apiUser: aptly
|
apiUser: aptly
|
||||||
operatorEmail: "{{ .operator.email }}"
|
operatorEmail: '{{ .operator.email }}'
|
||||||
defaultSecrets:
|
defaultSecrets:
|
||||||
- key: apiPassword
|
- key: apiPassword
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
name: baserow
|
name: baserow
|
||||||
is: baserow
|
is: baserow
|
||||||
description: Baserow is an open-source no-code database platform and Airtable alternative. Create and manage databases through a spreadsheet-like interface without writing code.
|
description: Baserow is an open-source no-code database platform and Airtable alternative. Create and manage databases through a spreadsheet-like interface without writing code.
|
||||||
|
category: business
|
||||||
icon: https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/baserow.svg
|
icon: https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/baserow.svg
|
||||||
latest: "2"
|
latest: "2"
|
||||||
|
|||||||
@@ -69,6 +69,15 @@ spec:
|
|||||||
volumeMounts:
|
volumeMounts:
|
||||||
- name: baserow-data
|
- name: baserow-data
|
||||||
mountPath: /baserow/data
|
mountPath: /baserow/data
|
||||||
|
securityContext:
|
||||||
|
runAsNonRoot: false
|
||||||
|
runAsUser: 0
|
||||||
|
allowPrivilegeEscalation: false
|
||||||
|
capabilities:
|
||||||
|
drop: [ALL]
|
||||||
|
readOnlyRootFilesystem: false
|
||||||
|
seccompProfile:
|
||||||
|
type: RuntimeDefault
|
||||||
livenessProbe:
|
livenessProbe:
|
||||||
httpGet:
|
httpGet:
|
||||||
path: /api/_health/
|
path: /api/_health/
|
||||||
|
|||||||
@@ -3,10 +3,6 @@ kind: Ingress
|
|||||||
metadata:
|
metadata:
|
||||||
name: baserow
|
name: baserow
|
||||||
namespace: {{ .namespace }}
|
namespace: {{ .namespace }}
|
||||||
annotations:
|
|
||||||
external-dns.alpha.kubernetes.io/target: {{ .externalDnsDomain }}
|
|
||||||
external-dns.alpha.kubernetes.io/cloudflare-proxied: "false"
|
|
||||||
external-dns.alpha.kubernetes.io/ttl: "60"
|
|
||||||
spec:
|
spec:
|
||||||
ingressClassName: traefik
|
ingressClassName: traefik
|
||||||
rules:
|
rules:
|
||||||
@@ -20,7 +16,3 @@ spec:
|
|||||||
name: baserow
|
name: baserow
|
||||||
port:
|
port:
|
||||||
number: 80
|
number: 80
|
||||||
tls:
|
|
||||||
- hosts:
|
|
||||||
- {{ .domain }}
|
|
||||||
secretName: {{ .tlsSecretName }}
|
|
||||||
|
|||||||
@@ -1,23 +1,21 @@
|
|||||||
version: 2.2.2-1
|
version: 2.2.2-3
|
||||||
requires:
|
requires:
|
||||||
- name: postgres
|
- name: postgres
|
||||||
- name: redis
|
- name: redis
|
||||||
defaultConfig:
|
defaultConfig:
|
||||||
namespace: baserow
|
namespace: baserow
|
||||||
externalDnsDomain: "{{ .cloud.domain }}"
|
|
||||||
domain: baserow.{{ .cloud.domain }}
|
domain: baserow.{{ .cloud.domain }}
|
||||||
tlsSecretName: wildcard-wild-cloud-tls
|
|
||||||
storage: 2Gi
|
storage: 2Gi
|
||||||
db:
|
db:
|
||||||
host: "{{ .apps.postgres.host }}"
|
host: '{{ .apps.postgres.host }}'
|
||||||
port: "5432"
|
port: '5432'
|
||||||
name: baserow
|
name: baserow
|
||||||
user: baserow
|
user: baserow
|
||||||
redis:
|
redis:
|
||||||
host: "{{ .apps.redis.host }}"
|
host: '{{ .apps.redis.host }}'
|
||||||
defaultSecrets:
|
defaultSecrets:
|
||||||
- key: dbPassword
|
- key: dbPassword
|
||||||
- key: secretKey
|
- key: secretKey
|
||||||
requiredSecrets:
|
requiredSecrets:
|
||||||
- postgres.password
|
- postgres.password
|
||||||
- redis.password
|
- redis.password
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
name: bookstack
|
name: bookstack
|
||||||
is: bookstack
|
is: bookstack
|
||||||
description: BookStack is a simple, self-hosted, easy-to-use platform for organising and storing information.
|
description: BookStack is a simple, self-hosted, easy-to-use platform for organising and storing information.
|
||||||
|
category: productivity
|
||||||
icon: https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/bookstack.svg
|
icon: https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/bookstack.svg
|
||||||
latest: "26"
|
latest: "26"
|
||||||
|
|||||||
@@ -3,10 +3,6 @@ kind: Ingress
|
|||||||
metadata:
|
metadata:
|
||||||
name: bookstack
|
name: bookstack
|
||||||
namespace: {{ .namespace }}
|
namespace: {{ .namespace }}
|
||||||
annotations:
|
|
||||||
external-dns.alpha.kubernetes.io/target: {{ .externalDnsDomain }}
|
|
||||||
external-dns.alpha.kubernetes.io/cloudflare-proxied: "false"
|
|
||||||
external-dns.alpha.kubernetes.io/ttl: "60"
|
|
||||||
spec:
|
spec:
|
||||||
ingressClassName: traefik
|
ingressClassName: traefik
|
||||||
rules:
|
rules:
|
||||||
@@ -20,7 +16,3 @@ spec:
|
|||||||
name: bookstack
|
name: bookstack
|
||||||
port:
|
port:
|
||||||
number: 80
|
number: 80
|
||||||
tls:
|
|
||||||
- hosts:
|
|
||||||
- {{ .domain }}
|
|
||||||
secretName: {{ .tlsSecretName }}
|
|
||||||
|
|||||||
@@ -1,12 +1,10 @@
|
|||||||
version: 26.05.1-2
|
version: 26.05.1-3
|
||||||
requires:
|
requires:
|
||||||
- name: mysql
|
- name: mysql
|
||||||
- name: smtp
|
- name: smtp
|
||||||
defaultConfig:
|
defaultConfig:
|
||||||
namespace: bookstack
|
namespace: bookstack
|
||||||
externalDnsDomain: '{{ .cloud.domain }}'
|
|
||||||
domain: bookstack.{{ .cloud.domain }}
|
domain: bookstack.{{ .cloud.domain }}
|
||||||
tlsSecretName: wildcard-wild-cloud-tls
|
|
||||||
storage: 2Gi
|
storage: 2Gi
|
||||||
db:
|
db:
|
||||||
host: '{{ .apps.mysql.host }}'
|
host: '{{ .apps.mysql.host }}'
|
||||||
@@ -19,8 +17,8 @@ defaultConfig:
|
|||||||
from: '{{ .apps.smtp.from }}'
|
from: '{{ .apps.smtp.from }}'
|
||||||
user: '{{ .apps.smtp.user }}'
|
user: '{{ .apps.smtp.user }}'
|
||||||
defaultSecrets:
|
defaultSecrets:
|
||||||
- key: appKey
|
- key: appKey
|
||||||
- key: dbPassword
|
- key: dbPassword
|
||||||
- key: smtpPassword
|
- key: smtpPassword
|
||||||
requiredSecrets:
|
requiredSecrets:
|
||||||
- mysql.rootPassword
|
- mysql.rootPassword
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
name: bookwyrm
|
name: bookwyrm
|
||||||
is: bookwyrm
|
is: bookwyrm
|
||||||
description: BookWyrm is a federated social reading platform using ActivityPub. Track your reading, write reviews, and connect with readers across the fediverse.
|
description: BookWyrm is a federated social reading platform using ActivityPub. Track your reading, write reviews, and connect with readers across the fediverse.
|
||||||
|
category: social
|
||||||
icon: https://raw.githubusercontent.com/bookwyrm-social/bookwyrm/main/bookwyrm/static/images/logo.png
|
icon: https://raw.githubusercontent.com/bookwyrm-social/bookwyrm/main/bookwyrm/static/images/logo.png
|
||||||
latest: "0"
|
latest: "0"
|
||||||
|
|||||||
38
bookwyrm/notes.md
Normal file
38
bookwyrm/notes.md
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
# BookWyrm — Notes
|
||||||
|
|
||||||
|
## SCSS themes must be compiled before collectstatic
|
||||||
|
|
||||||
|
BookWyrm ships SCSS source files instead of pre-compiled CSS. The static root is an `emptyDir`
|
||||||
|
(ephemeral), so it is empty on every pod restart. Running `collectstatic` alone fails:
|
||||||
|
|
||||||
|
```
|
||||||
|
ValueError: Missing staticfiles manifest entry for 'css/themes/...'
|
||||||
|
```
|
||||||
|
|
||||||
|
**Fix**: compile themes before collecting static files:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
command: ["/bin/sh", "-c"]
|
||||||
|
args:
|
||||||
|
- |
|
||||||
|
python manage.py compile_themes \
|
||||||
|
&& python manage.py collectstatic --noinput \
|
||||||
|
&& python manage.py migrate \
|
||||||
|
&& exec gunicorn bookwyrm.wsgi:application ...
|
||||||
|
```
|
||||||
|
|
||||||
|
Check the image's `Dockerfile` or `docker_start.sh` to confirm the current required build steps.
|
||||||
|
|
||||||
|
## PostgreSQL extensions require superuser
|
||||||
|
|
||||||
|
BookWyrm uses extensions (`bloom`, `pg_trgm`, etc.) that require superuser to install. Migrations
|
||||||
|
fail with `permission denied to create extension "bloom"` if the app DB user is not a superuser.
|
||||||
|
|
||||||
|
**Fix**: create the extensions in the `db-init-job`, which connects as the `postgres` superuser:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
psql -d "$APP_DB_NAME" -c "CREATE EXTENSION IF NOT EXISTS bloom;"
|
||||||
|
psql -d "$APP_DB_NAME" -c "CREATE EXTENSION IF NOT EXISTS pg_trgm;"
|
||||||
|
```
|
||||||
|
|
||||||
|
This must run before migrations (i.e. in `db-init-job`, not in the app startup command).
|
||||||
@@ -80,4 +80,7 @@ spec:
|
|||||||
GRANT USAGE ON SCHEMA public TO \"$BOOKWYRM_DB_USER\";
|
GRANT USAGE ON SCHEMA public TO \"$BOOKWYRM_DB_USER\";
|
||||||
"
|
"
|
||||||
|
|
||||||
|
# Create bloom extension (required by bookwyrm migrations, needs superuser)
|
||||||
|
psql -d "$BOOKWYRM_DB_NAME" -c "CREATE EXTENSION IF NOT EXISTS bloom;"
|
||||||
|
|
||||||
echo "Database initialization completed."
|
echo "Database initialization completed."
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ spec:
|
|||||||
containers:
|
containers:
|
||||||
- name: bookwyrm
|
- name: bookwyrm
|
||||||
image: ghcr.io/bookwyrm-social/bookwyrm:v0.8.6
|
image: ghcr.io/bookwyrm-social/bookwyrm:v0.8.6
|
||||||
command: ["gunicorn", "bookwyrm.wsgi:application"]
|
command: ["/bin/sh", "-c", "python manage.py compile_themes && python manage.py collectstatic --noinput && python manage.py migrate && exec gunicorn bookwyrm.wsgi:application"]
|
||||||
ports:
|
ports:
|
||||||
- name: http
|
- name: http
|
||||||
containerPort: 8000
|
containerPort: 8000
|
||||||
|
|||||||
@@ -3,10 +3,6 @@ kind: Ingress
|
|||||||
metadata:
|
metadata:
|
||||||
name: bookwyrm
|
name: bookwyrm
|
||||||
namespace: bookwyrm
|
namespace: bookwyrm
|
||||||
annotations:
|
|
||||||
external-dns.alpha.kubernetes.io/target: {{ .externalDnsDomain }}
|
|
||||||
external-dns.alpha.kubernetes.io/cloudflare-proxied: "false"
|
|
||||||
external-dns.alpha.kubernetes.io/ttl: "60"
|
|
||||||
spec:
|
spec:
|
||||||
ingressClassName: traefik
|
ingressClassName: traefik
|
||||||
rules:
|
rules:
|
||||||
@@ -20,7 +16,3 @@ spec:
|
|||||||
name: bookwyrm
|
name: bookwyrm
|
||||||
port:
|
port:
|
||||||
number: 80
|
number: 80
|
||||||
tls:
|
|
||||||
- hosts:
|
|
||||||
- {{ .domain }}
|
|
||||||
secretName: {{ .tlsSecretName }}
|
|
||||||
|
|||||||
@@ -1,13 +1,11 @@
|
|||||||
version: 0.8.7-1
|
version: 0.8.7-2
|
||||||
requires:
|
requires:
|
||||||
- name: postgres
|
- name: postgres
|
||||||
- name: redis
|
- name: redis
|
||||||
- name: smtp
|
- name: smtp
|
||||||
defaultConfig:
|
defaultConfig:
|
||||||
namespace: bookwyrm
|
namespace: bookwyrm
|
||||||
externalDnsDomain: '{{ .cloud.domain }}'
|
|
||||||
domain: bookwyrm.{{ .cloud.domain }}
|
domain: bookwyrm.{{ .cloud.domain }}
|
||||||
tlsSecretName: wildcard-wild-cloud-tls
|
|
||||||
storage: 2Gi
|
storage: 2Gi
|
||||||
db:
|
db:
|
||||||
host: '{{ .apps.postgres.host }}'
|
host: '{{ .apps.postgres.host }}'
|
||||||
@@ -22,9 +20,9 @@ defaultConfig:
|
|||||||
from: '{{ .apps.smtp.from }}'
|
from: '{{ .apps.smtp.from }}'
|
||||||
user: '{{ .apps.smtp.user }}'
|
user: '{{ .apps.smtp.user }}'
|
||||||
defaultSecrets:
|
defaultSecrets:
|
||||||
- key: secretKey
|
- key: secretKey
|
||||||
- key: dbPassword
|
- key: dbPassword
|
||||||
- key: smtpPassword
|
- key: smtpPassword
|
||||||
requiredSecrets:
|
requiredSecrets:
|
||||||
- postgres.password
|
- postgres.password
|
||||||
- redis.password
|
- redis.password
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
name: cert-manager
|
name: cert-manager
|
||||||
is: cert-manager
|
is: cert-manager
|
||||||
description: X.509 certificate management for Kubernetes
|
description: X.509 certificate management for Kubernetes
|
||||||
category: infrastructure
|
category: services
|
||||||
|
icon: https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/cert-manager.svg
|
||||||
latest: "v1"
|
latest: "v1"
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
name: chamilo
|
name: chamilo
|
||||||
is: chamilo
|
is: chamilo
|
||||||
description: Chamilo is a free and open-source e-learning platform (LMS) for course management, assessments, and online education.
|
description: Chamilo is a free and open-source e-learning platform (LMS) for course management, assessments, and online education.
|
||||||
|
category: education
|
||||||
icon: https://raw.githubusercontent.com/chamilo/chamilo-lms/master/public/img/logo.png
|
icon: https://raw.githubusercontent.com/chamilo/chamilo-lms/master/public/img/logo.png
|
||||||
latest: "1"
|
latest: "1"
|
||||||
|
|||||||
@@ -3,10 +3,6 @@ kind: Ingress
|
|||||||
metadata:
|
metadata:
|
||||||
name: chamilo
|
name: chamilo
|
||||||
namespace: {{ .namespace }}
|
namespace: {{ .namespace }}
|
||||||
annotations:
|
|
||||||
external-dns.alpha.kubernetes.io/cloudflare-proxied: "false"
|
|
||||||
external-dns.alpha.kubernetes.io/target: {{ .externalDnsDomain }}
|
|
||||||
external-dns.alpha.kubernetes.io/ttl: "60"
|
|
||||||
spec:
|
spec:
|
||||||
ingressClassName: traefik
|
ingressClassName: traefik
|
||||||
rules:
|
rules:
|
||||||
@@ -20,7 +16,3 @@ spec:
|
|||||||
name: chamilo
|
name: chamilo
|
||||||
port:
|
port:
|
||||||
number: 80
|
number: 80
|
||||||
tls:
|
|
||||||
- hosts:
|
|
||||||
- {{ .domain }}
|
|
||||||
secretName: {{ .tlsSecretName }}
|
|
||||||
|
|||||||
@@ -1,19 +1,17 @@
|
|||||||
version: 1.11.28-1
|
version: 1.11.28-2
|
||||||
requires:
|
requires:
|
||||||
- name: mysql
|
- name: mysql
|
||||||
- name: smtp
|
- name: smtp
|
||||||
defaultConfig:
|
defaultConfig:
|
||||||
namespace: chamilo
|
namespace: chamilo
|
||||||
externalDnsDomain: '{{ .cloud.domain }}'
|
|
||||||
domain: chamilo.{{ .cloud.domain }}
|
domain: chamilo.{{ .cloud.domain }}
|
||||||
tlsSecretName: wildcard-wild-cloud-tls
|
|
||||||
storage: 2Gi
|
storage: 2Gi
|
||||||
siteName: Chamilo LMS
|
siteName: Chamilo LMS
|
||||||
adminUser: admin
|
adminUser: admin
|
||||||
adminEmail: '{{ .operator.email }}'
|
adminEmail: '{{ .operator.email }}'
|
||||||
db:
|
db:
|
||||||
host: '{{ .apps.mysql.host }}'
|
host: '{{ .apps.mysql.host }}'
|
||||||
port: "3306"
|
port: '3306'
|
||||||
name: chamilo
|
name: chamilo
|
||||||
user: chamilo
|
user: chamilo
|
||||||
smtp:
|
smtp:
|
||||||
@@ -22,8 +20,8 @@ defaultConfig:
|
|||||||
from: '{{ .apps.smtp.from }}'
|
from: '{{ .apps.smtp.from }}'
|
||||||
user: '{{ .apps.smtp.user }}'
|
user: '{{ .apps.smtp.user }}'
|
||||||
defaultSecrets:
|
defaultSecrets:
|
||||||
- key: adminPassword
|
- key: adminPassword
|
||||||
- key: dbPassword
|
- key: dbPassword
|
||||||
- key: smtpPassword
|
- key: smtpPassword
|
||||||
requiredSecrets:
|
requiredSecrets:
|
||||||
- mysql.rootPassword
|
- mysql.rootPassword
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
name: community-search
|
name: community-search
|
||||||
is: community-search
|
is: community-search
|
||||||
description: Community Search is a federated, self-hosted search engine built on community-curated indexes rather than global web crawling.
|
description: Community Search is a federated, self-hosted search engine built on community-curated indexes rather than global web crawling.
|
||||||
|
category: community
|
||||||
latest: "0"
|
latest: "0"
|
||||||
|
ignoreRules:
|
||||||
|
- WC-ICON # no established icon available
|
||||||
|
|||||||
@@ -2,10 +2,6 @@ apiVersion: networking.k8s.io/v1
|
|||||||
kind: Ingress
|
kind: Ingress
|
||||||
metadata:
|
metadata:
|
||||||
name: community-search
|
name: community-search
|
||||||
annotations:
|
|
||||||
external-dns.alpha.kubernetes.io/cloudflare-proxied: "false"
|
|
||||||
external-dns.alpha.kubernetes.io/target: {{ .externalDnsDomain }}
|
|
||||||
external-dns.alpha.kubernetes.io/ttl: "60"
|
|
||||||
spec:
|
spec:
|
||||||
ingressClassName: traefik
|
ingressClassName: traefik
|
||||||
rules:
|
rules:
|
||||||
@@ -19,7 +15,3 @@ spec:
|
|||||||
name: community-search
|
name: community-search
|
||||||
port:
|
port:
|
||||||
number: 80
|
number: 80
|
||||||
tls:
|
|
||||||
- hosts:
|
|
||||||
- {{ .domain }}
|
|
||||||
secretName: {{ .tlsSecretName }}
|
|
||||||
|
|||||||
@@ -1,12 +1,9 @@
|
|||||||
version: 0.1.2_1
|
version: 0.1.2_1-1
|
||||||
requires: []
|
|
||||||
defaultConfig:
|
defaultConfig:
|
||||||
namespace: community-search
|
namespace: community-search
|
||||||
externalDnsDomain: '{{ .cloud.domain }}'
|
|
||||||
domain: search.{{ .cloud.domain }}
|
domain: search.{{ .cloud.domain }}
|
||||||
tlsSecretName: wildcard-wild-cloud-tls
|
|
||||||
storage: 2Gi
|
storage: 2Gi
|
||||||
selfUrl: 'https://search.{{ .cloud.domain }}'
|
selfUrl: https://search.{{ .cloud.domain }}
|
||||||
selfName: 'Community Search'
|
selfName: Community Search
|
||||||
defaultSecrets:
|
defaultSecrets:
|
||||||
- key: adminToken
|
- key: adminToken
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
name: coredns
|
name: coredns
|
||||||
is: coredns
|
is: coredns
|
||||||
description: DNS server for internal cluster DNS resolution
|
description: DNS server for internal cluster DNS resolution
|
||||||
category: infrastructure
|
category: services
|
||||||
|
icon: https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/coredns.svg
|
||||||
latest: "v1"
|
latest: "v1"
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
name: crowdsec
|
name: crowdsec
|
||||||
is: crowdsec
|
is: crowdsec
|
||||||
description: CrowdSec security engine with Traefik bouncer for threat detection and rate limiting
|
description: CrowdSec security engine with Traefik bouncer for threat detection and rate limiting
|
||||||
category: infrastructure
|
category: services
|
||||||
|
icon: https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/crowdsec.svg
|
||||||
latest: "v1"
|
latest: "v1"
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ spec:
|
|||||||
- name: LEVEL_INFO
|
- name: LEVEL_INFO
|
||||||
value: "true"
|
value: "true"
|
||||||
- name: AGENT_USERNAME
|
- name: AGENT_USERNAME
|
||||||
value: "kubernetes-cluster"
|
value: "{{ if .agentUsername }}{{ .agentUsername }}{{ else }}kubernetes-cluster{{ end }}"
|
||||||
- name: AGENT_PASSWORD
|
- name: AGENT_PASSWORD
|
||||||
valueFrom:
|
valueFrom:
|
||||||
secretKeyRef:
|
secretKeyRef:
|
||||||
@@ -72,6 +72,12 @@ spec:
|
|||||||
name: crowdsec-secrets
|
name: crowdsec-secrets
|
||||||
key: bouncerApiKey
|
key: bouncerApiKey
|
||||||
optional: true
|
optional: true
|
||||||
|
{{ if .centralLapiUrl }}
|
||||||
|
- name: DISABLE_LOCAL_API
|
||||||
|
value: "true"
|
||||||
|
- name: LOCAL_API_URL
|
||||||
|
value: "{{ .centralLapiUrl }}"
|
||||||
|
{{ end }}
|
||||||
ports:
|
ports:
|
||||||
- name: lapi
|
- name: lapi
|
||||||
containerPort: 8080
|
containerPort: 8080
|
||||||
@@ -79,6 +85,7 @@ spec:
|
|||||||
- name: prometheus
|
- name: prometheus
|
||||||
containerPort: 6060
|
containerPort: 6060
|
||||||
protocol: TCP
|
protocol: TCP
|
||||||
|
{{ if not .centralLapiUrl }}
|
||||||
livenessProbe:
|
livenessProbe:
|
||||||
httpGet:
|
httpGet:
|
||||||
path: /health
|
path: /health
|
||||||
@@ -91,6 +98,7 @@ spec:
|
|||||||
port: 8080
|
port: 8080
|
||||||
initialDelaySeconds: 10
|
initialDelaySeconds: 10
|
||||||
periodSeconds: 10
|
periodSeconds: 10
|
||||||
|
{{ end }}
|
||||||
resources:
|
resources:
|
||||||
requests:
|
requests:
|
||||||
cpu: 50m
|
cpu: 50m
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
version: v1.7.8-1
|
version: v1.7.8-4
|
||||||
requires:
|
requires:
|
||||||
- name: longhorn
|
- name: longhorn
|
||||||
- name: traefik
|
- name: traefik
|
||||||
@@ -6,6 +6,8 @@ defaultConfig:
|
|||||||
namespace: crowdsec
|
namespace: crowdsec
|
||||||
rateLimitAverage: "100"
|
rateLimitAverage: "100"
|
||||||
rateLimitBurst: "100"
|
rateLimitBurst: "100"
|
||||||
|
centralLapiUrl: ""
|
||||||
|
agentUsername: ""
|
||||||
defaultSecrets:
|
defaultSecrets:
|
||||||
- key: agentPassword
|
- key: agentPassword
|
||||||
- key: bouncerApiKey
|
- key: bouncerApiKey
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ spec:
|
|||||||
plugin:
|
plugin:
|
||||||
bouncer:
|
bouncer:
|
||||||
crowdsecLapiScheme: http
|
crowdsecLapiScheme: http
|
||||||
crowdsecLapiHost: crowdsec-lapi.crowdsec.svc.cluster.local:8080
|
crowdsecLapiHost: {{ if .centralLapiUrl }}{{ strings.TrimPrefix "http://" .centralLapiUrl }}{{ else }}crowdsec-lapi.crowdsec.svc.cluster.local:8080{{ end }}
|
||||||
crowdsecLapiKeyFile: /etc/traefik/crowdsec/api-key
|
crowdsecLapiKeyFile: /etc/traefik/crowdsec/api-key
|
||||||
crowdsecMode: stream
|
crowdsecMode: stream
|
||||||
updateIntervalSeconds: 15
|
updateIntervalSeconds: 15
|
||||||
@@ -48,7 +48,6 @@ spec:
|
|||||||
browserXssFilter: true
|
browserXssFilter: true
|
||||||
contentTypeNosniff: true
|
contentTypeNosniff: true
|
||||||
forceSTSHeader: true
|
forceSTSHeader: true
|
||||||
frameDeny: true
|
|
||||||
sslRedirect: true
|
sslRedirect: true
|
||||||
stsIncludeSubdomains: true
|
stsIncludeSubdomains: true
|
||||||
stsPreload: true
|
stsPreload: true
|
||||||
@@ -72,6 +71,7 @@ spec:
|
|||||||
X-Forwarded-Proto: https
|
X-Forwarded-Proto: https
|
||||||
customResponseHeaders:
|
customResponseHeaders:
|
||||||
Server: ""
|
Server: ""
|
||||||
|
X-Frame-Options: SAMEORIGIN
|
||||||
X-Robots-Tag: noindex,nofollow,nosnippet,noarchive,notranslate,noimageindex
|
X-Robots-Tag: noindex,nofollow,nosnippet,noarchive,notranslate,noimageindex
|
||||||
---
|
---
|
||||||
apiVersion: traefik.io/v1alpha1
|
apiVersion: traefik.io/v1alpha1
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
name: cryptpad
|
name: cryptpad
|
||||||
is: cryptpad
|
is: cryptpad
|
||||||
description: CryptPad is an end-to-end encrypted collaboration suite with documents, spreadsheets, kanban boards, and more.
|
description: CryptPad is an end-to-end encrypted collaboration suite with documents, spreadsheets, kanban boards, and more.
|
||||||
|
category: productivity
|
||||||
icon: https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/cryptpad.svg
|
icon: https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/cryptpad.svg
|
||||||
latest: "2024"
|
latest: "2024"
|
||||||
|
|||||||
25
cryptpad/notes.md
Normal file
25
cryptpad/notes.md
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
# CryptPad — Notes
|
||||||
|
|
||||||
|
## Config directory requires emptyDir + initContainer
|
||||||
|
|
||||||
|
CryptPad's `/cryptpad/config/` directory is in the image overlay filesystem and is not writable
|
||||||
|
by the container process even when running as root.
|
||||||
|
|
||||||
|
**Fix**: mount an `emptyDir` at `/cryptpad/config` and use an initContainer to pre-seed
|
||||||
|
`config.example.js` from the image into the emptyDir:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
initContainers:
|
||||||
|
- name: seed-config
|
||||||
|
image: cryptpad/cryptpad:version-X.Y.Z
|
||||||
|
command: [sh, -c, "cp /cryptpad/config/config.example.js /config-dest/config.example.js"]
|
||||||
|
volumeMounts:
|
||||||
|
- name: cryptpad-config
|
||||||
|
mountPath: /config-dest
|
||||||
|
volumes:
|
||||||
|
- name: cryptpad-config
|
||||||
|
emptyDir: {}
|
||||||
|
```
|
||||||
|
|
||||||
|
Set `CPAD_CONF=/cryptpad/config/config.js` — the startup script copies `config.example.js` to
|
||||||
|
`config.js` on first run if `config.js` doesn't exist.
|
||||||
@@ -22,7 +22,7 @@ spec:
|
|||||||
type: RuntimeDefault
|
type: RuntimeDefault
|
||||||
initContainers:
|
initContainers:
|
||||||
- name: seed-config
|
- name: seed-config
|
||||||
image: cryptpad/cryptpad:latest
|
image: cryptpad/cryptpad:version-2026.5.1
|
||||||
command:
|
command:
|
||||||
- sh
|
- sh
|
||||||
- -c
|
- -c
|
||||||
@@ -33,9 +33,18 @@ spec:
|
|||||||
volumeMounts:
|
volumeMounts:
|
||||||
- name: cryptpad-config
|
- name: cryptpad-config
|
||||||
mountPath: /config-dest
|
mountPath: /config-dest
|
||||||
|
securityContext:
|
||||||
|
runAsNonRoot: false
|
||||||
|
runAsUser: 0
|
||||||
|
allowPrivilegeEscalation: false
|
||||||
|
capabilities:
|
||||||
|
drop: [ALL]
|
||||||
|
readOnlyRootFilesystem: false
|
||||||
|
seccompProfile:
|
||||||
|
type: RuntimeDefault
|
||||||
containers:
|
containers:
|
||||||
- name: cryptpad
|
- name: cryptpad
|
||||||
image: cryptpad/cryptpad:latest
|
image: cryptpad/cryptpad:version-2026.5.1
|
||||||
ports:
|
ports:
|
||||||
- name: http
|
- name: http
|
||||||
containerPort: 3000
|
containerPort: 3000
|
||||||
@@ -80,7 +89,14 @@ spec:
|
|||||||
periodSeconds: 10
|
periodSeconds: 10
|
||||||
failureThreshold: 3
|
failureThreshold: 3
|
||||||
securityContext:
|
securityContext:
|
||||||
|
runAsNonRoot: false
|
||||||
|
runAsUser: 0
|
||||||
|
allowPrivilegeEscalation: false
|
||||||
|
capabilities:
|
||||||
|
drop: [ALL]
|
||||||
readOnlyRootFilesystem: false
|
readOnlyRootFilesystem: false
|
||||||
|
seccompProfile:
|
||||||
|
type: RuntimeDefault
|
||||||
volumes:
|
volumes:
|
||||||
- name: cryptpad-data
|
- name: cryptpad-data
|
||||||
persistentVolumeClaim:
|
persistentVolumeClaim:
|
||||||
|
|||||||
@@ -3,10 +3,6 @@ kind: Ingress
|
|||||||
metadata:
|
metadata:
|
||||||
name: cryptpad
|
name: cryptpad
|
||||||
namespace: cryptpad
|
namespace: cryptpad
|
||||||
annotations:
|
|
||||||
external-dns.alpha.kubernetes.io/target: {{ .externalDnsDomain }}
|
|
||||||
external-dns.alpha.kubernetes.io/cloudflare-proxied: "false"
|
|
||||||
external-dns.alpha.kubernetes.io/ttl: "60"
|
|
||||||
spec:
|
spec:
|
||||||
ingressClassName: traefik
|
ingressClassName: traefik
|
||||||
rules:
|
rules:
|
||||||
@@ -30,8 +26,3 @@ spec:
|
|||||||
name: cryptpad
|
name: cryptpad
|
||||||
port:
|
port:
|
||||||
number: 80
|
number: 80
|
||||||
tls:
|
|
||||||
- hosts:
|
|
||||||
- {{ .domain }}
|
|
||||||
- {{ .sandboxDomain }}
|
|
||||||
secretName: {{ .tlsSecretName }}
|
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
version: 2024.x-1
|
version: 2026.5.1-3
|
||||||
defaultConfig:
|
defaultConfig:
|
||||||
namespace: cryptpad
|
namespace: cryptpad
|
||||||
externalDnsDomain: '{{ .cloud.domain }}'
|
|
||||||
domain: cryptpad.{{ .cloud.domain }}
|
domain: cryptpad.{{ .cloud.domain }}
|
||||||
sandboxDomain: cryptpad-sandbox.{{ .cloud.domain }}
|
sandboxDomain: cryptpad-sandbox.{{ .cloud.domain }}
|
||||||
tlsSecretName: wildcard-wild-cloud-tls
|
|
||||||
storage: 2Gi
|
storage: 2Gi
|
||||||
defaultSecrets:
|
defaultSecrets:
|
||||||
- key: adminKey
|
- key: adminKey
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
name: decidim
|
name: decidim
|
||||||
is: decidim
|
is: decidim
|
||||||
description: Decidim is a participatory democracy framework for cities and organizations. Built in Ruby on Rails, it enables citizen participation through proposals, debates, and voting. Includes Sidekiq for background job processing.
|
description: Decidim is a participatory democracy framework for cities and organizations. Built in Ruby on Rails, it enables citizen participation through proposals, debates, and voting. Includes Sidekiq for background job processing.
|
||||||
|
category: community
|
||||||
icon: https://raw.githubusercontent.com/decidim/decidim/develop/logo.svg
|
icon: https://raw.githubusercontent.com/decidim/decidim/develop/logo.svg
|
||||||
latest: "0"
|
latest: "0"
|
||||||
|
|||||||
@@ -4,15 +4,8 @@ kind: Ingress
|
|||||||
metadata:
|
metadata:
|
||||||
name: decidim
|
name: decidim
|
||||||
namespace: decidim
|
namespace: decidim
|
||||||
annotations:
|
|
||||||
external-dns.alpha.kubernetes.io/target: {{ .externalDnsDomain }}
|
|
||||||
external-dns.alpha.kubernetes.io/cloudflare-proxied: "false"
|
|
||||||
spec:
|
spec:
|
||||||
ingressClassName: traefik
|
ingressClassName: traefik
|
||||||
tls:
|
|
||||||
- hosts:
|
|
||||||
- {{ .domain }}
|
|
||||||
secretName: {{ .tlsSecretName }}
|
|
||||||
rules:
|
rules:
|
||||||
- host: {{ .domain }}
|
- host: {{ .domain }}
|
||||||
http:
|
http:
|
||||||
|
|||||||
@@ -1,16 +1,14 @@
|
|||||||
version: 0.31.0-2
|
version: 0.31.0-4
|
||||||
requires:
|
requires:
|
||||||
- name: postgres
|
- name: postgres
|
||||||
- name: redis
|
- name: redis
|
||||||
- name: smtp
|
- name: smtp
|
||||||
defaultConfig:
|
defaultConfig:
|
||||||
namespace: decidim
|
namespace: decidim
|
||||||
externalDnsDomain: '{{ .cloud.domain }}'
|
|
||||||
storage: 2Gi
|
storage: 2Gi
|
||||||
systemAdminEmail: '{{ .operator.email }}'
|
systemAdminEmail: '{{ .operator.email }}'
|
||||||
siteName: 'Decidim'
|
siteName: Decidim
|
||||||
domain: decidim.{{ .cloud.domain }}
|
domain: decidim.{{ .cloud.domain }}
|
||||||
tlsSecretName: wildcard-wild-cloud-tls
|
|
||||||
db:
|
db:
|
||||||
host: '{{ .apps.postgres.host }}'
|
host: '{{ .apps.postgres.host }}'
|
||||||
port: '{{ .apps.postgres.port }}'
|
port: '{{ .apps.postgres.port }}'
|
||||||
@@ -27,13 +25,14 @@ defaultConfig:
|
|||||||
tls: '{{ .apps.smtp.tls }}'
|
tls: '{{ .apps.smtp.tls }}'
|
||||||
startTls: '{{ .apps.smtp.startTls }}'
|
startTls: '{{ .apps.smtp.startTls }}'
|
||||||
defaultSecrets:
|
defaultSecrets:
|
||||||
- key: systemAdminPassword
|
- key: systemAdminPassword
|
||||||
- key: secretKeyBase
|
- key: secretKeyBase
|
||||||
default: "{{ random.AlphaNum 128 }}"
|
default: '{{ random.AlphaNum 128 }}'
|
||||||
- key: dbPassword
|
- key: dbPassword
|
||||||
- key: dbUrl
|
- key: dbUrl
|
||||||
default: "postgres://{{ .app.db.user }}:{{ .secrets.dbPassword }}@{{ .app.db.host }}:{{ .app.db.port }}/{{ .app.db.name }}"
|
default: postgres://{{ .app.db.user }}:{{ .secrets.dbPassword }}@{{ .app.db.host
|
||||||
|
}}:{{ .app.db.port }}/{{ .app.db.name }}?sslmode=disable
|
||||||
requiredSecrets:
|
requiredSecrets:
|
||||||
- postgres.password
|
- postgres.password
|
||||||
- redis.password
|
- redis.password
|
||||||
- smtp.password
|
- smtp.password
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
name: discourse
|
name: discourse
|
||||||
is: discourse
|
is: discourse
|
||||||
description: Discourse is a modern, open-source discussion platform designed for online communities and forums.
|
description: Discourse is a modern, open-source discussion platform designed for online communities and forums.
|
||||||
|
category: community
|
||||||
icon: https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/discourse.svg
|
icon: https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/discourse.svg
|
||||||
latest: "3"
|
latest: "3"
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ spec:
|
|||||||
END IF;
|
END IF;
|
||||||
END
|
END
|
||||||
\$\$;
|
\$\$;
|
||||||
|
ALTER USER $DISCOURSE_DB_USER WITH PASSWORD '$DISCOURSE_DB_PASSWORD';
|
||||||
GRANT ALL PRIVILEGES ON DATABASE $DISCOURSE_DB_NAME TO $DISCOURSE_DB_USER;
|
GRANT ALL PRIVILEGES ON DATABASE $DISCOURSE_DB_NAME TO $DISCOURSE_DB_USER;
|
||||||
GRANT ALL ON SCHEMA public TO $DISCOURSE_DB_USER;
|
GRANT ALL ON SCHEMA public TO $DISCOURSE_DB_USER;
|
||||||
GRANT USAGE ON SCHEMA public TO $DISCOURSE_DB_USER;
|
GRANT USAGE ON SCHEMA public TO $DISCOURSE_DB_USER;
|
||||||
|
|||||||
@@ -5,10 +5,8 @@ kind: Ingress
|
|||||||
metadata:
|
metadata:
|
||||||
name: discourse
|
name: discourse
|
||||||
namespace: "{{ .namespace }}"
|
namespace: "{{ .namespace }}"
|
||||||
annotations:
|
|
||||||
external-dns.alpha.kubernetes.io/cloudflare-proxied: "false"
|
|
||||||
external-dns.alpha.kubernetes.io/target: "{{ .externalDnsDomain }}"
|
|
||||||
spec:
|
spec:
|
||||||
|
ingressClassName: traefik
|
||||||
rules:
|
rules:
|
||||||
- host: "{{ .domain }}"
|
- host: "{{ .domain }}"
|
||||||
http:
|
http:
|
||||||
@@ -20,7 +18,3 @@ spec:
|
|||||||
name: discourse
|
name: discourse
|
||||||
port:
|
port:
|
||||||
name: http
|
name: http
|
||||||
tls:
|
|
||||||
- hosts:
|
|
||||||
- "{{ .domain }}"
|
|
||||||
secretName: wildcard-external-wild-cloud-tls
|
|
||||||
|
|||||||
@@ -1,17 +1,15 @@
|
|||||||
version: 3.5.3-3
|
version: 3.5.3-5
|
||||||
requires:
|
requires:
|
||||||
- name: postgres
|
- name: postgres
|
||||||
- name: redis
|
- name: redis
|
||||||
- name: smtp
|
- name: smtp
|
||||||
defaultConfig:
|
defaultConfig:
|
||||||
namespace: discourse
|
namespace: discourse
|
||||||
externalDnsDomain: '{{ .cloud.domain }}'
|
|
||||||
storage: 2Gi
|
storage: 2Gi
|
||||||
adminEmail: '{{ .operator.email }}'
|
adminEmail: '{{ .operator.email }}'
|
||||||
adminUsername: admin
|
adminUsername: admin
|
||||||
siteName: 'Community'
|
siteName: Community
|
||||||
domain: discourse.{{ .cloud.domain }}
|
domain: discourse.{{ .cloud.domain }}
|
||||||
tlsSecretName: wildcard-wild-cloud-tls
|
|
||||||
db:
|
db:
|
||||||
host: '{{ .apps.postgres.host }}'
|
host: '{{ .apps.postgres.host }}'
|
||||||
port: '{{ .apps.postgres.port }}'
|
port: '{{ .apps.postgres.port }}'
|
||||||
@@ -28,13 +26,14 @@ defaultConfig:
|
|||||||
tls: '{{ .apps.smtp.tls }}'
|
tls: '{{ .apps.smtp.tls }}'
|
||||||
startTls: '{{ .apps.smtp.startTls }}'
|
startTls: '{{ .apps.smtp.startTls }}'
|
||||||
defaultSecrets:
|
defaultSecrets:
|
||||||
- key: adminPassword
|
- key: adminPassword
|
||||||
- key: secretKeyBase
|
- key: secretKeyBase
|
||||||
default: "{{ random.Hex 32 }}"
|
default: '{{ random.Hex 32 }}'
|
||||||
- key: smtpPassword
|
- key: smtpPassword
|
||||||
- key: dbPassword
|
- key: dbPassword
|
||||||
- key: dbUrl
|
- key: dbUrl
|
||||||
default: "postgres://{{ .app.db.user }}:{{ .secrets.dbPassword }}@{{ .app.db.host }}:{{ .app.db.port }}/{{ .app.db.name }}?sslmode=disable"
|
default: postgres://{{ .app.db.user }}:{{ .secrets.dbPassword }}@{{ .app.db.host
|
||||||
|
}}:{{ .app.db.port }}/{{ .app.db.name }}?sslmode=disable
|
||||||
requiredSecrets:
|
requiredSecrets:
|
||||||
- postgres.password
|
- postgres.password
|
||||||
- redis.password
|
- redis.password
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
name: docker-registry
|
name: docker-registry
|
||||||
is: docker-registry
|
is: docker-registry
|
||||||
description: Private Docker image registry for cluster
|
description: Private Docker image registry for cluster
|
||||||
category: infrastructure
|
category: services
|
||||||
|
icon: https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/docker.svg
|
||||||
latest: "3"
|
latest: "3"
|
||||||
|
|
||||||
|
ignoreRules:
|
||||||
|
- WC-DNS # internal registry; external DNS not needed
|
||||||
|
|||||||
@@ -11,10 +11,7 @@ spec:
|
|||||||
matchLabels:
|
matchLabels:
|
||||||
app: docker-registry
|
app: docker-registry
|
||||||
strategy:
|
strategy:
|
||||||
rollingUpdate:
|
type: Recreate
|
||||||
maxSurge: 0
|
|
||||||
maxUnavailable: 1
|
|
||||||
type: RollingUpdate
|
|
||||||
template:
|
template:
|
||||||
metadata:
|
metadata:
|
||||||
labels:
|
labels:
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ kind: Ingress
|
|||||||
metadata:
|
metadata:
|
||||||
name: docker-registry
|
name: docker-registry
|
||||||
spec:
|
spec:
|
||||||
|
ingressClassName: traefik
|
||||||
rules:
|
rules:
|
||||||
- host: {{ .host }}
|
- host: {{ .host }}
|
||||||
http:
|
http:
|
||||||
@@ -14,7 +15,3 @@ spec:
|
|||||||
name: docker-registry
|
name: docker-registry
|
||||||
port:
|
port:
|
||||||
number: 5000
|
number: 5000
|
||||||
tls:
|
|
||||||
- hosts:
|
|
||||||
- {{ .host }}
|
|
||||||
secretName: wildcard-internal-wild-cloud-tls
|
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ labels:
|
|||||||
- includeSelectors: true
|
- includeSelectors: true
|
||||||
pairs:
|
pairs:
|
||||||
app: docker-registry
|
app: docker-registry
|
||||||
managedBy: wild-cloud
|
managedBy: kustomize
|
||||||
|
partOf: wild-cloud
|
||||||
resources:
|
resources:
|
||||||
- deployment.yaml
|
- deployment.yaml
|
||||||
- ingress.yaml
|
- ingress.yaml
|
||||||
|
|||||||
@@ -1,8 +1,5 @@
|
|||||||
version: "3.0.0"
|
version: 3.0.0-2
|
||||||
requires:
|
|
||||||
- name: traefik
|
|
||||||
- name: cert-manager
|
|
||||||
defaultConfig:
|
defaultConfig:
|
||||||
namespace: docker-registry
|
namespace: docker-registry
|
||||||
host: "registry.{{ .cloud.internalDomain }}"
|
host: registry.{{ .cloud.internalDomain }}
|
||||||
storage: 5Gi
|
storage: 5Gi
|
||||||
|
|||||||
78
docs/database.md
Normal file
78
docs/database.md
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
# Database Patterns
|
||||||
|
|
||||||
|
## PostgreSQL
|
||||||
|
|
||||||
|
### Always use `?sslmode=disable` `[WC-SSL]`
|
||||||
|
|
||||||
|
Wild Cloud's internal PostgreSQL has no SSL configured. Without `?sslmode=disable`, connections fail with "The server does not support SSL connections."
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
defaultSecrets:
|
||||||
|
- key: dbUrl
|
||||||
|
default: "postgresql://{{ .app.db.user }}:{{ .secrets.dbPassword }}@{{ .app.db.host }}:{{ .app.db.port }}/{{ .app.db.name }}?sslmode=disable"
|
||||||
|
```
|
||||||
|
|
||||||
|
Also set `PGSSLMODE=disable` for apps that use libpq directly.
|
||||||
|
|
||||||
|
### db-init-job
|
||||||
|
|
||||||
|
Include a `db-init-job.yaml` for every app that uses PostgreSQL. See `immich`, `gitea`, or `openproject` for reference implementations. The job must:
|
||||||
|
|
||||||
|
- Create the database if it doesn't exist
|
||||||
|
- Create/update the user with correct credentials
|
||||||
|
- Grant permissions
|
||||||
|
- Install required extensions (`vector`, `pg_trgm`, etc.)
|
||||||
|
- Use `restartPolicy: OnFailure` and `runAsUser: 999`
|
||||||
|
- Be idempotent — safe to re-run after redeploy
|
||||||
|
|
||||||
|
### Database URL secrets
|
||||||
|
|
||||||
|
When an app needs a connection URL with embedded credentials, use a `dbUrl` secret — do not construct URLs inline:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# Wrong: Kustomize cannot do runtime env var substitution
|
||||||
|
- name: DB_URL
|
||||||
|
value: "postgresql://user:$(DB_PASSWORD)@host/db"
|
||||||
|
|
||||||
|
# Correct: use a secret with the full URL
|
||||||
|
- name: DB_URL
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: myapp-secrets
|
||||||
|
key: dbUrl
|
||||||
|
```
|
||||||
|
|
||||||
|
## MySQL
|
||||||
|
|
||||||
|
### db-init user password idempotency `[WC-DBIN]`
|
||||||
|
|
||||||
|
`CREATE USER IF NOT EXISTS` only sets the password on first creation. On redeploy against an existing database the password stays stale, causing "Access denied".
|
||||||
|
|
||||||
|
Always follow `CREATE USER` with `ALTER USER`:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE USER IF NOT EXISTS '${DB_USERNAME}'@'%' IDENTIFIED BY '${DB_PASSWORD}';
|
||||||
|
ALTER USER '${DB_USERNAME}'@'%' IDENTIFIED BY '${DB_PASSWORD}';
|
||||||
|
GRANT ALL PRIVILEGES ON ${DB_DATABASE_NAME}.* TO '${DB_USERNAME}'@'%';
|
||||||
|
FLUSH PRIVILEGES;
|
||||||
|
```
|
||||||
|
|
||||||
|
`ALTER USER` is a no-op when the user was just created — it is safe to always include it.
|
||||||
|
|
||||||
|
### Required secrets reference
|
||||||
|
|
||||||
|
MySQL secrets are copied into `<app>-secrets`, not `mysql-secrets`. Reference them as:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
secretKeyRef:
|
||||||
|
name: myapp-secrets
|
||||||
|
key: mysql.rootPassword # not mysql-secrets / rootPassword
|
||||||
|
```
|
||||||
|
|
||||||
|
## Database env var naming
|
||||||
|
|
||||||
|
Name database-related env vars so the backup system can identify them:
|
||||||
|
|
||||||
|
- **Database name**: include `DATABASE`, `DB_NAME`, `DBNAME`, or `__DATABASE`
|
||||||
|
- **Database URLs**: value must contain `://`
|
||||||
|
- **Usernames**: include `USER` — these are not patched on restore
|
||||||
44
docs/jvm.md
Normal file
44
docs/jvm.md
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
# JVM (Spring Boot / Scala / Kotlin)
|
||||||
|
|
||||||
|
## Startup delay
|
||||||
|
|
||||||
|
Spring Boot loads the full application context before accepting HTTP. Cold starts typically take 60–120 seconds depending on the number of beans and auto-configuration classes.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
livenessProbe:
|
||||||
|
tcpSocket:
|
||||||
|
port: 8080
|
||||||
|
initialDelaySeconds: 120
|
||||||
|
periodSeconds: 30
|
||||||
|
failureThreshold: 6
|
||||||
|
readinessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /actuator/health
|
||||||
|
port: 8080
|
||||||
|
initialDelaySeconds: 90
|
||||||
|
periodSeconds: 15
|
||||||
|
failureThreshold: 3
|
||||||
|
```
|
||||||
|
|
||||||
|
If the app exposes a Spring Actuator health endpoint, prefer it for readiness over the root path.
|
||||||
|
|
||||||
|
## Heap limit
|
||||||
|
|
||||||
|
Set `-Xmx` to keep the heap within the container memory limit. The JVM does not automatically respect cgroup limits on older JDK versions.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
env:
|
||||||
|
- name: JAVA_OPTS
|
||||||
|
value: "-Xmx512m -Xms256m"
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
memory: 768Mi
|
||||||
|
requests:
|
||||||
|
memory: 512Mi
|
||||||
|
```
|
||||||
|
|
||||||
|
Keep `-Xmx` 25–33% below the container limit to leave room for off-heap memory (metaspace, direct buffers, GC overhead).
|
||||||
|
|
||||||
|
## Resources
|
||||||
|
|
||||||
|
512Mi–1Gi request is typical. JVM startup can spike higher — use `requests` to reserve and `limits` to cap.
|
||||||
44
docs/linuxserver.md
Normal file
44
docs/linuxserver.md
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
# linuxserver.io / s6-overlay images
|
||||||
|
|
||||||
|
## Security context
|
||||||
|
|
||||||
|
Images using the s6-overlay init system (all linuxserver.io images) must run as root and need full capabilities to switch to their internal `abc` user. **Do not drop capabilities** — s6-overlay's user-switching will fail.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
spec:
|
||||||
|
template:
|
||||||
|
spec:
|
||||||
|
securityContext: # pod level
|
||||||
|
runAsNonRoot: false
|
||||||
|
runAsUser: 0
|
||||||
|
seccompProfile:
|
||||||
|
type: RuntimeDefault
|
||||||
|
containers:
|
||||||
|
- name: app
|
||||||
|
securityContext: # container level — only this field
|
||||||
|
readOnlyRootFilesystem: false
|
||||||
|
```
|
||||||
|
|
||||||
|
Do NOT set `allowPrivilegeEscalation: false` or `capabilities.drop: ALL` for these images.
|
||||||
|
|
||||||
|
Suppress `WC-SC-POD` and `WC-SC-CTR` in `app.yaml`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
ignoreRules:
|
||||||
|
- WC-SC-POD # s6-overlay requires root
|
||||||
|
- WC-SC-CTR # s6-overlay requires full capabilities
|
||||||
|
```
|
||||||
|
|
||||||
|
## PUID / PGID
|
||||||
|
|
||||||
|
linuxserver.io images accept `PUID` and `PGID` env vars to set the internal `abc` user's UID/GID. Set them explicitly for consistent file ownership on PVCs:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
env:
|
||||||
|
- name: PUID
|
||||||
|
value: "1000"
|
||||||
|
- name: PGID
|
||||||
|
value: "1000"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Affected apps**: BookStack, and any app using a linuxserver.io image.
|
||||||
54
docs/nginx.md
Normal file
54
docs/nginx.md
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
# nginx-based images
|
||||||
|
|
||||||
|
## Security context
|
||||||
|
|
||||||
|
nginx binds to port 80 (or 443) and needs to `chown` files before dropping to `www-data`. It must run as root but can drop most capabilities after startup.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
spec:
|
||||||
|
template:
|
||||||
|
spec:
|
||||||
|
securityContext: # pod level
|
||||||
|
runAsNonRoot: false
|
||||||
|
runAsUser: 0
|
||||||
|
seccompProfile:
|
||||||
|
type: RuntimeDefault
|
||||||
|
containers:
|
||||||
|
- name: app
|
||||||
|
securityContext: # container level
|
||||||
|
allowPrivilegeEscalation: false
|
||||||
|
capabilities:
|
||||||
|
drop: [ALL]
|
||||||
|
add: [CHOWN, SETUID, SETGID, NET_BIND_SERVICE]
|
||||||
|
readOnlyRootFilesystem: false
|
||||||
|
```
|
||||||
|
|
||||||
|
Add `NET_BIND_SERVICE` only if the container binds to a port below 1024. Drop it if the app uses a high port (8080, 3000, etc.).
|
||||||
|
|
||||||
|
## Docker DNS resolver (`WC-DKRDNS`)
|
||||||
|
|
||||||
|
nginx proxy configs shipped for Docker often contain:
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
resolver 127.0.0.11 valid=3s;
|
||||||
|
set $backend app-backend:8000;
|
||||||
|
proxy_pass http://$backend$request_uri;
|
||||||
|
```
|
||||||
|
|
||||||
|
`127.0.0.11` is Docker's embedded DNS resolver — it does not exist in Kubernetes. This fails with `send() failed (111: Connection refused) while resolving`.
|
||||||
|
|
||||||
|
There's a second trap: **any nginx variable anywhere in the `proxy_pass` URL** (even `$request_uri` in the path) forces runtime DNS resolution and requires a `resolver` directive. Removing the resolver line but keeping `proxy_pass http://backend:8000$request_uri;` still fails with `no resolver defined to resolve backend`.
|
||||||
|
|
||||||
|
**Fix**: override the nginx config template via a ConfigMap mounted with `subPath`, and remove the variable from `proxy_pass` entirely:
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
# Instead of:
|
||||||
|
resolver 127.0.0.11 valid=3s;
|
||||||
|
set $backend ${BACKEND};
|
||||||
|
proxy_pass http://$backend$request_uri;
|
||||||
|
|
||||||
|
# Use:
|
||||||
|
proxy_pass http://${BACKEND};
|
||||||
|
```
|
||||||
|
|
||||||
|
With no nginx variable in `proxy_pass`, nginx resolves the hostname at startup using the pod's `/etc/resolv.conf` (which points to CoreDNS). The full request URI is still forwarded automatically in a regex `location ~` block. Mount the ConfigMap with `subPath` to override just the template file without replacing the whole directory.
|
||||||
20
docs/nodejs.md
Normal file
20
docs/nodejs.md
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
# Node.js
|
||||||
|
|
||||||
|
## Memory limits
|
||||||
|
|
||||||
|
Node.js apps frequently OOM-crash at the default 512Mi limit. Set a higher limit and cap the V8 heap so it stays under it:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
memory: 1Gi
|
||||||
|
requests:
|
||||||
|
memory: 512Mi
|
||||||
|
env:
|
||||||
|
- name: NODE_OPTIONS
|
||||||
|
value: "--max-old-space-size=768"
|
||||||
|
```
|
||||||
|
|
||||||
|
`--max-old-space-size` is in MiB. Keep it 25–33% below the container limit so the process has headroom for non-heap memory (buffers, native modules, etc.).
|
||||||
|
|
||||||
|
**Applies to**: NocoDB, Outline, Gitea (web), and any Electron/Express/Next.js app.
|
||||||
31
docs/php.md
Normal file
31
docs/php.md
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
# PHP (Laravel / Symfony)
|
||||||
|
|
||||||
|
## Startup delay
|
||||||
|
|
||||||
|
Laravel's startup sequence (autoload optimization, key generation, migrations, package discovery) commonly takes 2–3 minutes on cluster restart or first boot. The default `initialDelaySeconds: 60` is too short — the liveness probe fires before the app is ready, kills the container, and a restart loop begins.
|
||||||
|
|
||||||
|
**Symptom**: pod shows `Running` for ~2 minutes, then gets `Killing` due to liveness probe failure, then a new pod starts the same cycle.
|
||||||
|
|
||||||
|
Use a `tcpSocket` liveness probe (avoids the Host header issue) with an extended initial delay:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
livenessProbe:
|
||||||
|
tcpSocket:
|
||||||
|
port: 8080
|
||||||
|
initialDelaySeconds: 120
|
||||||
|
periodSeconds: 30
|
||||||
|
failureThreshold: 6
|
||||||
|
readinessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /
|
||||||
|
port: 8080
|
||||||
|
initialDelaySeconds: 60
|
||||||
|
periodSeconds: 15
|
||||||
|
failureThreshold: 3
|
||||||
|
```
|
||||||
|
|
||||||
|
Symfony has the same behavior. Adjust `initialDelaySeconds` up if migrations are slow.
|
||||||
|
|
||||||
|
## Resources
|
||||||
|
|
||||||
|
256–512Mi request is typical for a PHP-FPM or Artisan-served app. Workers (queue:work, horizon) are separate processes — give them their own Deployment and 256–512Mi each.
|
||||||
65
docs/python.md
Normal file
65
docs/python.md
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
# Python (Django / FastAPI)
|
||||||
|
|
||||||
|
## Health probe host header
|
||||||
|
|
||||||
|
Kubernetes sends probe requests directly to the pod IP. Django's `ALLOWED_HOSTS` rejects requests without a matching `Host` header, causing every probe to return 400 and the pod to restart. Add a `Host` header to all HTTP probes:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
livenessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /
|
||||||
|
port: 8000
|
||||||
|
httpHeaders:
|
||||||
|
- name: Host
|
||||||
|
value: "{{ .domain }}"
|
||||||
|
readinessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /
|
||||||
|
port: 8000
|
||||||
|
httpHeaders:
|
||||||
|
- name: Host
|
||||||
|
value: "{{ .domain }}"
|
||||||
|
```
|
||||||
|
|
||||||
|
FastAPI and Flask apps with explicit host validation have the same issue.
|
||||||
|
|
||||||
|
## Startup delay (apps that run migrations)
|
||||||
|
|
||||||
|
Django apps that run `migrate` on startup need extra time before the liveness probe fires. The default 60-second delay is too short for large migration sets.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
livenessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /
|
||||||
|
port: 8000
|
||||||
|
httpHeaders:
|
||||||
|
- name: Host
|
||||||
|
value: "{{ .domain }}"
|
||||||
|
initialDelaySeconds: 90
|
||||||
|
periodSeconds: 30
|
||||||
|
failureThreshold: 6
|
||||||
|
readinessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /
|
||||||
|
port: 8000
|
||||||
|
httpHeaders:
|
||||||
|
- name: Host
|
||||||
|
value: "{{ .domain }}"
|
||||||
|
initialDelaySeconds: 60
|
||||||
|
periodSeconds: 15
|
||||||
|
failureThreshold: 3
|
||||||
|
```
|
||||||
|
|
||||||
|
## Celery workers
|
||||||
|
|
||||||
|
Celery workers are full Python processes. Size them at 256–512Mi and deploy them as a separate Deployment from the web process. They do not need an HTTP probe — use a `tcpSocket` or `exec` check if a liveness probe is needed at all.
|
||||||
|
|
||||||
|
## Non-interactive superuser creation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kubectl exec -n <ns> <pod> -- \
|
||||||
|
env DJANGO_SUPERUSER_PASSWORD="${PASSWORD}" \
|
||||||
|
python manage.py createsuperuser --email "${EMAIL}" --noinput
|
||||||
|
```
|
||||||
|
|
||||||
|
`--noinput` reads the password from `DJANGO_SUPERUSER_PASSWORD`.
|
||||||
33
docs/redis.md
Normal file
33
docs/redis.md
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
# Redis
|
||||||
|
|
||||||
|
## Authenticated Redis URL
|
||||||
|
|
||||||
|
Wild Cloud's Redis requires a password. Apps that take a Redis URL must embed the password. Use Kubernetes env var expansion so the password isn't hardcoded:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
requiredSecrets:
|
||||||
|
- redis.password
|
||||||
|
|
||||||
|
# In deployment env:
|
||||||
|
- name: REDIS_PASSWORD
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: myapp-secrets
|
||||||
|
key: redis.password
|
||||||
|
- name: REDIS_URL
|
||||||
|
value: "redis://:$(REDIS_PASSWORD)@{{ .redis.host }}:6379"
|
||||||
|
```
|
||||||
|
|
||||||
|
`$(REDIS_PASSWORD)` is evaluated by Kubernetes at pod start from other env vars in the same container spec.
|
||||||
|
|
||||||
|
## Apps that don't support Redis passwords
|
||||||
|
|
||||||
|
Some apps only accept `REDIS_HOST` and `REDIS_PORT` with no password option (e.g. Ushahidi). Deploy a dedicated unauthenticated Redis sidecar for those apps and remove `redis` from `requires` in `manifest.yaml`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- name: redis
|
||||||
|
image: redis:7-alpine
|
||||||
|
args: ["--save", ""] # disable persistence
|
||||||
|
```
|
||||||
|
|
||||||
|
Point the app's `REDIS_HOST` at the sidecar's service name.
|
||||||
32
docs/ruby.md
Normal file
32
docs/ruby.md
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
# Ruby (Rails)
|
||||||
|
|
||||||
|
## Startup delay
|
||||||
|
|
||||||
|
Rails startup (bundle exec, asset precompilation, initializer chain) typically takes 30–90 seconds. Extend the liveness probe delay to avoid restart loops:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
livenessProbe:
|
||||||
|
tcpSocket:
|
||||||
|
port: 3000
|
||||||
|
initialDelaySeconds: 90
|
||||||
|
periodSeconds: 30
|
||||||
|
failureThreshold: 6
|
||||||
|
readinessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /
|
||||||
|
port: 3000
|
||||||
|
initialDelaySeconds: 60
|
||||||
|
periodSeconds: 15
|
||||||
|
failureThreshold: 3
|
||||||
|
```
|
||||||
|
|
||||||
|
## Sidekiq workers
|
||||||
|
|
||||||
|
Sidekiq is a full Ruby process, not a thread pool. Deploy it as a separate Deployment from the web process with 256–512Mi memory. It does not need an HTTP liveness probe — omit it or use a simple `exec` check.
|
||||||
|
|
||||||
|
## Non-interactive rails console / rake tasks
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kubectl exec -n <ns> <pod> -- bundle exec rake db:migrate
|
||||||
|
kubectl exec -n <ns> <pod> -- bundle exec rails runner "User.create!(...)"
|
||||||
|
```
|
||||||
45
docs/scripts.md
Normal file
45
docs/scripts.md
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
# Post-Deploy Scripts
|
||||||
|
|
||||||
|
Some apps require a management command after first deploy to create an admin account, register a node, or invite the first user. Package these as shell scripts in `scripts/` alongside the kustomize files.
|
||||||
|
|
||||||
|
## Registering scripts
|
||||||
|
|
||||||
|
Register every script in `manifest.yaml` — the web UI shows a button with a parameter form for each one:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
scripts:
|
||||||
|
- name: create-superuser
|
||||||
|
path: scripts/create-superuser.sh
|
||||||
|
description: "Create the initial admin account."
|
||||||
|
params:
|
||||||
|
- name: EMAIL
|
||||||
|
required: true
|
||||||
|
- name: PASSWORD
|
||||||
|
description: Leave blank to generate a random one
|
||||||
|
```
|
||||||
|
|
||||||
|
## Script conventions
|
||||||
|
|
||||||
|
Follow `synapse/versions/v1/scripts/create-user.sh` as the reference implementation:
|
||||||
|
|
||||||
|
- Require `KUBECONFIG`, `WILD_INSTANCE`, and `WILD_API_DATA_DIR`; exit with a clear error if missing
|
||||||
|
- Read `namespace` from `config.yaml` via `yq` — never hardcode it
|
||||||
|
- Auto-generate passwords with `openssl rand` if `PASSWORD` is not supplied
|
||||||
|
- Find the running pod by label — never hardcode a pod name
|
||||||
|
- Print credentials at the end with a "save this — it won't be shown again" warning
|
||||||
|
|
||||||
|
## Common commands
|
||||||
|
|
||||||
|
**Django non-interactive superuser**:
|
||||||
|
```bash
|
||||||
|
kubectl exec -n <ns> <pod> -- \
|
||||||
|
env DJANGO_SUPERUSER_PASSWORD="${PASSWORD}" \
|
||||||
|
python manage.py createsuperuser --email "${EMAIL}" --noinput
|
||||||
|
```
|
||||||
|
|
||||||
|
**Rails**:
|
||||||
|
```bash
|
||||||
|
kubectl exec -n <ns> <pod> -- bundle exec rake db:migrate
|
||||||
|
```
|
||||||
|
|
||||||
|
**Affected apps**: Eventyay, Synapse, Headscale.
|
||||||
47
docs/traefik.md
Normal file
47
docs/traefik.md
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
# Traefik
|
||||||
|
|
||||||
|
## "No available server" after redeploy `[WC-SEL]`
|
||||||
|
|
||||||
|
If a Deployment was originally created without kustomize labels in its selector, re-deploying cannot fix it — `spec.selector` is immutable once set. The Service selector updates (it's mutable) but pods won't match it, leaving the Service with no endpoints.
|
||||||
|
|
||||||
|
**Symptom**: Traefik returns "No available server" even though pods are Running.
|
||||||
|
|
||||||
|
**Diagnosis**:
|
||||||
|
```bash
|
||||||
|
kubectl get endpoints <app> -n <namespace>
|
||||||
|
# shows <none> — pods aren't matching the service
|
||||||
|
kubectl get pods -n <namespace> --show-labels
|
||||||
|
# pod labels don't include app: <name> / managedBy: kustomize / partOf: wild-cloud
|
||||||
|
```
|
||||||
|
|
||||||
|
**Fix**: delete the Deployment and re-deploy:
|
||||||
|
```bash
|
||||||
|
kubectl delete deployment <name> -n <ns>
|
||||||
|
wild app deploy <app>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Same-origin iframes
|
||||||
|
|
||||||
|
The global Traefik `security-headers` middleware sets `X-Frame-Options: SAMEORIGIN`. Apps that embed their own sub-pages in iframes (e.g. Etherpad's pad editor) work correctly with this setting.
|
||||||
|
|
||||||
|
`DENY` was the previous setting and broke same-origin iframes. If you see:
|
||||||
|
```
|
||||||
|
Blocked a frame with origin "https://..." from accessing a cross-origin frame
|
||||||
|
```
|
||||||
|
the app is likely creating a same-origin iframe that a stale `DENY` header is blocking. Verify the cluster's Traefik middleware is using `SAMEORIGIN`.
|
||||||
|
|
||||||
|
**Note**: route-level Traefik middlewares run before entrypoint middlewares on the response path. A per-app middleware cannot override the global one.
|
||||||
|
|
||||||
|
## TLS-terminating reverse proxy (DISABLE_HTTPS)
|
||||||
|
|
||||||
|
Some apps (e.g. Zulip) redirect port 80 → HTTPS internally. When Traefik terminates TLS and forwards plain HTTP, this causes an infinite redirect loop.
|
||||||
|
|
||||||
|
Set these env vars to disable the internal redirect and trust the forwarded proto:
|
||||||
|
```yaml
|
||||||
|
- name: DISABLE_HTTPS
|
||||||
|
value: "true"
|
||||||
|
- name: LOADBALANCER_IPS
|
||||||
|
value: "10.244.0.0/16" # Kubernetes pod CIDR (Flannel default)
|
||||||
|
```
|
||||||
|
|
||||||
|
Also update liveness/readiness probes from HTTPS port 443 to HTTP port 80.
|
||||||
@@ -1,7 +1,10 @@
|
|||||||
name: e2e-test-app
|
name: e2e-test-app
|
||||||
is: e2e-test-app
|
is: e2e-test-app
|
||||||
description: End-to-end test application for automated integration testing. Includes PVC and PostgreSQL dependency to exercise all backup strategies.
|
description: End-to-end test application for automated integration testing. Includes PVC and PostgreSQL dependency to exercise all backup strategies.
|
||||||
|
category: services
|
||||||
latest: "2"
|
latest: "2"
|
||||||
|
ignoreRules:
|
||||||
|
- WC-ICON # test/QA app; not user-facing
|
||||||
upgrade:
|
upgrade:
|
||||||
from:
|
from:
|
||||||
- version: ">=1.0.0"
|
- version: ">=1.0.0"
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
version: 1.0.0-1
|
version: 1.0.0-2
|
||||||
requires:
|
requires:
|
||||||
- name: postgres
|
- name: postgres
|
||||||
defaultConfig:
|
defaultConfig:
|
||||||
namespace: e2e-test-app
|
namespace: e2e-test-app
|
||||||
domain: e2e-test-app.{{ .cloud.domain }}
|
domain: e2e-test-app.{{ .cloud.domain }}
|
||||||
externalDnsDomain: '{{ .cloud.domain }}'
|
|
||||||
tlsSecretName: wildcard-wild-cloud-tls
|
|
||||||
storage: 1Gi
|
storage: 1Gi
|
||||||
db:
|
db:
|
||||||
host: '{{ .apps.postgres.host }}'
|
host: '{{ .apps.postgres.host }}'
|
||||||
@@ -13,8 +11,9 @@ defaultConfig:
|
|||||||
name: e2e_test_app
|
name: e2e_test_app
|
||||||
user: e2e_test_app
|
user: e2e_test_app
|
||||||
defaultSecrets:
|
defaultSecrets:
|
||||||
- key: dbPassword
|
- key: dbPassword
|
||||||
- key: dbUrl
|
- key: dbUrl
|
||||||
default: "postgres://{{ .app.db.user }}:{{ .secrets.dbPassword }}@{{ .app.db.host }}:{{ .app.db.port }}/{{ .app.db.name }}?sslmode=disable"
|
default: postgres://{{ .app.db.user }}:{{ .secrets.dbPassword }}@{{ .app.db.host
|
||||||
|
}}:{{ .app.db.port }}/{{ .app.db.name }}?sslmode=disable
|
||||||
requiredSecrets:
|
requiredSecrets:
|
||||||
- postgres.password
|
- postgres.password
|
||||||
|
|||||||
@@ -1,12 +1,10 @@
|
|||||||
version: 2.0.0-1
|
version: 2.0.0-2
|
||||||
requires:
|
requires:
|
||||||
- name: postgres
|
- name: postgres
|
||||||
- name: mysql
|
- name: mysql
|
||||||
defaultConfig:
|
defaultConfig:
|
||||||
namespace: e2e-test-app
|
namespace: e2e-test-app
|
||||||
domain: e2e-test-app.{{ .cloud.domain }}
|
domain: e2e-test-app.{{ .cloud.domain }}
|
||||||
externalDnsDomain: '{{ .cloud.domain }}'
|
|
||||||
tlsSecretName: wildcard-wild-cloud-tls
|
|
||||||
storage: 1Gi
|
storage: 1Gi
|
||||||
db:
|
db:
|
||||||
host: '{{ .apps.postgres.host }}'
|
host: '{{ .apps.postgres.host }}'
|
||||||
@@ -19,10 +17,11 @@ defaultConfig:
|
|||||||
name: e2e_test_app
|
name: e2e_test_app
|
||||||
user: e2e_test_app
|
user: e2e_test_app
|
||||||
defaultSecrets:
|
defaultSecrets:
|
||||||
- key: dbPassword
|
- key: dbPassword
|
||||||
- key: dbUrl
|
- key: dbUrl
|
||||||
default: "postgres://{{ .app.db.user }}:{{ .secrets.dbPassword }}@{{ .app.db.host }}:{{ .app.db.port }}/{{ .app.db.name }}?sslmode=disable"
|
default: postgres://{{ .app.db.user }}:{{ .secrets.dbPassword }}@{{ .app.db.host
|
||||||
- key: mysqlPassword
|
}}:{{ .app.db.port }}/{{ .app.db.name }}?sslmode=disable
|
||||||
|
- key: mysqlPassword
|
||||||
requiredSecrets:
|
requiredSecrets:
|
||||||
- postgres.password
|
- postgres.password
|
||||||
- mysql.rootPassword
|
- mysql.rootPassword
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
name: etherpad
|
name: etherpad
|
||||||
is: etherpad
|
is: etherpad
|
||||||
description: Etherpad is a highly customizable open source online editor providing collaborative editing in real-time.
|
description: Etherpad is a highly customizable open source online editor providing collaborative editing in real-time.
|
||||||
|
category: productivity
|
||||||
icon: https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/etherpad.svg
|
icon: https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/etherpad.svg
|
||||||
latest: "2"
|
latest: "2"
|
||||||
|
|||||||
@@ -3,10 +3,6 @@ kind: Ingress
|
|||||||
metadata:
|
metadata:
|
||||||
name: etherpad
|
name: etherpad
|
||||||
namespace: etherpad
|
namespace: etherpad
|
||||||
annotations:
|
|
||||||
external-dns.alpha.kubernetes.io/target: {{ .externalDnsDomain }}
|
|
||||||
external-dns.alpha.kubernetes.io/cloudflare-proxied: "false"
|
|
||||||
external-dns.alpha.kubernetes.io/ttl: "60"
|
|
||||||
spec:
|
spec:
|
||||||
ingressClassName: traefik
|
ingressClassName: traefik
|
||||||
rules:
|
rules:
|
||||||
@@ -20,7 +16,3 @@ spec:
|
|||||||
name: etherpad
|
name: etherpad
|
||||||
port:
|
port:
|
||||||
number: 80
|
number: 80
|
||||||
tls:
|
|
||||||
- hosts:
|
|
||||||
- {{ .domain }}
|
|
||||||
secretName: {{ .tlsSecretName }}
|
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
version: 2.2.7-1
|
version: 2.2.7-2
|
||||||
requires:
|
requires:
|
||||||
- name: postgres
|
- name: postgres
|
||||||
defaultConfig:
|
defaultConfig:
|
||||||
namespace: etherpad
|
namespace: etherpad
|
||||||
externalDnsDomain: '{{ .cloud.domain }}'
|
|
||||||
domain: etherpad.{{ .cloud.domain }}
|
domain: etherpad.{{ .cloud.domain }}
|
||||||
tlsSecretName: wildcard-wild-cloud-tls
|
|
||||||
storage: 2Gi
|
storage: 2Gi
|
||||||
title: Etherpad
|
title: Etherpad
|
||||||
db:
|
db:
|
||||||
@@ -14,9 +12,10 @@ defaultConfig:
|
|||||||
name: etherpad
|
name: etherpad
|
||||||
user: etherpad
|
user: etherpad
|
||||||
defaultSecrets:
|
defaultSecrets:
|
||||||
- key: adminPassword
|
- key: adminPassword
|
||||||
- key: dbPassword
|
- key: dbPassword
|
||||||
- key: dbUrl
|
- key: dbUrl
|
||||||
default: 'postgresql://{{ .app.db.user }}:{{ .secrets.dbPassword }}@{{ .app.db.host }}:{{ .app.db.port }}/{{ .app.db.name }}?sslmode=disable'
|
default: postgresql://{{ .app.db.user }}:{{ .secrets.dbPassword }}@{{ .app.db.host
|
||||||
|
}}:{{ .app.db.port }}/{{ .app.db.name }}?sslmode=disable
|
||||||
requiredSecrets:
|
requiredSecrets:
|
||||||
- postgres.password
|
- postgres.password
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
name: eventyay
|
name: eventyay
|
||||||
is: eventyay
|
is: eventyay
|
||||||
description: Eventyay is an open-source event management platform covering ticketing, registration, speaker and session management, scheduling, video, and attendee check-in.
|
description: Eventyay is an open-source event management platform covering ticketing, registration, speaker and session management, scheduling, video, and attendee check-in.
|
||||||
|
category: community
|
||||||
icon: https://raw.githubusercontent.com/fossasia/eventyay/main/app/eventyay/static/common/img/logo.svg
|
icon: https://raw.githubusercontent.com/fossasia/eventyay/main/app/eventyay/static/common/img/logo.svg
|
||||||
latest: "1"
|
latest: "1"
|
||||||
|
ignoreRules:
|
||||||
|
- WC-IMG # no versioned Docker tags upstream; main tag is intentional
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ Key settings in `config.yaml`:
|
|||||||
|
|
||||||
2. Create the superuser account:
|
2. Create the superuser account:
|
||||||
```bash
|
```bash
|
||||||
kubectl exec -n eventyay deploy/eventyay -- python manage.py createsuperuser
|
EMAIL=you@example.com scripts/create-superuser.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
3. Log in at `/orga/login/` with the credentials you just created.
|
3. Log in at `/orga/login/` with the credentials you just created.
|
||||||
|
|||||||
@@ -122,26 +122,48 @@ spec:
|
|||||||
mountPath: /home/app/.gunicorn
|
mountPath: /home/app/.gunicorn
|
||||||
livenessProbe:
|
livenessProbe:
|
||||||
httpGet:
|
httpGet:
|
||||||
path: /
|
path: /static/pretixbase/img/eventyay-icon.svg
|
||||||
port: 8000
|
port: 8080
|
||||||
httpHeaders:
|
|
||||||
- name: Host
|
|
||||||
value: "{{ .domain }}"
|
|
||||||
initialDelaySeconds: 120
|
initialDelaySeconds: 120
|
||||||
timeoutSeconds: 10
|
timeoutSeconds: 10
|
||||||
periodSeconds: 30
|
periodSeconds: 30
|
||||||
failureThreshold: 6
|
failureThreshold: 6
|
||||||
readinessProbe:
|
readinessProbe:
|
||||||
httpGet:
|
httpGet:
|
||||||
path: /
|
path: /static/pretixbase/img/eventyay-icon.svg
|
||||||
port: 8000
|
port: 8080
|
||||||
httpHeaders:
|
|
||||||
- name: Host
|
|
||||||
value: "{{ .domain }}"
|
|
||||||
initialDelaySeconds: 60
|
initialDelaySeconds: 60
|
||||||
timeoutSeconds: 5
|
timeoutSeconds: 5
|
||||||
periodSeconds: 15
|
periodSeconds: 15
|
||||||
failureThreshold: 3
|
failureThreshold: 3
|
||||||
|
- name: nginx
|
||||||
|
image: nginxinc/nginx-unprivileged:1.27-alpine
|
||||||
|
ports:
|
||||||
|
- name: nginx-http
|
||||||
|
containerPort: 8080
|
||||||
|
protocol: TCP
|
||||||
|
volumeMounts:
|
||||||
|
- name: eventyay-static
|
||||||
|
mountPath: /static
|
||||||
|
readOnly: true
|
||||||
|
- name: nginx-config
|
||||||
|
mountPath: /etc/nginx/conf.d
|
||||||
|
readOnly: true
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
cpu: 200m
|
||||||
|
memory: 128Mi
|
||||||
|
requests:
|
||||||
|
cpu: 10m
|
||||||
|
memory: 32Mi
|
||||||
|
securityContext:
|
||||||
|
allowPrivilegeEscalation: false
|
||||||
|
capabilities:
|
||||||
|
drop:
|
||||||
|
- ALL
|
||||||
|
add:
|
||||||
|
- NET_BIND_SERVICE
|
||||||
|
readOnlyRootFilesystem: false
|
||||||
volumes:
|
volumes:
|
||||||
- name: eventyay-data
|
- name: eventyay-data
|
||||||
persistentVolumeClaim:
|
persistentVolumeClaim:
|
||||||
@@ -150,6 +172,9 @@ spec:
|
|||||||
emptyDir: {}
|
emptyDir: {}
|
||||||
- name: eventyay-gunicorn
|
- name: eventyay-gunicorn
|
||||||
emptyDir: {}
|
emptyDir: {}
|
||||||
|
- name: nginx-config
|
||||||
|
configMap:
|
||||||
|
name: eventyay-nginx
|
||||||
restartPolicy: Always
|
restartPolicy: Always
|
||||||
---
|
---
|
||||||
apiVersion: apps/v1
|
apiVersion: apps/v1
|
||||||
@@ -237,9 +262,9 @@ spec:
|
|||||||
limits:
|
limits:
|
||||||
cpu: "2"
|
cpu: "2"
|
||||||
ephemeral-storage: 1Gi
|
ephemeral-storage: 1Gi
|
||||||
memory: 1Gi
|
memory: 4Gi
|
||||||
requests:
|
requests:
|
||||||
cpu: 50m
|
cpu: 50m
|
||||||
ephemeral-storage: 50Mi
|
ephemeral-storage: 50Mi
|
||||||
memory: 512Mi
|
memory: 2Gi
|
||||||
restartPolicy: Always
|
restartPolicy: Always
|
||||||
|
|||||||
@@ -3,10 +3,6 @@ kind: Ingress
|
|||||||
metadata:
|
metadata:
|
||||||
name: eventyay
|
name: eventyay
|
||||||
namespace: {{ .namespace }}
|
namespace: {{ .namespace }}
|
||||||
annotations:
|
|
||||||
external-dns.alpha.kubernetes.io/target: {{ .externalDnsDomain }}
|
|
||||||
external-dns.alpha.kubernetes.io/cloudflare-proxied: "false"
|
|
||||||
external-dns.alpha.kubernetes.io/ttl: "60"
|
|
||||||
spec:
|
spec:
|
||||||
ingressClassName: traefik
|
ingressClassName: traefik
|
||||||
rules:
|
rules:
|
||||||
@@ -20,7 +16,3 @@ spec:
|
|||||||
name: eventyay
|
name: eventyay
|
||||||
port:
|
port:
|
||||||
number: 80
|
number: 80
|
||||||
tls:
|
|
||||||
- hosts:
|
|
||||||
- {{ .domain }}
|
|
||||||
secretName: {{ .tlsSecretName }}
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ labels:
|
|||||||
resources:
|
resources:
|
||||||
- namespace.yaml
|
- namespace.yaml
|
||||||
- pvc.yaml
|
- pvc.yaml
|
||||||
|
- nginx-configmap.yaml
|
||||||
- deployment.yaml
|
- deployment.yaml
|
||||||
- service.yaml
|
- service.yaml
|
||||||
- ingress.yaml
|
- ingress.yaml
|
||||||
|
|||||||
@@ -1,13 +1,11 @@
|
|||||||
version: main-2
|
version: main-6
|
||||||
requires:
|
requires:
|
||||||
- name: postgres
|
- name: postgres
|
||||||
- name: redis
|
- name: redis
|
||||||
- name: smtp
|
- name: smtp
|
||||||
defaultConfig:
|
defaultConfig:
|
||||||
namespace: eventyay
|
namespace: eventyay
|
||||||
externalDnsDomain: '{{ .cloud.domain }}'
|
|
||||||
domain: eventyay.{{ .cloud.domain }}
|
domain: eventyay.{{ .cloud.domain }}
|
||||||
tlsSecretName: wildcard-wild-cloud-tls
|
|
||||||
storage: 2Gi
|
storage: 2Gi
|
||||||
timezone: UTC
|
timezone: UTC
|
||||||
db:
|
db:
|
||||||
@@ -23,10 +21,20 @@ defaultConfig:
|
|||||||
port: '{{ .apps.smtp.port }}'
|
port: '{{ .apps.smtp.port }}'
|
||||||
from: '{{ .apps.smtp.from }}'
|
from: '{{ .apps.smtp.from }}'
|
||||||
user: '{{ .apps.smtp.user }}'
|
user: '{{ .apps.smtp.user }}'
|
||||||
|
scripts:
|
||||||
|
- name: create-superuser
|
||||||
|
path: scripts/create-superuser.sh
|
||||||
|
description: Create an Eventyay superuser account for first-time setup.
|
||||||
|
params:
|
||||||
|
- name: EMAIL
|
||||||
|
description: Email address for the superuser account
|
||||||
|
required: true
|
||||||
|
- name: PASSWORD
|
||||||
|
description: Password (leave blank to generate a random one)
|
||||||
defaultSecrets:
|
defaultSecrets:
|
||||||
- key: dbPassword
|
- key: dbPassword
|
||||||
- key: djangoSecret
|
- key: djangoSecret
|
||||||
requiredSecrets:
|
requiredSecrets:
|
||||||
- postgres.password
|
- postgres.password
|
||||||
- redis.password
|
- redis.password
|
||||||
- smtp.password
|
- smtp.password
|
||||||
|
|||||||
31
eventyay/versions/1/nginx-configmap.yaml
Normal file
31
eventyay/versions/1/nginx-configmap.yaml
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: ConfigMap
|
||||||
|
metadata:
|
||||||
|
name: eventyay-nginx
|
||||||
|
namespace: {{ .namespace }}
|
||||||
|
data:
|
||||||
|
nginx.conf: |
|
||||||
|
server {
|
||||||
|
listen 8080;
|
||||||
|
server_name _;
|
||||||
|
client_max_body_size 100m;
|
||||||
|
|
||||||
|
location /static/ {
|
||||||
|
alias /static/;
|
||||||
|
expires 30d;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
}
|
||||||
|
|
||||||
|
location /media/ {
|
||||||
|
alias /media/;
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://127.0.0.1:8000;
|
||||||
|
proxy_set_header Host $http_host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto;
|
||||||
|
proxy_read_timeout 120s;
|
||||||
|
}
|
||||||
|
}
|
||||||
56
eventyay/versions/1/scripts/create-superuser.sh
Executable file
56
eventyay/versions/1/scripts/create-superuser.sh
Executable file
@@ -0,0 +1,56 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Create an Eventyay superuser (organizer admin account).
|
||||||
|
#
|
||||||
|
# Required for first-time setup — the web UI cannot be accessed until
|
||||||
|
# at least one superuser exists.
|
||||||
|
#
|
||||||
|
# Runs on the worker pod because the web pod's memory limit is too tight
|
||||||
|
# to run an additional Django process alongside gunicorn workers.
|
||||||
|
set -e
|
||||||
|
set -o pipefail
|
||||||
|
|
||||||
|
if [ -z "${KUBECONFIG}" ] || [ -z "${WILD_INSTANCE}" ] || [ -z "${WILD_API_DATA_DIR}" ]; then
|
||||||
|
echo "ERROR: KUBECONFIG, WILD_INSTANCE, and WILD_API_DATA_DIR must be set"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "${EMAIL}" ]; then
|
||||||
|
echo "ERROR: EMAIL is required"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
CONFIG_FILE="${WILD_API_DATA_DIR}/instances/${WILD_INSTANCE}/config.yaml"
|
||||||
|
|
||||||
|
NAMESPACE="$(yq '.apps.eventyay.namespace' "${CONFIG_FILE}" | tr -d '"')"
|
||||||
|
NAMESPACE="${NAMESPACE:-eventyay}"
|
||||||
|
|
||||||
|
# Run on the worker pod — same image as web, but with a higher memory limit
|
||||||
|
# which is needed to run manage.py alongside the existing gunicorn workers.
|
||||||
|
POD="$(kubectl get pods -n "${NAMESPACE}" -l component=worker --field-selector=status.phase=Running -o jsonpath='{.items[0].metadata.name}' 2>/dev/null)"
|
||||||
|
if [ -z "${POD}" ]; then
|
||||||
|
echo "ERROR: No running Eventyay worker pod found in namespace ${NAMESPACE}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "${PASSWORD}" ]; then
|
||||||
|
PASSWORD="$(openssl rand -base64 24 | tr -d '/+=' | head -c 24)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Creating superuser '${EMAIL}'..."
|
||||||
|
|
||||||
|
kubectl exec -n "${NAMESPACE}" "${POD}" -- \
|
||||||
|
python manage.py shell -c "
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
User = get_user_model()
|
||||||
|
if User.objects.filter(email='${EMAIL}').exists():
|
||||||
|
raise SystemExit('ERROR: User ${EMAIL} already exists')
|
||||||
|
User.objects.create_superuser(email='${EMAIL}', password='${PASSWORD}')
|
||||||
|
"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== Superuser created ==="
|
||||||
|
echo "Email: ${EMAIL}"
|
||||||
|
echo "Password: ${PASSWORD}"
|
||||||
|
echo ""
|
||||||
|
echo "Log in at /orga/login/ with these credentials."
|
||||||
|
echo "Save this password — it will not be shown again."
|
||||||
@@ -9,5 +9,5 @@ spec:
|
|||||||
ports:
|
ports:
|
||||||
- name: http
|
- name: http
|
||||||
port: 80
|
port: 80
|
||||||
targetPort: 8000
|
targetPort: 8080
|
||||||
protocol: TCP
|
protocol: TCP
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
name: example-admin
|
name: example-admin
|
||||||
is: example
|
is: example
|
||||||
description: An example application that is deployed with internal-only access.
|
description: An example application that is deployed with internal-only access.
|
||||||
|
category: services
|
||||||
latest: "1"
|
latest: "1"
|
||||||
|
ignoreRules:
|
||||||
|
- WC-ICON # example/demo app
|
||||||
|
- WC-IMG # example/demo app intentionally uses latest for simplicity
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ spec:
|
|||||||
labels:
|
labels:
|
||||||
app: example-admin
|
app: example-admin
|
||||||
spec:
|
spec:
|
||||||
|
securityContext:
|
||||||
|
seccompProfile:
|
||||||
|
type: RuntimeDefault
|
||||||
containers:
|
containers:
|
||||||
- name: example-admin
|
- name: example-admin
|
||||||
image: nginx:latest
|
image: nginx:latest
|
||||||
@@ -28,6 +31,15 @@ spec:
|
|||||||
requests:
|
requests:
|
||||||
cpu: 50m
|
cpu: 50m
|
||||||
memory: 10Mi
|
memory: 10Mi
|
||||||
|
securityContext:
|
||||||
|
runAsNonRoot: false
|
||||||
|
runAsUser: 0
|
||||||
|
allowPrivilegeEscalation: false
|
||||||
|
capabilities:
|
||||||
|
drop: [ALL]
|
||||||
|
readOnlyRootFilesystem: false
|
||||||
|
seccompProfile:
|
||||||
|
type: RuntimeDefault
|
||||||
livenessProbe:
|
livenessProbe:
|
||||||
httpGet:
|
httpGet:
|
||||||
path: /
|
path: /
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ kind: Ingress
|
|||||||
metadata:
|
metadata:
|
||||||
name: example-admin
|
name: example-admin
|
||||||
spec:
|
spec:
|
||||||
|
ingressClassName: traefik
|
||||||
rules:
|
rules:
|
||||||
- host: "{{ .host }}"
|
- host: "{{ .host }}"
|
||||||
http:
|
http:
|
||||||
@@ -15,7 +16,3 @@ spec:
|
|||||||
name: example-admin
|
name: example-admin
|
||||||
port:
|
port:
|
||||||
number: 80
|
number: 80
|
||||||
tls:
|
|
||||||
- hosts:
|
|
||||||
- "{{ .host }}"
|
|
||||||
secretName: wildcard-internal-wild-cloud-tls
|
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
version: 1.0.0-1
|
version: 1.0.0-3
|
||||||
defaultConfig:
|
defaultConfig:
|
||||||
namespace: example-admin
|
namespace: example-admin
|
||||||
externalDnsDomain: '{{ .cloud.domain }}'
|
|
||||||
host: '{{ .host }}'
|
host: '{{ .host }}'
|
||||||
tlsSecretName: wildcard-internal-wild-cloud-tls
|
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
name: example-app
|
name: example-app
|
||||||
is: example
|
is: example
|
||||||
description: An example application that is deployed with public access.
|
description: An example application that is deployed with public access.
|
||||||
|
category: services
|
||||||
latest: "1"
|
latest: "1"
|
||||||
|
ignoreRules:
|
||||||
|
- WC-ICON # example/demo app
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ spec:
|
|||||||
labels:
|
labels:
|
||||||
app: example-app
|
app: example-app
|
||||||
spec:
|
spec:
|
||||||
|
securityContext:
|
||||||
|
seccompProfile:
|
||||||
|
type: RuntimeDefault
|
||||||
containers:
|
containers:
|
||||||
- name: example-app
|
- name: example-app
|
||||||
image: nginx:alpine
|
image: nginx:alpine
|
||||||
@@ -26,6 +29,15 @@ spec:
|
|||||||
requests:
|
requests:
|
||||||
cpu: 50m
|
cpu: 50m
|
||||||
memory: 32Mi
|
memory: 32Mi
|
||||||
|
securityContext:
|
||||||
|
runAsNonRoot: false
|
||||||
|
runAsUser: 0
|
||||||
|
allowPrivilegeEscalation: false
|
||||||
|
capabilities:
|
||||||
|
drop: [ALL]
|
||||||
|
readOnlyRootFilesystem: false
|
||||||
|
seccompProfile:
|
||||||
|
type: RuntimeDefault
|
||||||
livenessProbe:
|
livenessProbe:
|
||||||
httpGet:
|
httpGet:
|
||||||
path: /
|
path: /
|
||||||
|
|||||||
@@ -4,8 +4,6 @@ kind: Ingress
|
|||||||
metadata:
|
metadata:
|
||||||
name: example-app
|
name: example-app
|
||||||
annotations:
|
annotations:
|
||||||
external-dns.alpha.kubernetes.io/target: "{{ .cloud.externalDnsTarget }}"
|
|
||||||
external-dns.alpha.kubernetes.io/cloudflare-proxied: "false"
|
|
||||||
# Optional: Enable basic auth
|
# Optional: Enable basic auth
|
||||||
# traefik.ingress.kubernetes.io/auth-type: basic
|
# traefik.ingress.kubernetes.io/auth-type: basic
|
||||||
# traefik.ingress.kubernetes.io/auth-secret: basic-auth
|
# traefik.ingress.kubernetes.io/auth-secret: basic-auth
|
||||||
@@ -22,7 +20,3 @@ spec:
|
|||||||
name: example-app
|
name: example-app
|
||||||
port:
|
port:
|
||||||
number: 80
|
number: 80
|
||||||
tls:
|
|
||||||
- hosts:
|
|
||||||
- "{{ .host }}"
|
|
||||||
secretName: wildcard-wild-cloud-tls
|
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
version: 1.0.0-1
|
version: 1.0.0-2
|
||||||
defaultConfig:
|
defaultConfig:
|
||||||
namespace: example-app
|
namespace: example-app
|
||||||
externalDnsDomain: '{{ .cloud.domain }}'
|
|
||||||
host: example-app.{{ .cloud.domain }}
|
host: example-app.{{ .cloud.domain }}
|
||||||
tlsSecretName: wildcard-wild-cloud-tls
|
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
name: externaldns
|
name: externaldns
|
||||||
is: externaldns
|
is: externaldns
|
||||||
description: Automatically configures DNS records for services
|
description: Automatically configures DNS records for services
|
||||||
category: infrastructure
|
category: services
|
||||||
latest: "v0"
|
latest: "v0"
|
||||||
|
ignoreRules:
|
||||||
|
- WC-ICON # infrastructure service; not user-facing
|
||||||
|
- WC-SEL # infrastructure service; selectors are managed by the upstream manifest
|
||||||
|
- WC-NS # fixed namespace: externaldns
|
||||||
|
|||||||
@@ -16,6 +16,10 @@ spec:
|
|||||||
app: external-dns
|
app: external-dns
|
||||||
spec:
|
spec:
|
||||||
serviceAccountName: external-dns
|
serviceAccountName: external-dns
|
||||||
|
securityContext:
|
||||||
|
runAsNonRoot: true
|
||||||
|
seccompProfile:
|
||||||
|
type: RuntimeDefault
|
||||||
containers:
|
containers:
|
||||||
- name: external-dns
|
- name: external-dns
|
||||||
image: registry.k8s.io/external-dns/external-dns:v0.13.4
|
image: registry.k8s.io/external-dns/external-dns:v0.13.4
|
||||||
@@ -24,12 +28,21 @@ spec:
|
|||||||
- --source=ingress
|
- --source=ingress
|
||||||
- --txt-owner-id={{ .ownerId }}
|
- --txt-owner-id={{ .ownerId }}
|
||||||
- --provider=cloudflare
|
- --provider=cloudflare
|
||||||
- --domain-filter=payne.io
|
{{- with (index . "domainFilter") }}
|
||||||
#- --exclude-domains=internal.${DOMAIN}
|
- --domain-filter={{ . }}
|
||||||
|
{{- end }}
|
||||||
|
{{- with (index . "internalDomain") }}
|
||||||
|
- --exclude-domains={{ . }}
|
||||||
|
{{- end }}
|
||||||
- --cloudflare-dns-records-per-page=5000
|
- --cloudflare-dns-records-per-page=5000
|
||||||
- --publish-internal-services
|
- --publish-internal-services
|
||||||
- --no-cloudflare-proxied
|
- --no-cloudflare-proxied
|
||||||
- --log-level=debug
|
- --log-level=debug
|
||||||
|
securityContext:
|
||||||
|
allowPrivilegeEscalation: false
|
||||||
|
capabilities:
|
||||||
|
drop: [ALL]
|
||||||
|
readOnlyRootFilesystem: true
|
||||||
env:
|
env:
|
||||||
- name: CF_API_TOKEN
|
- name: CF_API_TOKEN
|
||||||
valueFrom:
|
valueFrom:
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
version: v0.13.4-1
|
version: v0.13.4-6
|
||||||
deploymentName: external-dns
|
deploymentName: external-dns
|
||||||
requires:
|
requires:
|
||||||
- name: cert-manager
|
- name: cert-manager
|
||||||
defaultConfig:
|
defaultConfig:
|
||||||
namespace: externaldns
|
namespace: externaldns
|
||||||
ownerId: "wild-cloud-{{ .cluster.name }}"
|
ownerId: "wild-cloud-{{ .cluster.name }}"
|
||||||
|
internalDomain: "{{ .cloud.internalDomain }}"
|
||||||
defaultSecrets:
|
defaultSecrets:
|
||||||
- key: cloudflareToken
|
- key: cloudflareToken
|
||||||
requiredSecrets:
|
requiredSecrets:
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
name: firefly-iii
|
name: firefly-iii
|
||||||
is: firefly-iii
|
is: firefly-iii
|
||||||
description: Firefly III is a self-hosted personal finance manager that helps you track income, expenses, and budgets with double-entry bookkeeping.
|
description: Firefly III is a self-hosted personal finance manager that helps you track income, expenses, and budgets with double-entry bookkeeping.
|
||||||
|
category: business
|
||||||
icon: https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/firefly-iii.svg
|
icon: https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/firefly-iii.svg
|
||||||
latest: "6"
|
latest: "6"
|
||||||
|
|||||||
@@ -3,10 +3,6 @@ kind: Ingress
|
|||||||
metadata:
|
metadata:
|
||||||
name: firefly-iii
|
name: firefly-iii
|
||||||
namespace: firefly-iii
|
namespace: firefly-iii
|
||||||
annotations:
|
|
||||||
external-dns.alpha.kubernetes.io/target: {{ .externalDnsDomain }}
|
|
||||||
external-dns.alpha.kubernetes.io/cloudflare-proxied: "false"
|
|
||||||
external-dns.alpha.kubernetes.io/ttl: "60"
|
|
||||||
spec:
|
spec:
|
||||||
ingressClassName: traefik
|
ingressClassName: traefik
|
||||||
rules:
|
rules:
|
||||||
@@ -20,7 +16,3 @@ spec:
|
|||||||
name: firefly-iii
|
name: firefly-iii
|
||||||
port:
|
port:
|
||||||
number: 80
|
number: 80
|
||||||
tls:
|
|
||||||
- hosts:
|
|
||||||
- {{ .domain }}
|
|
||||||
secretName: {{ .tlsSecretName }}
|
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
version: 6.6.3-1
|
version: 6.6.3-2
|
||||||
requires:
|
requires:
|
||||||
- name: postgres
|
- name: postgres
|
||||||
defaultConfig:
|
defaultConfig:
|
||||||
namespace: firefly-iii
|
namespace: firefly-iii
|
||||||
externalDnsDomain: '{{ .cloud.domain }}'
|
|
||||||
domain: firefly-iii.{{ .cloud.domain }}
|
domain: firefly-iii.{{ .cloud.domain }}
|
||||||
tlsSecretName: wildcard-wild-cloud-tls
|
|
||||||
storage: 1Gi
|
storage: 1Gi
|
||||||
timezone: UTC
|
timezone: UTC
|
||||||
adminEmail: '{{ .operator.email }}'
|
adminEmail: '{{ .operator.email }}'
|
||||||
@@ -15,7 +13,7 @@ defaultConfig:
|
|||||||
name: fireflyiii
|
name: fireflyiii
|
||||||
user: fireflyiii
|
user: fireflyiii
|
||||||
defaultSecrets:
|
defaultSecrets:
|
||||||
- key: appKey
|
- key: appKey
|
||||||
- key: dbPassword
|
- key: dbPassword
|
||||||
requiredSecrets:
|
requiredSecrets:
|
||||||
- postgres.password
|
- postgres.password
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
name: formbricks
|
name: formbricks
|
||||||
is: formbricks
|
is: formbricks
|
||||||
description: Formbricks is an open-source survey and form platform for collecting and analyzing user feedback. A self-hosted alternative to Typeform and SurveyMonkey.
|
description: Formbricks is an open-source survey and form platform for collecting and analyzing user feedback. A self-hosted alternative to Typeform and SurveyMonkey.
|
||||||
|
category: business
|
||||||
icon: https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/formbricks.svg
|
icon: https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/formbricks.svg
|
||||||
latest: "3"
|
latest: "3"
|
||||||
|
|||||||
@@ -3,10 +3,6 @@ kind: Ingress
|
|||||||
metadata:
|
metadata:
|
||||||
name: formbricks
|
name: formbricks
|
||||||
namespace: {{ .namespace }}
|
namespace: {{ .namespace }}
|
||||||
annotations:
|
|
||||||
external-dns.alpha.kubernetes.io/target: {{ .externalDnsDomain }}
|
|
||||||
external-dns.alpha.kubernetes.io/cloudflare-proxied: "false"
|
|
||||||
external-dns.alpha.kubernetes.io/ttl: "60"
|
|
||||||
spec:
|
spec:
|
||||||
ingressClassName: traefik
|
ingressClassName: traefik
|
||||||
rules:
|
rules:
|
||||||
@@ -20,7 +16,3 @@ spec:
|
|||||||
name: formbricks
|
name: formbricks
|
||||||
port:
|
port:
|
||||||
number: 80
|
number: 80
|
||||||
tls:
|
|
||||||
- hosts:
|
|
||||||
- {{ .domain }}
|
|
||||||
secretName: {{ .tlsSecretName }}
|
|
||||||
|
|||||||
@@ -1,27 +1,26 @@
|
|||||||
version: 3.6.0-1
|
version: 3.6.0-2
|
||||||
requires:
|
requires:
|
||||||
- name: postgres
|
- name: postgres
|
||||||
- name: redis
|
- name: redis
|
||||||
defaultConfig:
|
defaultConfig:
|
||||||
namespace: formbricks
|
namespace: formbricks
|
||||||
externalDnsDomain: "{{ .cloud.domain }}"
|
|
||||||
domain: formbricks.{{ .cloud.domain }}
|
domain: formbricks.{{ .cloud.domain }}
|
||||||
tlsSecretName: wildcard-wild-cloud-tls
|
|
||||||
storage: 2Gi
|
storage: 2Gi
|
||||||
db:
|
db:
|
||||||
host: "{{ .apps.postgres.host }}"
|
host: '{{ .apps.postgres.host }}'
|
||||||
port: "{{ .apps.postgres.port }}"
|
port: '{{ .apps.postgres.port }}'
|
||||||
name: formbricks
|
name: formbricks
|
||||||
user: formbricks
|
user: formbricks
|
||||||
redis:
|
redis:
|
||||||
host: "{{ .apps.redis.host }}"
|
host: '{{ .apps.redis.host }}'
|
||||||
defaultSecrets:
|
defaultSecrets:
|
||||||
- key: dbPassword
|
- key: dbPassword
|
||||||
- key: dbUrl
|
- key: dbUrl
|
||||||
default: "postgresql://{{ .app.db.user }}:{{ .secrets.dbPassword }}@{{ .app.db.host }}:{{ .app.db.port }}/{{ .app.db.name }}?sslmode=disable"
|
default: postgresql://{{ .app.db.user }}:{{ .secrets.dbPassword }}@{{ .app.db.host
|
||||||
- key: nextauthSecret
|
}}:{{ .app.db.port }}/{{ .app.db.name }}?sslmode=disable
|
||||||
- key: encryptionKey
|
- key: nextauthSecret
|
||||||
- key: cronSecret
|
- key: encryptionKey
|
||||||
|
- key: cronSecret
|
||||||
requiredSecrets:
|
requiredSecrets:
|
||||||
- postgres.password
|
- postgres.password
|
||||||
- redis.password
|
- redis.password
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
name: gancio
|
name: gancio
|
||||||
is: gancio
|
is: gancio
|
||||||
description: Gancio is a federated event platform for local communities with ActivityPub support, allowing you to create and share events across the Fediverse.
|
description: Gancio is a federated event platform for local communities with ActivityPub support, allowing you to create and share events across the Fediverse.
|
||||||
|
category: community
|
||||||
icon: https://gancio.org/favicon.ico
|
icon: https://gancio.org/favicon.ico
|
||||||
latest: "1"
|
latest: "1"
|
||||||
|
|||||||
21
gancio/notes.md
Normal file
21
gancio/notes.md
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
# Gancio — Notes
|
||||||
|
|
||||||
|
## "Non empty db" crash on re-deploy
|
||||||
|
|
||||||
|
Gancio stores its setup state in `config.json` on the data PVC. If the PVC is lost or the app is
|
||||||
|
deleted and redeployed against an existing database, Gancio finds a non-empty DB but no
|
||||||
|
`config.json` and refuses to start: `"Non empty db! Please move your current db elsewhere than retry."`
|
||||||
|
|
||||||
|
**Fix**: drop and recreate the public schema, then restart the deployment:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kubectl exec -n postgres <postgres-pod> -- psql -U postgres gancio \
|
||||||
|
-c "DROP SCHEMA public CASCADE; CREATE SCHEMA public; GRANT ALL ON SCHEMA public TO gancio; GRANT ALL ON SCHEMA public TO public;"
|
||||||
|
|
||||||
|
kubectl scale deployment -n gancio gancio --replicas=0
|
||||||
|
# wait for pod to terminate
|
||||||
|
kubectl scale deployment -n gancio gancio --replicas=1
|
||||||
|
```
|
||||||
|
|
||||||
|
Scale down before dropping the schema to release the database connection first.
|
||||||
|
This only affects re-deployments; fresh installs work fine.
|
||||||
@@ -28,6 +28,6 @@ Key settings in `config.yaml`:
|
|||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
- **Re-deploy warning**: If the app is deleted and redeployed with an existing database, Gancio will refuse to start with a "Non empty db" error. Fix by dropping and recreating the public schema in the `gancio` database before restarting. See ADDING-APPS-NOTES.md note 18 for the exact commands.
|
- **Re-deploy warning**: If the app is deleted and redeployed with an existing database, Gancio will refuse to start with a "Non empty db" error. See `gancio/notes.md` for the fix.
|
||||||
- Gancio stores its setup state in `config.json` on the data PVC — do not delete the PVC without also clearing the database
|
- Gancio stores its setup state in `config.json` on the data PVC — do not delete the PVC without also clearing the database
|
||||||
- Events are federated to Mastodon and other ActivityPub platforms
|
- Events are federated to Mastodon and other ActivityPub platforms
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user