Compare commits

...

80 Commits

Author SHA1 Message Date
Paul Payne
9f5057dff8 feat(validation): add validation scripts for Wild Cloud app packages 2026-07-02 20:55:53 +00:00
Paul Payne
d9f10716b0 fix(pretix): increase memory limit to 6Gi; eventyay nginx sidecar; crowdsec middleware
pretix/standalone runs gunicorn + celery + celery-beat in one container
and OOMKilled at 4Gi. Increased limit to 6Gi, request to 512Mi.

eventyay: add nginx sidecar to serve static files, update health probes
to check a static asset at port 8080, add create-superuser script,
register script in manifest.

crowdsec: middleware fix (previously uncommitted).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-22 04:20:40 +00:00
Paul Payne
74570413fc fix(karrot): fix nginx DNS resolver for Kubernetes and add README
The karrot-frontend nginx template hardcodes Docker's DNS resolver
(127.0.0.11) which doesn't exist in Kubernetes. Added a ConfigMap to
override the template, removing the Docker DNS resolver by using a
direct proxy_pass with no nginx variable (which would force runtime
resolution). Also adds a README with usage instructions.

Documents lessons learned in ADDING-APPS-NOTES.md:
- Note 24: nginx Docker DNS resolver doesn't work in Kubernetes; any
  nginx variable in proxy_pass (including $request_uri) forces runtime
  DNS resolution requiring a resolver directive
- Note 25: ConfigMap changes don't restart pods; subPath mounts never
  auto-update; always follow with kubectl rollout restart
- Note 26: includeSelectors mismatch affects every deployment in an
  app — check all endpoints, not just the web-facing one

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-22 04:08:04 +00:00
Paul Payne
7aa3e04829 Change aptly from service to app and update category handling in docs. 2026-06-20 21:45:39 +00:00
Paul Payne
368e3a8455 Adds app category labels. 2026-06-20 08:28:46 +00:00
Paul Payne
6e77c1ada5 Automatic GPG creation on aptly deployment. 2026-06-20 07:22:19 +00:00
Paul Payne
5c6f8f3a2b Removes manifest config for deprecated restore scenarios. 2026-06-20 06:40:14 +00:00
Paul Payne
dc54a10b51 Adds mysql to e2e test app. 2026-06-20 06:39:26 +00:00
Paul Payne
c223e2b5e0 Reduces apps default resource usage. 2026-06-19 21:38:56 +00:00
Paul Payne
63dcd1b82b Removes matrix app (renamed synapse) 2026-06-19 21:38:00 +00:00
Paul Payne
f445f50265 Adds mastodon app. 2026-06-19 21:37:06 +00:00
Paul Payne
0673442451 Adds loomio app. 2026-06-19 21:36:48 +00:00
Paul Payne
4d4b48cf72 Adds zulip app. 2026-06-19 21:33:33 +00:00
Paul Payne
cb5f3d66ff Adds writefreely app. 2026-06-19 21:33:33 +00:00
Paul Payne
be035c7a85 Adds wikijs app. 2026-06-19 21:33:33 +00:00
Paul Payne
0554916818 Adds vaultwarden app. 2026-06-19 21:33:30 +00:00
Paul Payne
2b1db70090 Adds ushahidi app. 2026-06-19 21:33:30 +00:00
Paul Payne
da8dfe9196 Adds umap app. 2026-06-19 21:33:30 +00:00
Paul Payne
0449272102 Adds typebot app. 2026-06-19 21:33:26 +00:00
Paul Payne
a2852dab0a Adds taiga app. 2026-06-19 21:33:26 +00:00
Paul Payne
1d57e26dae Adds synapse app. 2026-06-19 21:33:26 +00:00
Paul Payne
274d7a8fb9 Adds pretix app. 2026-06-19 21:33:23 +00:00
Paul Payne
cd37c07a33 Adds polis app. 2026-06-19 21:33:23 +00:00
Paul Payne
bd1116048d Adds pixelfed app. 2026-06-19 21:33:23 +00:00
Paul Payne
c2efed5937 Adds peertube app. 2026-06-19 21:33:20 +00:00
Paul Payne
424e57baec Adds outline app. 2026-06-19 21:33:20 +00:00
Paul Payne
b1295223b1 Adds ohmyform app. 2026-06-19 21:33:20 +00:00
Paul Payne
a3dafa925b Adds odoo app. 2026-06-19 21:33:17 +00:00
Paul Payne
8f8d3f7680 Adds nocodb app. 2026-06-19 21:33:17 +00:00
Paul Payne
110eca06ce Adds nextcloud app. 2026-06-19 21:33:17 +00:00
Paul Payne
5e98b3fce6 Adds moodle app. 2026-06-19 21:33:14 +00:00
Paul Payne
855fb03e17 Adds mobilizon app. 2026-06-19 21:33:14 +00:00
Paul Payne
a2e3031b84 Adds mediawiki app. 2026-06-19 21:33:14 +00:00
Paul Payne
2ebbc65dd3 Adds mautic app. 2026-06-19 21:33:11 +00:00
Paul Payne
2d0292a69b Adds mattermost app. 2026-06-19 21:33:11 +00:00
Paul Payne
d7a11b380c Adds limesurvey app. 2026-06-19 21:33:11 +00:00
Paul Payne
3e3b048f5b Adds librebooking app. 2026-06-19 21:33:08 +00:00
Paul Payne
347c4e0c90 Adds karrot app. 2026-06-19 21:33:08 +00:00
Paul Payne
413800cbcb Adds jitsi app. 2026-06-19 21:33:08 +00:00
Paul Payne
f9ef89f00d Adds indico app. 2026-06-19 21:33:05 +00:00
Paul Payne
761712ba71 Adds headscale app. 2026-06-19 21:33:05 +00:00
Paul Payne
777bab792c Adds gancio app. 2026-06-19 21:33:05 +00:00
Paul Payne
b42f048f5c Adds formbricks app. 2026-06-19 21:33:01 +00:00
Paul Payne
8a2539e527 Adds firefly-iii app. 2026-06-19 21:33:01 +00:00
Paul Payne
1d63970113 Adds eventyay app. 2026-06-19 21:33:01 +00:00
Paul Payne
a25366daf7 Adds etherpad app. 2026-06-19 21:32:58 +00:00
Paul Payne
aee2e93803 Adds cryptpad app. 2026-06-19 21:32:58 +00:00
Paul Payne
294cf59dd0 Adds chamilo app. 2026-06-19 21:32:58 +00:00
Paul Payne
7b41ed5148 Adds bookwyrm app. 2026-06-19 21:32:56 +00:00
Paul Payne
78ac8d0cbb Adds bookstack app. 2026-06-19 21:29:35 +00:00
Paul Payne
c2493dc58a Adds baserow app. 2026-06-19 21:29:25 +00:00
Paul Payne
b95586e74b Adds akaunting app. 2026-06-19 21:29:14 +00:00
Paul Payne
319ec01f0c Fix default ingress. 2026-06-08 17:10:46 +00:00
Paul Payne
4eb722f393 Aptly and community search. 2026-06-08 17:10:11 +00:00
Paul Payne
8117fb8175 Restore strategies. 2026-05-25 23:10:03 +00:00
Paul Payne
189fdab0bc refactor: update README for clarity and remove setup script 2026-05-24 20:04:12 +00:00
Paul Payne
bc7a168851 Reorganized for new stable/waypoint versioning design. 2026-05-24 18:28:47 +00:00
Paul Payne
945d2225a2 First version of app upgrade. 2026-05-24 04:00:07 +00:00
Paul Payne
6e1c676c09 feat: update versioning in longhorn and snapshot-controller manifests 2026-05-23 20:42:45 +00:00
Paul Payne
6b5325c6f3 Standardize config. 2026-05-23 19:51:33 +00:00
Paul Payne
e2e3f730a5 fix: remove unnecessary namespace from default configuration in README and manifest 2026-05-23 11:36:51 +00:00
Paul Payne
46002ff273 feat: update NFS documentation and manifest version for improved clarity and configuration 2026-05-23 11:25:50 +00:00
Paul Payne
acec744df8 feat: update NFS configuration and add check-nfs script for server validation 2026-05-23 11:24:21 +00:00
Paul Payne
12e87635c6 docs: Update ADDING-APPS.md to remove cloud.smtp references
SMTP config is now at apps.smtp.* via the SMTP infrastructure app,
not cloud.smtp.*. Remove the old variable listing and update the
configuration flow documentation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-22 23:31:14 +00:00
Paul Payne
351dff14d4 feat: add BackupTarget configuration and update kustomization to include it 2026-05-21 04:22:13 +00:00
Paul Payne
0645624ded feat: update Immich version and image tags to 1.135.3 in manifest.yaml 2026-05-21 04:21:40 +00:00
Paul Payne
afa21ef650 feat: add initial Kubernetes manifests for e2e-test-app including deployment, service, PVC, and database initialization job 2026-05-21 04:20:56 +00:00
Paul Payne
5733c20098 feat: add repair-certificates script for managing stuck certificates and ACME orders 2026-05-18 04:24:21 +00:00
Paul Payne
54abfdd469 Add kustomization.yaml for cert-manager with custom DNS settings
- Introduced a new kustomization.yaml file for cert-manager.
- Configured a patch to modify the cert-manager Deployment to use a custom DNS policy and settings.
- Set dnsPolicy to None and specified custom nameservers and search options.
2026-05-18 03:39:21 +00:00
Paul Payne
e4c24d4a8c feat: update CrowdSec and Traefik manifests; remove installation scripts and add secret management 2026-05-18 03:33:37 +00:00
Paul Payne
b52e76eeeb feat: remove installation scripts for CoreDNS, ExternalDNS, Headlamp, MetalLB, and NVIDIA Device Plugin; update manifests for deployment configurations 2026-05-18 02:46:00 +00:00
Paul Payne
872a804aa7 feat: update manifests and namespaces to use templated namespace variables 2026-05-17 23:26:20 +00:00
Paul Payne
edff518815 chore: remove deprecated deployment and service configurations for communitarian 2026-05-17 22:35:03 +00:00
Paul Payne
27747bb2a5 docs: Update deployment strategy for apps using ReadWriteOnce PVCs 2026-05-17 22:31:41 +00:00
Paul Payne
326cca5870 docs: Add backup/restore database naming conventions to ADDING-APPS.md
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-17 19:24:52 +00:00
Paul Payne
9687fad812 feat: Move cluster services to wild-directory as unified packages
Convert all 15 cluster services from embedded API format to
wild-directory packages using the unified manifest format:
- metallb, traefik, cert-manager, longhorn, snapshot-controller
- nfs, smtp, coredns, node-feature-discovery, nvidia-device-plugin
- externaldns, docker-registry, headlamp, crowdsec, utils

Changes:
- wild-manifest.yaml → manifest.yaml with is, defaultConfig, requires
- Eliminated configReferences and serviceConfig fields
- Flattened kustomize.template/ to package root
- Template vars use flat defaultConfig keys
- install.sh paths updated for apps/ layout
- Updated 9 app manifests: cloud.smtp.* → apps.smtp.* with requires
- Removed dead install: true field from 6 app manifests

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-17 02:26:46 +00:00
Paul Payne
aaf74cc00c Upgrade OpenWeb UI 2026-05-16 22:22:33 +00:00
Paul Payne
c837d04f95 fix(deployment-api): update init container paths to use /app/api/data 2026-02-28 05:19:09 +00:00
Paul Payne
f9938f4ca6 Add Kubernetes manifests for communitarian application including deployments, services, ingress, middleware, PVC, and kustomization 2026-02-18 13:23:04 +00:00
Paul Payne
1e8425c98d Add README files for various applications: Decidim, Discourse, Example Admin, Example App, Ghost, Immich, Keila, Lemmy, Listmonk, Loomio, Matrix, Memcached, MySQL, Open WebUI, OpenProject, PostgreSQL, Redis, and vLLM 2026-02-17 07:58:44 +00:00
823 changed files with 43300 additions and 1292 deletions

362
ADDING-APPS-NOTES.md Normal file
View File

@@ -0,0 +1,362 @@
# 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 23 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 1015 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>`.
### 21. "No available server" from Traefik — kustomize label selector mismatch
If a deployment was originally created without kustomize labels in its selector (or with stale templates), re-deploying via `wild app deploy` cannot fix it because `spec.selector` on a Deployment is immutable once set. The Service selector will update (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**: `kubectl get endpoints <app> -n <namespace>` shows `<none>`; pod labels don't include `app: <name>` / `managedBy: kustomize` / `partOf: wild-cloud`
- **Fix**: Delete the affected deployments and re-apply kustomize — `kubectl delete deployment <name> -n <ns>` then `kubectl apply -k <instance>/apps/<app>/`
- **Root cause**: The Deployment selector is set correctly on first deploy, but if the resource already existed with only `component: web` (e.g., from a pre-kustomize deployment), the selector is stuck
### 22. Apps using same-origin iframes need SAMEORIGIN frame policy
The global Traefik `security-headers` middleware applies `X-Frame-Options: SAMEORIGIN` to all responses (changed from `DENY`). Apps that use iframes internally (like Etherpad's pad editor, which loads `../static/empty.html` in an iframe) were broken by the stricter `DENY` policy.
- **Root cause**: `DENY` prevents even same-origin iframes. `SAMEORIGIN` still blocks cross-origin embedding while allowing apps to embed their own sub-pages.
- **Symptoms**: JavaScript error `Blocked a frame with origin "https://..." from accessing a cross-origin frame` — occurs when an app creates a same-origin iframe and then the `X-Frame-Options: DENY` header causes the browser to block access to it.
- **Important**: Route-level Traefik middlewares run BEFORE entrypoint middlewares on the response path. A per-app middleware cannot override a global entrypoint middleware — the entrypoint middleware always runs last.
- **Fix applied globally**: Changed `frameDeny: true` to `X-Frame-Options: SAMEORIGIN` in `customResponseHeaders` in the crowdsec `security-headers` middleware.
- **Affects**: Etherpad (pad editor iframe), any app that loads sub-pages in iframes on the same domain.
### 24. nginx proxy containers: Docker DNS resolver (`127.0.0.11`) doesn't work in Kubernetes
Apps that use nginx as a frontend proxy (e.g., karrot-frontend) often ship with an nginx config template that uses Docker's embedded DNS resolver:
```nginx
resolver 127.0.0.11 valid=3s;
set $backend app-backend:8000;
proxy_pass http://$backend$request_uri;
```
This fails in Kubernetes with `send() failed (111: Connection refused) while resolving, resolver: 127.0.0.11`. 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.
### 25. ConfigMap changes don't restart pods — and `subPath` mounts never auto-update
When you update a ConfigMap and run `wild app deploy` (`kubectl apply`), the ConfigMap object is updated but **pods are not restarted**. You must explicitly restart them:
```bash
kubectl rollout restart deployment/<name> -n <namespace>
```
There's a further subtlety: ConfigMaps mounted with `subPath` are **never** live-updated in the pod filesystem, even if you wait. The pod must be restarted to pick up changes regardless of the `subPath` mount update behavior.
This means: after any ConfigMap change, always follow up with a rollout restart for affected deployments.
### 26. The `includeSelectors: true` mismatch affects every deployment in the app — check them all
When diagnosing "no available server" from Traefik (note #21), it's easy to focus only on the deployment that serves the HTTP route and miss other deployments in the same namespace. The `includeSelectors: true` label propagation affects every resource in the kustomization — if any deployment pre-existed with only `component: X` labels, its Service will have `<none>` endpoints and dependent services (backends, workers, Redis) will silently fail.
**Always run this after any selector-related redeploy**:
```bash
kubectl get endpoints --all-namespaces | grep "<none>"
```
Check every deployment in the affected namespace, not just the one Traefik is routing to. A Redis pod with `<none>` endpoints will cause the backend to crash with `ConnectionError` even though the Redis pod itself shows `1/1 Running`.
**Checklist when fixing a selector mismatch in an app**:
1. `kubectl get endpoints -n <app>` — identify all services with `<none>`
2. `kubectl get pods -n <app> --show-labels` — confirm which pods have old labels
3. Delete ALL affected deployments (not just the web one), then redeploy
4. Verify all endpoints are populated before declaring the app fixed
### 23. Post-deploy admin scripts for apps that require manual first-run steps
Some apps can't complete setup through the web UI until an initial admin account is created via a CLI command. Package these as shell scripts in `scripts/` alongside the kustomize files so users have a repeatable, documented way to run them.
**When to add a script**: If the app's README or upstream docs require running a management command after deploy (creating a superuser, registering a node, inviting the first user, etc.) — wrap it in a script.
**Always register scripts in `manifest.yaml`** — the web UI automatically shows a button with a parameter form for every script listed there. Without this, users have to drop to the CLI even though the UI fully supports it. Format:
```yaml
scripts:
- name: create-superuser
path: scripts/create-superuser.sh
description: "Short description shown in the UI."
params:
- name: EMAIL
description: Description shown in the param form
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):
- Require `KUBECONFIG`, `WILD_INSTANCE`, and `WILD_API_DATA_DIR` env vars; exit with a clear error if missing
- Read `namespace` from `config.yaml` via `yq` so the script doesn't hardcode values
- Auto-generate passwords with `openssl rand` if `PASSWORD` is not supplied
- Find the running pod by label (don't hardcode a pod name); fail clearly if none found
- Print credentials at the end with a "save this — it won't be shown again" warning
- Reference the script in the app README's "First-Time Setup" section in place of a raw `kubectl exec` command
**Non-interactive Django `createsuperuser`**:
```bash
kubectl exec -n <ns> <pod> -c <container> -- \
env DJANGO_SUPERUSER_PASSWORD="${PASSWORD}" \
python manage.py createsuperuser --email "${EMAIL}" --noinput
```
The `--noinput` flag reads the password from `DJANGO_SUPERUSER_PASSWORD`.
**Affected apps so far**: Eventyay (`scripts/create-superuser.sh`), Synapse (`scripts/create-user.sh`), Headscale (`scripts/register-node.sh`).
## 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 | ✅ working | nginx frontend: override template via ConfigMap to remove Docker DNS resolver (see note 24) |
| 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 | |

View File

@@ -6,69 +6,461 @@ This guide is for contributors and maintainers who want to create or modify Wild
Wild Cloud apps are Kubernetes applications packaged as Kustomize configurations with standardized conventions for configuration management, secrets handling, and deployment.
## Directory Structure
Each app has a two-level structure: an `app.yaml` meta file at the root, and version-specific files inside `versions/`. Version directories are named by **slot** (typically the major version), not by the full version string. The actual version lives in `manifest.yaml` inside the slot.
```
myapp/
├── app.yaml # App identity, latest slot pointer, upgrade routing
└── versions/
├── 2/ # Current latest slot (manifest.yaml has version: 2.3.1)
│ ├── manifest.yaml # Version-specific config (requires, defaultConfig, etc.)
│ ├── kustomization.yaml
│ └── *.yaml # Kubernetes resource templates
└── 1/ # Waypoint slot (only if upgrade routing needs it)
├── manifest.yaml
├── kustomization.yaml
└── *.yaml
```
Most apps have **one** version directory. A second appears only when a waypoint is needed for upgrade routing.
## Required Files
Each app directory must contain:
1. **`manifest.yaml`** - App metadata and configuration schema
2. **`kustomization.yaml`** - Kustomize configuration with Wild Cloud labels
3. **Resource files** - Kubernetes manifests (deployments, services, ingresses, etc.)
1. **`app.yaml`** - App identity, latest slot pointer, and upgrade routing rules
2. **`versions/{slot}/manifest.yaml`** - Version-specific configuration schema
3. **`versions/{slot}/kustomization.yaml`** - Kustomize configuration with Wild Cloud labels
4. **`versions/{slot}/*.yaml`** - Kubernetes resource templates
## App Manifest (`manifest.yaml`)
## App Meta (`app.yaml`)
The manifest defines the app's metadata, dependencies, configuration schema, and secret requirements.
This is the contents of an example `manifest.yaml` file for an app named "immich":
The `app.yaml` file at the app root defines identity, display info, and upgrade routing. These fields are version-independent.
```yaml
name: immich
is: immich
description: Immich is a self-hosted photo and video backup solution that allows you to store, manage, and share your media files securely.
version: 1.0.0
icon: https://immich.app/assets/images/logo.png
requires:
- name: pg
alias: db # Use a different reference name in templates
- name: redis # 'alias' and 'installedAs' default to 'name' value
defaultConfig:
serverImage: ghcr.io/immich-app/immich-server:release
mlImage: ghcr.io/immich-app/immich-machine-learning:release
timezone: UTC
serverPort: 2283
mlPort: 3003
storage: 250Gi
cacheStorage: 10Gi
redisHostname: "{{ .apps.redis.host }}" # Can reference 'requires' app configurations
dbHostname: "{{ .apps.pg.host }}"
db: # Configuration can be nested
name: immich
user: immich
host: "{{ .apps.pg.host }}"
port: "{{ .apps.pg.port }}"
domain: immich.{{ .cloud.domain }}
defaultSecrets:
- key: password # Random value will be generated if empty
- key: dbUrl
default: "postgresql://{{ .app.db.user }}:{{ .secrets.dbPassword }}@{{ .app.db.host }}:{{ .app.db.port }}/{{ .app.db.name }}?pool=30" # Can reference secrets and config as long as they have been defined before this line. Reference config with {{ .app.? }} and secrets with {{ .secrets.? }}
requiredSecrets:
- db.password # References postgres app via 'db' alias
- redis.auth # References redis app via 'redis' name (no alias)
latest: "1"
```
### Manifest Fields
### App Meta Fields
| Field | Required | Description |
|-------|----------|-------------|
| `name` | Yes | App identifier (must match directory name) |
| `is` | Yes | Unique id for this app. Used for `requires` mapping |
| `description` | Yes | Brief app description shown in listings |
| `version` | Yes | App version (follow upstream versioning) |
| `icon` | No | URL to app icon for UI display |
| `category` | Yes | Category for filtering in the app directory — use an established one or introduce a new one (see below) |
| `latest` | Yes | Slot name -- directory name under `versions/` (not a version string) |
| `upgrade` | No | Upgrade routing rules (see Upgrade Metadata below) |
### Categories
The UI uses `category` to populate the filter buttons in the App Directory. Use an established category when one fits — new categories are fine when an app clearly doesn't belong anywhere existing. Current categories:
| Category | Use for |
|----------|---------|
| `services` | Wild Cloud infrastructure services (traefik, metallb, cert-manager, etc.) |
| `database` | Database engines (postgres, mysql, redis, memcached) |
| `productivity` | Wikis, project management, file sharing, collaboration (nextcloud, outline, taiga, etc.) |
| `business` | ERP, CRM, accounting, forms, marketing automation (odoo, akaunting, mautic, etc.) |
| `communication` | Chat, messaging, video conferencing, email (mattermost, jitsi, listmonk, etc.) |
| `social` | Social networks and fediverse apps (mastodon, pixelfed, peertube, ghost, etc.) |
| `community` | Community organizing, civic tech, decision-making (loomio, decidim, discourse, etc.) |
| `education` | Learning management systems (moodle, chamilo) |
| `developer` | Dev tools, code hosting, package management (gitea, aptly, etc.) |
| `security` | VPN, password management (vaultwarden, headscale) |
| `ai` | AI/ML inference and interfaces (vllm, open-webui) |
| `media` | Photo and video management (immich) |
## Version Manifest (`versions/{slot}/manifest.yaml`)
Each version slot contains a `manifest.yaml` with version-specific installation details: dependencies, configuration schema, and secret requirements.
```yaml
version: 1.135.3-1
requires:
- name: pg
alias: db # Use a different reference name in templates
- name: redis # 'alias' and 'installedAs' default to 'name' value
defaultConfig:
namespace: immich
externalDnsDomain: "{{ .cloud.domain }}"
storage: 250Gi
cacheStorage: 10Gi
domain: immich.{{ .cloud.domain }}
tlsSecretName: wildcard-wild-cloud-tls
db: # Configuration can be nested
host: "{{ .apps.pg.host }}" # Can reference 'requires' app configurations
name: immich
user: immich
redis:
host: "{{ .apps.redis.host }}"
defaultSecrets:
- key: password # Random value will be generated if empty
- key: dbUrl
default: "postgresql://{{ .app.db.user }}:{{ .secrets.dbPassword }}@{{ .app.db.host }}:{{ .app.db.port }}/{{ .app.db.name }}?pool=30"
requiredSecrets:
- db.password # References postgres app via 'db' alias
- redis.auth # References redis app via 'redis' name (no alias)
```
### Version Manifest Fields
| Field | Required | Description |
|-------|----------|-------------|
| `version` | Yes | App version (see Versioning Convention below) |
| `requires` | No | List of dependency apps with optional aliases |
| `defaultConfig` | Yes | Default configuration values merged into operator's `config.yaml` |
| `defaultSecrets` | No | This app's secrets (no 'default' = auto-generated) |
| `requiredSecrets` | No | List of secrets from dependency apps (format: `<app-ref>.<key>`) |
### Versioning Convention
Wild Cloud uses a two-part version scheme inspired by Debian packaging: `<upstream>-<revision>`.
- **Upstream version** tracks the third-party software version (e.g., `v4.0.18`, `1.120.2`)
- **Packaging revision** tracks Wild Cloud packaging changes (template fixes, manifest cleanup, config restructuring) that don't change the upstream software version
**Examples:**
- `v4.0.18` — initial packaging of upstream v4.0.18
- `v4.0.18-1` — first packaging fix (no upstream change)
- `v4.0.18-2` — second packaging fix
- `v4.0.19` — upstream version bump, revision resets
**When to bump the packaging revision:** Any change to the app package that doesn't correspond to an upstream software update — manifest field changes, template improvements, kustomize restructuring, security context fixes, label corrections, etc.
**When to bump the upstream version:** When updating the container image tag or deploying a new version of the third-party software.
The web UI uses version comparison to detect available updates. If the deployed version differs from the wild-directory version, operators see an update indicator and can apply it from the app detail panel.
### Slot Naming Convention
Version directory names are **slot names**, not version strings. The slot is a stable label; the actual version lives in `manifest.yaml` inside the slot.
**Rules:**
- Use the **major version** as the slot name (e.g., `1`, `2`, `5`, `v3`)
- Preserve the `v` prefix if the upstream project uses it (e.g., `v1` for cert-manager)
- **Never** put packaging revisions (`-1`, `-2`) in directory names
- **Never** put minor/patch versions in directory names unless creating a waypoint that needs to be distinct from another slot at the same major version
**Examples:**
| App | Slot name | Version in manifest |
|-----|-----------|-------------------|
| Ghost 5.118.1-2 | `5` | `5.118.1-2` |
| cert-manager v1.17.2 | `v1` | `v1.17.2` |
| Immich 1.135.3-1 | `1` | `1.135.3-1` |
| Traefik v3.4 | `v3` | `v3.4` |
When bumping versions (upstream or packaging), update files inside the existing slot. Only create a new directory when you need a new waypoint.
### Upgrade Metadata
Most apps can upgrade from any version to any other version directly — no special metadata is needed. The `upgrade` field is **optional** and only required when an app has breaking changes that need controlled upgrade paths.
**When you don't need `upgrade:`** Simple apps (Ghost, Redis, most stateless apps) where any version can safely replace any other version. This is the 90% case — just bump the version and the system handles it as a single-step update.
**When you need `upgrade:`** Apps with breaking database schema changes, incompatible config formats, or upstream requirements for sequential version upgrades (e.g., Discourse requires stepping through major versions).
#### The `upgrade` block in `app.yaml`
Upgrade routing rules live in `app.yaml`, centralized for all versions. The system iteratively re-evaluates these rules after each waypoint step.
```yaml
# app.yaml
name: myapp
latest: "3"
upgrade:
from:
- version: ">=3.5.0" # Can upgrade directly from 3.5.x
- version: ">=3.4.0"
via: "2" # Must pass through slot "2" first (a waypoint)
- version: "<3.4.0"
blocked: true
notes: "Requires sequential major upgrades. See upstream docs."
preUpgrade:
backup: required # "none", "recommended", or "required"
```
Note: `latest` and `via` are **slot names** (directory names), not version strings. The system reads the actual version from the manifest inside each slot.
Version-specific upgrade behavior (migrations, configMigrations) lives in the version's `manifest.yaml`:
```yaml
# versions/3/manifest.yaml
version: 3.6.0
upgrade:
migrations:
pre:
- migrations/pre-deploy.yaml # K8s Job YAML paths relative to version dir
post:
- migrations/post-deploy.yaml
configMigrations:
oldKeyName: newKeyName # Renames config keys automatically
```
**`app.yaml` upgrade fields:**
| Field | Description |
|-------|-------------|
| `from` | List of version constraint rules, evaluated in order (first match wins) |
| `from[].version` | Version constraint: `>=`, `>`, `<=`, `<`, `=`, or `>0` (matches any) |
| `from[].via` | Waypoint slot name in `versions/` — upgrade must pass through this slot first |
| `from[].blocked` | If true, upgrade is blocked with an error message |
| `from[].notes` | Human-readable message shown when blocked or as context |
| `preUpgrade.backup` | Backup requirement: `"required"` blocks upgrade until backup is done, `"recommended"` shows a warning |
**Version `manifest.yaml` upgrade fields:**
| Field | Description |
|-------|-------------|
| `migrations.pre` | K8s Job YAMLs to run before deploying this version step |
| `migrations.post` | K8s Job YAMLs to run after deploying this version step |
| `configMigrations` | Map of old config key → new config key for automatic renaming |
#### Waypoint versions
When an upgrade requires passing through an intermediate version, add that version's files as a new slot in the `versions/` directory alongside the latest:
```
myapp/
├── app.yaml # Routing rules + latest pointer
└── versions/
├── 3/ # Latest slot (version: 3.6.0)
│ ├── manifest.yaml
│ ├── kustomization.yaml
│ └── *.yaml
└── 2/ # Waypoint slot (version: 2.8.0)
├── manifest.yaml
├── kustomization.yaml
└── *.yaml
```
Each waypoint is a complete app package. The system computes a chain automatically — for example, upgrading from 2.3.0 to 3.6.0 might produce: `2.3.0 → 2.8.0 (slot "2") → 3.6.0 (slot "3")`.
**Creating a waypoint:** The current latest slot becomes the waypoint (leave it in place), then create a new slot for the new major version:
```bash
# Current slot "2" (with version 2.8.0) stays as a waypoint
# Create the new slot for the next major version
mkdir -p wild-directory/myapp/versions/3
# ... add manifest.yaml, kustomization.yaml, *.yaml for 3.0.0 ...
# Update app.yaml: set latest to "3", add upgrade routing rules with via: "2"
```
#### Migration jobs
Migration jobs are K8s Job manifests that run database migrations or other one-time tasks during an upgrade step. They must be **idempotent** (safe to re-run) since a failed upgrade might be retried.
Place migration job files in the version slot directory and reference them from that version's `manifest.yaml`:
```yaml
# versions/3/migrations/db-migrate.yaml
apiVersion: batch/v1
kind: Job
metadata:
name: myapp-db-migrate
spec:
template:
spec:
restartPolicy: OnFailure
containers:
- name: migrate
image: myapp:3.6.0
command: ["bundle", "exec", "rake", "db:migrate"]
```
Each migration step belongs to the version that introduces the breaking change. If version 3.6.0 requires a schema migration, the migration lives in the slot `3/` directory.
#### Example: simple app with waypoint
```yaml
# myapp/app.yaml
name: myapp
latest: "2"
upgrade:
from:
- version: ">=1.0.0"
via: "1"
- version: "<1.0.0"
blocked: true
notes: "Versions before 1.0.0 are not supported"
preUpgrade:
backup: recommended
```
This creates a 2-step upgrade path: `1.x → slot "1" (e.g., version 1.0.0-1) → slot "2" (e.g., version 2.0.0)`. The waypoint at `versions/1/` is a complete app package used as an intermediate step.
### Adding a New Version
When an upstream app releases a new version, you update the Wild Directory package to track it. The process depends on whether the new version has breaking changes.
#### Simple version bump (no breaking changes)
Most version updates are simple — update the container image tag, adjust any changed config, and update the version in `manifest.yaml`. No directory rename or `app.yaml` change needed.
```bash
# 1. Update files inside the existing slot
# - Bump version in manifest.yaml (e.g., 1.2.0 → 1.3.0)
# - Update container image tags in deployment YAMLs
# - Adjust defaultConfig if the new version adds/changes config
vi wild-directory/myapp/versions/1/manifest.yaml
vi wild-directory/myapp/versions/1/deployment.yaml
# 2. app.yaml doesn't change — latest still points to slot "1"
# 3. Test
wild app add myapp && wild app deploy myapp
```
The directory structure stays the same:
```
myapp/
├── app.yaml # latest: "1" (unchanged)
└── versions/
└── 1/
├── manifest.yaml # version: 1.3.0 (bumped)
└── *.yaml
```
#### Version bump with breaking changes (waypoint required)
When the new version can't safely upgrade from all previous versions — e.g., a database schema change requires stepping through an intermediate version — create a new slot for the new major version, keep the old slot as a waypoint, and add routing rules.
```bash
# 1. The current slot (2/) becomes a waypoint — leave it in place
# 2. Create a new slot for the new major version
mkdir -p wild-directory/myapp/versions/3
# ... add new version files (manifest.yaml, kustomization.yaml, *.yaml) ...
# 3. Update app.yaml: point latest to new slot, add upgrade routing rules
```
```yaml
# app.yaml
name: myapp
latest: "3"
upgrade:
from:
- version: ">=2.5.0" # 2.5.x can upgrade directly
- version: ">=2.0.0"
via: "2" # Older 2.x must pass through slot 2 first
- version: "<2.0.0"
blocked: true
notes: "Upgrade to 2.x first. See upstream migration guide."
preUpgrade:
backup: recommended
```
The resulting directory:
```
myapp/
├── app.yaml # latest: "3", upgrade routing rules
└── versions/
├── 3/ # New latest (manifest.yaml has version: 3.0.0)
│ ├── manifest.yaml
│ └── *.yaml
└── 2/ # Waypoint (manifest.yaml has version: 2.5.0)
├── manifest.yaml
└── *.yaml
```
#### Version bump with database migrations
When the new version requires a schema migration (e.g., `ALTER TABLE`, new indexes, data transformations), add migration job files to the slot directory and reference them from the version's `manifest.yaml`. Since this is a minor/patch update within the same major version, update files in-place in the existing slot.
```bash
# 1. Update files inside the existing slot
# - Bump version in manifest.yaml (e.g., 2.0.0 → 2.1.0)
# - Update container image tags in deployment YAMLs
vi wild-directory/myapp/versions/2/manifest.yaml
vi wild-directory/myapp/versions/2/deployment.yaml
# 2. Add migration job files
mkdir -p wild-directory/myapp/versions/2/migrations
```
Create the migration job:
```yaml
# versions/2/migrations/pre-deploy.yaml
apiVersion: batch/v1
kind: Job
metadata:
name: myapp-migrate-2-1-0
namespace: myapp
spec:
backoffLimit: 3
template:
spec:
restartPolicy: OnFailure
securityContext:
runAsNonRoot: true
runAsUser: 999
seccompProfile:
type: RuntimeDefault
containers:
- name: migrate
image: myapp:2.1.0
command: ["bundle", "exec", "rake", "db:migrate"]
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop: [ALL]
env:
- name: DATABASE_URL
valueFrom:
secretKeyRef:
name: myapp-secrets
key: dbUrl
```
Reference the migration in the version manifest:
```yaml
# versions/2/manifest.yaml
version: 2.1.0
upgrade:
migrations:
pre:
- migrations/pre-deploy.yaml
defaultConfig:
# ...
```
`app.yaml` doesn't change — `latest` still points to slot `"2"`.
Migration jobs must be **idempotent** — safe to re-run if an upgrade is retried after a partial failure. Use `CREATE IF NOT EXISTS`, `ALTER TABLE IF NOT EXISTS`, etc.
**Pre vs post migrations:**
- `pre` — runs before deploying the new version's manifests (schema changes that the new code needs)
- `post` — runs after deploying (data backfills, cleanup that the old code didn't need)
#### Version bump with config key renames
When a version renames config keys (e.g., `dbHost``db.host`), use `configMigrations` to automatically rename them during upgrade:
```yaml
# versions/2/manifest.yaml
version: 2.1.0
upgrade:
configMigrations:
dbHost: db.host
dbPort: db.port
dbName: db.name
defaultConfig:
db:
host: "{{ .apps.pg.host }}"
port: "5432"
name: myapp
```
The system renames the keys in the instance's `config.yaml` before recompiling templates with the new version.
### Dependency Configuration
- Each dependency in `requires` can have:
@@ -121,15 +513,6 @@ Here's a comprehensive rundown of all config variables that get set during clust
- cloud.dockerRegistryHost - Docker registry hostname (e.g., "registry.internal.cloud2.payne.io")
##### SMTP Configuration (SMTP Service):
- cloud.smtp.host - SMTP server hostname
- cloud.smtp.port - SMTP port (typically "465" or "587")
- cloud.smtp.user - SMTP username
- cloud.smtp.from - Default 'from' email address
- cloud.smtp.tls - Enable TLS (true/false)
- cloud.smtp.startTls - Enable STARTTLS (true/false)
###### Backup Configuration:
- cloud.backup.root - Root path for backups
@@ -214,8 +597,7 @@ Configuration Flow
- ExternalDNS → cluster.externalDns.ownerId
- NFS → cloud.nfs.*
- Docker Registry → cloud.dockerRegistryHost, cluster.dockerRegistry.storage
- SMTP → cloud.smtp.*
4. Apps: Each app adds its configuration under apps.<name>.* based on its manifest
4. Apps: Each app adds its configuration under apps.<name>.* based on its manifest (including SMTP as an infrastructure app at apps.smtp.*)
#### Manifest App Reference Resolution:
@@ -282,19 +664,47 @@ This means individual resources can use simple, component-specific selectors lik
Resource files in this repository are **templates** that get compiled when users add apps via the web app, CLI, or API. Only variables defined in the manifest file's 'defaultConfig' section are available to the resource templates. Use gomplate syntax to reference configuration:
### External DNS
### Ingress
Ingress resources should include external-dns annotations for automatic DNS management:
Use this as the standard ingress template for all apps:
```yaml
annotations:
external-dns.alpha.kubernetes.io/target: {{ .domain }}
external-dns.alpha.kubernetes.io/cloudflare-proxied: "false"
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: myapp
annotations:
external-dns.alpha.kubernetes.io/target: {{ .externalDnsDomain }}
external-dns.alpha.kubernetes.io/cloudflare-proxied: "false"
external-dns.alpha.kubernetes.io/ttl: "60"
spec:
ingressClassName: traefik
rules:
- host: {{ .domain }}
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: myapp
port:
number: 80
tls:
- hosts:
- {{ .domain }}
secretName: {{ .tlsSecretName }}
```
Note: 'domain' must be defined in the app manifest's 'defaultConfig' section.
**Rules:**
- Always use `spec.ingressClassName: traefik` — do NOT use the `kubernetes.io/ingress.class` annotation (deprecated)
- Do NOT add `cert-manager.io/cluster-issuer` — the wildcard TLS cert is pre-distributed to app namespaces and managed centrally; adding this annotation causes cert-manager to issue a wrong per-app cert that overwrites the wildcard secret
- Do NOT add Traefik redirect annotations — HTTPS redirect is handled globally by Traefik
- `externalDnsDomain`, `domain`, and `tlsSecretName` must be defined in `defaultConfig`
This creates a CNAME from the app subdomain to the cluster domain (e.g., `myapp.cloud.example.com``cloud.example.com`).
### External DNS
The `external-dns.alpha.kubernetes.io/target` annotation creates a CNAME from the app subdomain to the cluster domain (e.g., `myapp.cloud.example.com``cloud.example.com`). Always set `cloudflare-proxied: "false"` since Wild Cloud manages its own TLS.
## App Dependencies and Reference Mapping
@@ -345,6 +755,7 @@ Apps requiring PostgreSQL or MySQL should include a database initialization job
- Use `restartPolicy: OnFailure`
- Include in `kustomization.yaml` resources
- Use appropriate security context (e.g., `runAsUser: 999` for PostgreSQL)
- **Write idempotent SQL**: For MySQL, use `CREATE USER IF NOT EXISTS ... ; ALTER USER ...` so re-running the job after a redeploy doesn't leave stale passwords. See "MySQL db-init User Password Idempotency" in Common Gotchas.
**Example apps:** `immich`, `gitea`, `openproject`, `discourse`
@@ -369,6 +780,44 @@ When apps need database URLs with embedded credentials, **always use a dedicated
Add `apps.myapp.dbUrl` to your manifest's `defaultSecrets`, and the system will generate the complete URL with embedded credentials automatically when the app is added.
### Backup/Restore Database Name Conventions
Wild Cloud's backup/restore system uses blue-green deployments. During restore, a standby copy of the app is created with a colored database name (e.g., `myapp_green`). The system automatically patches env vars in your Kubernetes resources to point to the standby database.
**How it works:** The restore system compiles your kustomize resources, finds env vars whose values match the original database name, and generates kustomize JSON patches to replace them with the standby database name. It uses env var naming conventions to distinguish database name fields from username fields (since both often have the same value).
**Env var naming guidelines for database-related fields:**
- **Database name env vars** should contain one of: `DATABASE`, `DB_NAME`, `DBNAME`, or `__DATABASE` in the env var name (e.g., `LISTMONK_db__database`, `DB_NAME`, `POSTGRES_DB`)
- **Database URL env vars** are detected by containing `://` in the value (e.g., `postgresql://user:pass@host/dbname`)
- **Username env vars** should contain `USER` in the name (e.g., `DB_USER`, `LISTMONK_db__user`) — these will NOT be patched even if the value matches the database name
- Avoid env var names that are ambiguous about whether they hold a database name or username
**Example — correct naming:**
```yaml
env:
- name: DB_NAME # Will be patched (contains "DB_NAME")
value: myapp
- name: DB_USER # Will NOT be patched (contains "USER")
value: myapp
- name: DATABASE_URL # Will be patched (contains "://")
value: "postgresql://myapp:secret@postgres/myapp"
```
## Deployment Strategy
Apps using `ReadWriteOnce` (RWO) persistent volumes **must** set `strategy: type: Recreate` on their Deployment. RWO volumes can only be attached to one pod at a time, so the default `RollingUpdate` strategy will cause Multi-Attach errors during updates (the new pod can't mount the volume while the old pod still holds it).
```yaml
spec:
replicas: 1
strategy:
type: Recreate
selector:
matchLabels:
component: web
```
## Security Requirements
### Security Contexts
@@ -506,13 +955,261 @@ labels:
component: server # Simple component label
```
## Common Gotchas
Lessons learned from deploying apps to Wild Cloud. These are the non-obvious issues that cause failures.
### Always Verify Docker Image Availability
Before packaging an app, confirm the image is **publicly accessible without authentication**:
```bash
docker pull registry.example.com/org/image:tag
# or check via curl for ghcr.io:
curl -s "https://ghcr.io/token?scope=repository:org/image:pull&service=ghcr.io" | python3 -c "import json,sys; print(json.load(sys.stdin).get('token','no token')[:20])"
```
Common pitfalls:
- **Bitnami images** moved away from Docker Hub — their `bitnami/appname` images may no longer be available; check `oci.bitnami.com` or use the official upstream image instead
- **ghcr.io images** frequently require authentication even when the repo is "public" on GitHub — test anonymous pull before committing to them
- **compdemocracy/polis** and some other community apps use private AWS ECR or require GitHub tokens — these cannot be packaged for general use without finding a public mirror
If no public image exists, note it clearly in the wild-directory `app.yaml` description rather than packaging a broken app.
### Re-run `wild app add` After Template Changes
`wild app deploy <app>` applies whatever is already compiled in the instance's `apps/` directory. It does **not** recompile templates from wild-directory. After modifying any template file in wild-directory, always re-run:
```bash
wild app add <app> # recompiles templates into the instance dir
wild app deploy <app> # applies the recompiled files
```
### PostgreSQL Connection Strings: Always Include `?sslmode=disable`
Wild Cloud's internal PostgreSQL does not have SSL enabled. Any app that constructs a PostgreSQL connection string must include `?sslmode=disable`, or the connection will 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 add `PGSSLMODE=disable` as an env var for apps that use libpq directly.
### Redis URLs Must Include the Password
Wild Cloud's Redis requires authentication. Apps that accept a Redis URL must include the password:
```yaml
# In manifest.yaml requiredSecrets:
requiredSecrets:
- redis.password
# In deployment.yaml env — use K8s env var expansion for the URL:
- 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 evaluated by Kubernetes at pod start from other env vars in the same container spec.
### Runtime-Specific Resource Defaults
Default resource requests vary by runtime. The standard 256Mi/50m is too low for heavier runtimes:
| Runtime | Suggested request | Notes |
|---------|------------------|-------|
| Go/Rust | 64128Mi | Very efficient |
| Python/Ruby/PHP | 128256Mi | Typical range |
| Node.js | 256512Mi | Add `NODE_OPTIONS=--max-old-space-size=<N>` to cap heap |
| JVM (Java/Kotlin/Scala) | 512Mi1Gi | Set `-Xmx` to keep within limit |
| Celery/Sidekiq workers | 256512Mi | Full worker process per instance |
For Node.js apps, cap memory use explicitly:
```yaml
- name: NODE_OPTIONS
value: "--max-old-space-size=768" # ~75% of 1Gi limit
```
### Health Check Probes for Django/FastAPI (ALLOWED_HOSTS)
Django and similar frameworks reject health probe requests because Kubernetes sends them directly to the pod IP, which is not in `ALLOWED_HOSTS`. Fix by adding a `Host` header to the probe:
```yaml
livenessProbe:
httpGet:
path: /
port: 8000
httpHeaders:
- name: Host
value: "{{ .domain }}"
```
### Images That Need Root + Special Capabilities
Some images run an init system (nginx, s6-overlay, custom entrypoint scripts) that requires elevated privileges to set up directories and drop to a lower-privileged user. These fail when `runAsNonRoot: true` or `capabilities.drop: [ALL]` is set.
**Pattern for nginx-based images** (e.g., community app frontends):
```yaml
spec:
securityContext:
runAsNonRoot: false
runAsUser: 0
seccompProfile:
type: RuntimeDefault
containers:
- name: app
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop: [ALL]
add: [CHOWN, SETUID, SETGID] # needed for nginx to drop to worker user
readOnlyRootFilesystem: false
```
**Pattern for linuxserver.io / s6-overlay images** (e.g., BookStack, most linuxserver images):
```yaml
spec:
securityContext:
runAsNonRoot: false
runAsUser: 0
seccompProfile:
type: RuntimeDefault
containers:
- name: app
securityContext:
readOnlyRootFilesystem: false # Only this — do NOT drop capabilities
```
Signs you need this pattern:
- `chown(...) failed (1: Operation not permitted)` — add `CHOWN` capability
- `Permission denied` on a directory during startup — try running as root
- Container exits immediately with code 1 despite image being "official"
### Hex-Format Secrets
Some apps require secrets in specific formats (e.g., exactly 64 hex chars for Outline). The default Wild Cloud secret generator produces random alphanumeric strings. Use `crypto.SHA256` in the manifest `default` field to derive a hex string from another secret:
```yaml
defaultSecrets:
- key: utilsSecret # generated first (alphanumeric)
- key: secretKey
default: '{{ crypto.SHA256 .secrets.utilsSecret }}' # hex derivative
```
The order of `defaultSecrets` matters — a referenced secret must appear before the entry that references it.
### Odoo (and Other Apps) Requiring Explicit Database Initialization
Some apps don't auto-initialize their database — they need to be told which database to use AND to install their schema. Odoo is the most common case: without explicit `-d DATABASE -i base` args, it starts in multi-database manager mode and all health checks fail.
```yaml
# In the container spec:
args: ["-d", "{{ .db.name }}", "-i", "base"]
```
The `-i base` flag is **idempotent**: it installs on first run and updates on subsequent runs. The downside is slightly slower startup on subsequent restarts. For very large apps, use an initContainer to run initialization separately from the main container.
Combine with a `startupProbe` to give the initialization time to complete:
```yaml
startupProbe:
httpGet:
path: /web/health
port: 8069
failureThreshold: 40 # 40 × 30s = 20 minutes
periodSeconds: 30
timeoutSeconds: 10
livenessProbe:
httpGet:
path: /web/health
port: 8069
initialDelaySeconds: 0 # startupProbe takes over startup detection
periodSeconds: 30
failureThreshold: 6
```
The `startupProbe` runs until it succeeds (or exhausts `failureThreshold`), at which point the liveness and readiness probes kick in with `initialDelaySeconds: 0`. This is cleaner than inflating `initialDelaySeconds` on the main probes.
### Gancio Re-deploy: "Non empty db" Crash
Gancio stores setup state in `config.json` on its data PVC. If the PVC is deleted or the app is re-added without wiping the database, gancio finds tables in PostgreSQL but no `config.json` and refuses to start.
**Fix**: Drop the schema before restarting:
```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;"
```
Scale the deployment to 0 before dropping the schema to avoid active connection errors.
### Heavy-Framework Startup Times and Probe Delays
Frameworks with heavy startup initialization (Laravel, Rails, Spring Boot, Symfony) run tasks like package discovery, autoload generation, database migrations, and asset compilation before accepting HTTP requests. This takes 23 minutes — much longer than the default 60-second initial delay.
The failure mode is a restart loop: pod runs for ~2 minutes, liveness probe fires and fails, container is killed, new pod starts. The app never gets far enough to become Ready.
For these runtimes, use extended probe delays:
```yaml
livenessProbe:
tcpSocket:
port: 8080
initialDelaySeconds: 120 # Give the framework time to fully initialize
periodSeconds: 30
failureThreshold: 6 # Tolerate transient failures during startup
readinessProbe:
tcpSocket:
port: 8080
initialDelaySeconds: 60
periodSeconds: 15
failureThreshold: 3
```
**Runtimes that typically need this:**
| Runtime | Suggested liveness initialDelaySeconds |
|---------|----------------------------------------|
| PHP/Laravel/Symfony | 120s |
| Ruby on Rails | 90120s |
| Spring Boot (JVM) | 90120s |
| Django (with migrations) | 6090s |
| Node.js | 60s (usually sufficient) |
### MySQL db-init User Password Idempotency
The common pattern `CREATE USER IF NOT EXISTS 'user'@'%' IDENTIFIED BY '${PASSWORD}'` only sets the password when the user is first created. If the MySQL user already exists from a previous deployment (e.g., after deleting and re-adding an app without dropping the database), the password is silently unchanged and the new deployment fails with "Access denied".
Use the `CREATE ... IF NOT EXISTS` + `ALTER USER` pattern to make db-init jobs idempotent regardless of prior state:
```sql
CREATE DATABASE IF NOT EXISTS ${DB_DATABASE_NAME};
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;
```
The `ALTER USER` line is safe even when the user was just created — it's a no-op in that case. This applies to MySQL/MariaDB only; PostgreSQL's `CREATE USER IF NOT EXISTS` does not exist but `DO $$ BEGIN IF NOT EXISTS ... END $$` patterns serve the same purpose.
## Validation Checklist
Before submitting a new or modified app, verify:
- [ ] **Manifest**
- [ ] **App Meta (`app.yaml`)**
- [ ] `name` matches directory name
- [ ] All required fields present (`name`, `description`, `version`, `defaultConfig`)
- [ ] `latest` points to a valid version in `versions/`
- [ ] `description` present
- [ ] `upgrade` rules correct (if applicable)
- [ ] **Version Manifest (`versions/{slot}/manifest.yaml`)**
- [ ] `version` field present with full version string (e.g., `1.135.3-1`)
- [ ] Slot directory follows naming convention (major version, e.g., `1`, `v1`)
- [ ] All required fields present (`version`, `defaultConfig`)
- [ ] All template variables defined in `defaultConfig`
- [ ] `defaultSecrets` uses maps with 'key' and 'default' attributes
- [ ] `requiredSecrets` references use `<app-ref>.<key>` format
@@ -526,10 +1223,16 @@ Before submitting a new or modified app, verify:
- [ ] **Resources**
- [ ] Security contexts on all pods (both pod-level and container-level)
- [ ] `strategy: type: Recreate` on deployments with ReadWriteOnce PVCs
- [ ] Simple component labels, no Helm-style labels
- [ ] Ingresses include external-dns annotations
- [ ] Database apps include init jobs (if applicable)
- [ ] **Images**
- [ ] Docker images are publicly accessible (test anonymous pull)
- [ ] Image tags are specific versions, not `latest` or branch names
- [ ] Architecture constraints (amd64-only) noted in manifest if applicable
- [ ] **Testing**
- [ ] Templates compile successfully with sample config
- [ ] App deploys without errors in test cluster

View File

@@ -1,5 +1,6 @@
- @README.md
- @ADDING-APPS.md
- ADDING-APPS.md is the expert advice you need when adding new apps to the Wild Cloud Directory.
## Finding good sources of documentation for adding a new app to the Wild Cloud Directory

692
admin/docs/design.md Normal file
View File

@@ -0,0 +1,692 @@
# Wild Directory Versioning and Upgrade System
Design specification for how Wild Cloud packages its third-party applications,
tracks versions, and manages upgrades between versions.
## Problem
Wild Cloud packages third-party applications as Kustomize templates. Each
application needs:
1. **Identity** that doesn't change between versions (name, description, icon)
2. **Version-specific files** (k8s manifests, config schema, secrets schema)
3. **Upgrade routing** when breaking changes prevent direct version jumps
4. A clear, low-friction workflow for maintainers bumping versions
The system must handle both simple apps (any version replaces any other) and
complex apps (database migrations, mandatory stepping stones between major
versions).
## Design
### Two-level structure: `app.yaml` + `versions/`
Each app in the Wild Directory has a root-level `app.yaml` for identity and a
`versions/` directory containing one or more version slots:
```
ghost/
+-- app.yaml
+-- versions/
+-- 5/
+-- manifest.yaml
+-- kustomization.yaml
+-- deployment.yaml
+-- ...
```
**`app.yaml`** holds version-independent fields: name, description, icon,
category, the `latest` pointer, and upgrade routing rules. These are facts
about the app itself, not about any particular release.
**`versions/{slot}/manifest.yaml`** holds version-specific fields: the precise
version string, dependency declarations, default config, default secrets,
deploy configuration, and per-version migration jobs. These are facts about a
particular release.
When the API installs an app, it reads both files and merges them. The
installed manifest in the instance data directory contains the complete picture
(name, description, icon, version, config, etc.).
### Version slots vs version strings
A version directory is a **slot**, not a precise version identifier.
The directory name is a stable label chosen by the maintainer. The actual
version string lives in `manifest.yaml` inside the directory. These are
intentionally decoupled:
| Concept | Where it lives | Example |
|---------|---------------|---------|
| Slot name | Directory name under `versions/` | `5` |
| Actual version | `version` field in `manifest.yaml` | `5.118.1-2` |
| Latest pointer | `latest` field in `app.yaml` | `5` |
| Waypoint pointer | `via` field in upgrade rules | `2` |
The slot name should be the simplest stable identifier that distinguishes it
from other slots. For semver apps, use the major version (`1`, `2`, `3`). For
apps with non-semver schemes, use whatever upstream version boundary makes
sense (`5`, `v4`, etc.).
**Packaging revisions** (`-1`, `-2`, etc.) never appear in directory names.
They only appear in `manifest.yaml`'s `version` field. A packaging revision
is a Wild Cloud-side fix (template improvement, security context change, config
restructure) that doesn't change the upstream software.
**Minor and patch versions** also don't require new directories unless they
introduce a breaking change that requires a waypoint. Updating Ghost from
5.118.1 to 5.119.0 means editing files inside `versions/5/` and bumping the
`version` field. The directory stays the same.
This follows how established package systems work:
| System | Directory/file identity | Where version lives |
|--------|------------------------|-------------------|
| Debian source packages | Package name (stable) | `debian/changelog` |
| Helm charts | Chart name directory | `Chart.yaml` |
| Homebrew | Formula file per package | Version attribute in file |
| Nix packages | Package name directory | Derivation attribute |
| FreeBSD ports | Port name directory | `Makefile` variable |
| **Wild Cloud** | **Slot directory** | **`manifest.yaml`** |
Wild Directory is a source package collection (templates compiled at install
time), not an artifact repository (pre-built binaries stored per version). The
source package pattern is the right fit.
### When directories are created and destroyed
**Most apps have exactly one version directory.** This is the common case for
apps where any version can replace any other (Ghost, Redis, most stateless
services).
**A second directory appears only when a waypoint is needed** -- when a
breaking change means some installed versions can't jump directly to the
latest and must pass through an intermediate version first.
**A directory is removed when it's no longer needed as a waypoint.** If
version `2/` was a waypoint for upgrading from 1.x to 3.x, but you later
decide to drop support for 1.x entirely, you can remove `2/` and update the
routing rules to block `<2.0.0`.
### `app.yaml` specification
```yaml
# Required
name: ghost # Must match directory name
is: ghost # Unique type id, used for `requires` matching
description: "Ghost is a..." # Shown in app listings
latest: "5" # Slot name pointing to a directory in versions/
# Optional
icon: "https://..." # URL to app icon
category: infrastructure # Category for filtering
# Optional -- only needed for apps with breaking upgrade paths
upgrade:
from:
- version: ">=3.0.0" # Constraint against installed version
- version: ">=2.0.0"
via: "2" # Slot name of waypoint directory
- version: "<2.0.0"
blocked: true
notes: "Upgrade to 2.x first"
preUpgrade:
backup: recommended # "none", "recommended", or "required"
```
Fields:
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `name` | string | yes | App identifier, must match directory name |
| `is` | string | yes | Type id for dependency matching |
| `description` | string | yes | Human-readable description |
| `latest` | string | yes | Slot name -- directory name under `versions/` |
| `icon` | string | no | URL to icon image |
| `category` | string | no | Grouping category (e.g. `infrastructure`) |
| `upgrade` | object | no | Routing rules for version upgrades |
| `upgrade.from` | list | no | Ordered list of version constraint rules |
| `upgrade.from[].version` | string | yes | Version constraint: `>=`, `>`, `<=`, `<`, `=`, or `>0` |
| `upgrade.from[].via` | string | no | Slot name of waypoint to pass through |
| `upgrade.from[].blocked` | bool | no | If true, upgrade is blocked |
| `upgrade.from[].notes` | string | no | Human-readable message |
| `upgrade.preUpgrade` | object | no | Pre-upgrade requirements |
| `upgrade.preUpgrade.backup` | string | no | `"none"`, `"recommended"`, or `"required"` |
**`latest` is a slot name, not a version string.** It tells the API which
directory to look in. The actual version is read from the manifest inside that
directory.
**`via` is also a slot name.** It tells the upgrade planner which waypoint
directory to route through.
### Version `manifest.yaml` specification
```yaml
# Required
version: 5.118.1-2
# Optional
requires:
- name: pg
alias: db
- name: redis
defaultConfig:
namespace: ghost
domain: ghost.{{ .cloud.domain }}
# ...
defaultSecrets:
- key: password
- key: dbUrl
default: "postgresql://..."
requiredSecrets:
- db.password
deploy:
# ...
scripts:
# ...
# Optional -- only when this version has step-specific upgrade behavior
upgrade:
migrations:
pre:
- migrations/pre-deploy.yaml
post:
- migrations/post-deploy.yaml
configMigrations:
oldKey: new.key
```
Fields:
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `version` | string | yes | Precise version string (e.g. `5.118.1-2`) |
| `requires` | list | no | Dependencies with optional aliases |
| `defaultConfig` | map | yes | Configuration schema merged into instance config |
| `defaultSecrets` | list | no | App's own secrets |
| `requiredSecrets` | list | no | Secrets needed from dependencies |
| `deploy` | object | no | Deployment behavior (CRDs, phases, etc.) |
| `scripts` | list | no | Operational scripts |
| `upgrade.migrations.pre` | list | no | K8s Job YAMLs to run before deploying this version |
| `upgrade.migrations.post` | list | no | K8s Job YAMLs to run after deploying this version |
| `upgrade.configMigrations` | map | no | Old config key -> new config key renames |
**Note the field split:** Identity and routing live in `app.yaml`. Installation
details live here. The version manifest never contains `name`, `is`,
`description`, `icon`, `category`, or `upgrade.from` routing rules.
### Versioning convention
Wild Cloud uses a two-part version scheme: `<upstream>-<revision>`.
- **Upstream** tracks the third-party software version: `5.118.1`, `v4.0.18`,
`1.135.3`
- **Revision** tracks Wild Cloud packaging changes: `-1`, `-2`, etc.
A version without a revision suffix (e.g., `5.118.1`) is the initial
packaging. Revision `-1` is the first packaging fix that doesn't change
upstream software.
This is the same convention Debian uses for its source packages.
## Upgrade system
### The 90% case: no routing rules needed
Most apps can upgrade from any version to any other directly. The app has no
`upgrade` block in `app.yaml`. When the API detects a version mismatch between
installed and latest, it produces a single-step plan: install the latest
version over the current one.
### The 10% case: routing rules in `app.yaml`
Apps with breaking changes between versions need routing rules. These live in
`app.yaml`, centralized for all versions. The rules are evaluated iteratively:
1. Compare installed version against `upgrade.from` rules (first match wins)
2. If the matching rule has `via`, step to that waypoint
3. Re-evaluate the same rules with the waypoint's version as the new
"installed" version
4. Repeat until reaching latest or hitting a block
This is iterative re-evaluation, not recursive descent into waypoint
manifests. The routing logic lives in one place (`app.yaml`), not scattered
across version manifests.
### Rule evaluation
Rules are evaluated in order. First match wins. This means more-specific
rules must come before less-specific ones:
```yaml
upgrade:
from:
- version: ">=3.0.0" # Already past the breaking change, direct
- version: ">=2.0.0"
via: "2" # Must step through 2.x waypoint first
- version: "<2.0.0"
blocked: true # Too old, no supported path
```
An installed version of `2.5.0` matches `>=2.0.0`, steps to waypoint `2/`
(which contains, say, version `2.8.0`). Re-evaluation with `2.8.0` matches
`>=2.0.0` again... but this time the waypoint IS the current version, so the
visited-set check catches the cycle.
To avoid this, ensure a rule exists that matches post-waypoint versions
and routes them directly:
```yaml
upgrade:
from:
- version: ">=2.5.0" # Post-waypoint versions go direct
- version: ">=2.0.0"
via: "2" # Pre-waypoint versions step through
- version: "<2.0.0"
blocked: true
```
After stepping to waypoint `2/` (version `2.8.0`), re-evaluation matches
`>=2.5.0` (no `via`), which means direct upgrade to latest.
### Version constraints
Supported operators: `>=`, `>`, `<=`, `<`, `=`, and the special `>0` (matches
any version). Constraints compare major.minor.patch numerically. The packaging
revision is ignored in constraint matching so `>=5.118.0` matches `5.118.1-2`.
### Waypoints
A waypoint is a version slot that exists solely as a stepping stone in an
upgrade path. It contains a complete app package (manifest, kustomize, k8s
resources) that can be installed and run.
Waypoints are created when an upstream app has breaking changes that require
sequential upgrades. For example, Discourse requires stepping through each
major version. A database schema migration might require running version N
before jumping to version N+2.
Waypoint directories are referenced by slot name in `via` fields:
```yaml
# app.yaml
latest: "3"
upgrade:
from:
- version: ">=2.5.0"
- version: ">=2.0.0"
via: "2"
```
```
myapp/
+-- app.yaml
+-- versions/
+-- 3/ # Latest (version: 3.0.0)
+-- 2/ # Waypoint (version: 2.8.0)
```
### Per-version migrations
Migration jobs (database schema changes, data transformations) are version-
specific, not app-level. They live in the version's directory and are
referenced from that version's `manifest.yaml`:
```yaml
# versions/3/manifest.yaml
version: 3.0.0
upgrade:
migrations:
pre:
- migrations/pre-deploy.yaml
post:
- migrations/post-deploy.yaml
```
```
versions/3/
+-- manifest.yaml
+-- migrations/
| +-- pre-deploy.yaml # K8s Job: runs before deploying 3.0.0
| +-- post-deploy.yaml # K8s Job: runs after deploying 3.0.0
+-- kustomization.yaml
+-- deployment.yaml
+-- ...
```
Migration jobs must be idempotent (safe to re-run on retry). Use
`CREATE IF NOT EXISTS`, `ALTER TABLE IF NOT EXISTS`, etc.
**Pre-migrations** run before deploying the new version's manifests. Use for
schema changes the new code depends on.
**Post-migrations** run after deploying. Use for data backfills or cleanup
the old code didn't need.
### Config migrations
When a version renames config keys, `configMigrations` in the version manifest
tells the system to rename them automatically in the instance's `config.yaml`
before recompiling templates:
```yaml
# versions/2/manifest.yaml
version: 2.0.0
upgrade:
configMigrations:
dbHost: db.host
dbPort: db.port
```
## API path resolution
The API resolves app paths through `resolveAppDir()`:
1. Read `app.yaml` from the app root
2. Use `latest` (or a specific requested slot) to find `versions/{slot}/`
3. Verify `manifest.yaml` exists in that directory
4. Return the directory path and parsed `AppMeta`
For old-style apps (no `app.yaml`, `manifest.yaml` at root), the function
falls back to the legacy path. This provides backward compatibility during
migration.
When installing, `applyMeta()` merges identity fields from `app.yaml` onto
the version manifest:
```
app.yaml + versions/5/manifest.yaml = installed manifest.yaml
(name, is, desc, (version, requires, (complete picture)
icon, category) defaultConfig, ...)
```
The installed manifest in the instance data directory always contains all
fields because the instance needs the complete picture -- it doesn't have
access to the Wild Directory's `app.yaml` at runtime.
## Drift detection
The API detects when an installed app's version differs from the Wild
Directory's latest. For new-style apps:
1. Read `app.yaml` to get the `latest` slot name
2. Read `versions/{latest}/manifest.yaml` to get the actual version string
3. Compare against the installed manifest's version
If they differ, the app is marked as having source drift with the available
version noted. The upgrade planner then computes whether an upgrade path
exists and how many steps it requires.
## Maintainer workflows
### Simple version bump
The app upstream releases a new version with no breaking changes. Edit files
in-place:
```bash
# 1. Update files inside the existing slot
vi wild-directory/ghost/versions/5/manifest.yaml # bump version: 5.119.0
vi wild-directory/ghost/versions/5/deployment.yaml # update image tag
# 2. app.yaml doesn't change -- latest still points to "5"
# 3. Test
wild app add ghost && wild app deploy ghost
```
No directory created, renamed, or deleted. No `app.yaml` change.
### Packaging fix
A Wild Cloud template fix, no upstream change:
```bash
# 1. Bump packaging revision in manifest
vi wild-directory/ghost/versions/5/manifest.yaml # version: 5.118.1-3
# 2. Fix whatever needs fixing
vi wild-directory/ghost/versions/5/deployment.yaml
# 3. app.yaml doesn't change
```
### Breaking upstream version (new waypoint)
The app upstream releases a major version with schema changes:
```bash
# 1. Current slot becomes a waypoint -- leave it in place
# versions/2/ stays, containing version 2.8.0
# 2. Create the new slot
mkdir -p wild-directory/myapp/versions/3
# ... add manifest.yaml, kustomization.yaml, *.yaml for 3.0.0 ...
# 3. Update app.yaml
```
```yaml
# app.yaml
name: myapp
latest: "3"
upgrade:
from:
- version: ">=2.5.0" # Post-waypoint, direct
- version: ">=2.0.0"
via: "2" # Step through waypoint
- version: "<2.0.0"
blocked: true
```
### Adding a new app
```bash
mkdir -p wild-directory/newapp/versions/1
# Create app.yaml
cat > wild-directory/newapp/app.yaml <<EOF
name: newapp
is: newapp
description: My new application
latest: "1"
EOF
# Create version manifest + k8s resources in versions/1/
# ... manifest.yaml, kustomization.yaml, deployment.yaml, etc.
```
## Examples
### Ghost (simple app, one slot)
```
ghost/
+-- app.yaml
| name: ghost
| is: ghost
| description: Ghost is a powerful app for...
| icon: https://...
| latest: "5"
+-- versions/
+-- 5/
+-- manifest.yaml # version: 5.118.1-2
+-- kustomization.yaml
+-- deployment.yaml
+-- service.yaml
+-- ingress.yaml
+-- namespace.yaml
+-- pvc.yaml
+-- db-init-job.yaml
```
Upgrading from 5.100.0 to 5.118.1-2: single step, no routing rules needed.
### e2e-test-app (two slots, waypoint)
```
e2e-test-app/
+-- app.yaml
| name: e2e-test-app
| is: e2e-test-app
| description: End-to-end test application...
| latest: "2"
| upgrade:
| from:
| - version: ">=1.0.0"
| via: "1"
| - version: "<1.0.0"
| blocked: true
| notes: "Versions before 1.0.0 are not supported"
| preUpgrade:
| backup: recommended
+-- versions/
+-- 2/
| +-- manifest.yaml # version: 2.0.0
| +-- kustomization.yaml
| +-- ...
+-- 1/
+-- manifest.yaml # version: 1.0.0-1
+-- kustomization.yaml
+-- ...
```
Upgrading from 0.5.0: blocked ("Versions before 1.0.0 are not supported").
Upgrading from 1.2.0: two steps -- 1.2.0 -> 1.0.0-1 (waypoint) -> 2.0.0.
### SMTP (infrastructure service, one slot)
```
smtp/
+-- app.yaml
| name: smtp
| is: smtp
| description: SMTP relay service...
| category: infrastructure
| latest: "1"
+-- versions/
+-- 1/
+-- manifest.yaml # version: 1.0.0
```
### Complex app with migrations
```
discourse/
+-- app.yaml
| name: discourse
| is: discourse
| description: Discourse forum...
| latest: "3"
| upgrade:
| from:
| - version: ">=2.5.0"
| - version: ">=2.0.0"
| via: "2"
| - version: "<2.0.0"
| blocked: true
| notes: "See upstream migration guide"
| preUpgrade:
| backup: required
+-- versions/
+-- 3/
| +-- manifest.yaml # version: 3.6.0
| +-- migrations/
| | +-- pre-deploy.yaml
| +-- kustomization.yaml
| +-- ...
+-- 2/
+-- manifest.yaml # version: 2.8.0
+-- kustomization.yaml
+-- ...
```
## Implementation notes
### Go types
```go
// app.yaml
type AppMeta struct {
Name string `yaml:"name"`
Is string `yaml:"is,omitempty"`
Description string `yaml:"description"`
Icon string `yaml:"icon,omitempty"`
Category string `yaml:"category,omitempty"`
Latest string `yaml:"latest"` // slot name
Upgrade *UpgradeConfig `yaml:"upgrade,omitempty"`
}
// version manifest.yaml (version-specific fields only)
type AppManifest struct {
Version string `yaml:"version"` // precise version string
Requires []AppDependency `yaml:"requires,omitempty"`
DefaultConfig map[string]interface{} `yaml:"defaultConfig,omitempty"`
DefaultSecrets []SecretDefinition `yaml:"defaultSecrets,omitempty"`
RequiredSecrets []string `yaml:"requiredSecrets,omitempty"`
Deploy *DeployConfig `yaml:"deploy,omitempty"`
Scripts []Script `yaml:"scripts,omitempty"`
Upgrade *UpgradeConfig `yaml:"upgrade,omitempty"` // migrations only
// Identity fields (Name, Is, Description, etc.) populated by applyMeta()
}
```
### Backward compatibility
The API supports both new-style (`app.yaml` + `versions/`) and old-style
(`manifest.yaml` at root, `.versions/` for waypoints). `resolveAppDir()`
checks for `app.yaml` first and falls back to the old layout.
`ComputeUpgradePlan()` similarly checks for `app.yaml` before falling back
to recursive manifest-based routing.
Old-style support exists for migration purposes. New apps should always use
the new-style layout.
### Source URI in installed manifests
The `source` field in installed manifests points to the app root
(`file:///wild-directory/ghost`), not the version directory. `Fetch()` and
`Update()` resolve through `resolveAppDir()` from the root, which reads
`app.yaml` to find the current latest slot.
## Design rationale
### Why decouple directory name from version string?
Coupling them creates unnecessary churn. Every packaging fix requires creating
a new directory and deleting the old one (`5.118.1/` -> `5.118.1-1/`). Every
minor upstream release does the same. This churn has no benefit -- the files
inside are what matter, not the directory name.
Decoupling follows the source package pattern used by every major package
system. The directory is a stable container. The version is metadata inside it.
### Why centralize routing rules in `app.yaml`?
Scattering `upgrade.from` rules across version manifests means each waypoint
must know how to route TO itself. Adding a new waypoint requires editing
multiple manifests. Reading the upgrade path requires opening every version
manifest in the chain.
Centralizing in `app.yaml` means one file describes the complete routing
topology. The upgrade planner reads one file and iteratively applies rules.
Adding a new waypoint means editing one file.
### Why iterative re-evaluation instead of recursive descent?
Recursive descent reads the target manifest, finds a `via`, recurses into the
waypoint, which has its own `via`, and so on. This scatters routing logic
across files and makes the path hard to reason about.
Iterative re-evaluation reads `app.yaml` once, applies rules, steps to a
waypoint, then applies the SAME rules again with the new version. The routing
table is evaluated like firewall rules -- same table, different input. This
is simpler to implement, debug, and maintain.
### Why not use a flat file for all versions?
A single YAML file listing all versions and their configs would centralize
everything but would grow unwieldy for apps with many k8s resource files.
Each version slot needs its own `kustomization.yaml`, deployment specs,
service definitions, etc. Directories are the natural unit.

32
admin/scripts/validate-app.sh Executable file
View 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
View 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())

View 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}';

6
akaunting/app.yaml Normal file
View File

@@ -0,0 +1,6 @@
name: akaunting
is: akaunting
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
latest: "3"

View File

@@ -0,0 +1,40 @@
# Akaunting
Akaunting is an open-source accounting software for small businesses and freelancers.
## Dependencies
- **MySQL** - Database for storing accounting data
- **SMTP** - For sending invoices and notifications
## Configuration
Key settings in `config.yaml`:
- **domain** - Where Akaunting will be accessible
- **storage** - Persistent volume size (default: `2Gi`)
- **companyName** - Your company name (default: `My Company`)
- **companyEmail** - Company contact email (defaults to your operator email)
- **adminEmail** - Admin account email (defaults to your operator email)
- **locale** - Language/locale setting (default: `en-US`)
- **smtp** - Email settings inherited from your Wild Cloud SMTP service
## First-Time Setup
1. Add and deploy the app:
```bash
wild app add akaunting
wild app deploy akaunting
```
2. Log in with:
- **Email**: value of `adminEmail` in your config
- **Password**: value of `adminPassword` in your `secrets.yaml`
3. Set up your company details, chart of accounts, and start recording income and expenses.
## Notes
- Akaunting supports multiple companies in a single installation — additional companies can be added after login
- The `dbPrefix` config (`aka_`) prefixes all database table names to avoid conflicts
- Invoices, payments, and reports can be exported as PDF

View File

@@ -0,0 +1,64 @@
apiVersion: batch/v1
kind: Job
metadata:
name: akaunting-db-init
labels:
component: db-init
spec:
template:
metadata:
labels:
component: db-init
spec:
restartPolicy: OnFailure
securityContext:
runAsNonRoot: true
runAsUser: 999
runAsGroup: 999
seccompProfile:
type: RuntimeDefault
containers:
- name: mysql-init
image: mysql:9.1.0
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop:
- ALL
readOnlyRootFilesystem: false
env:
- name: MYSQL_ROOT_PASSWORD
valueFrom:
secretKeyRef:
name: akaunting-secrets
key: mysql.rootPassword
- name: DB_HOSTNAME
value: {{ .db.host }}
- name: DB_PORT
value: "{{ .db.port }}"
- name: DB_DATABASE_NAME
value: {{ .db.name }}
- name: DB_USERNAME
value: {{ .db.user }}
- name: DB_PASSWORD
valueFrom:
secretKeyRef:
name: akaunting-secrets
key: dbPassword
command:
- /bin/bash
- -c
- |
set -e
until mysql -h ${DB_HOSTNAME} -P ${DB_PORT} -u root -p${MYSQL_ROOT_PASSWORD} -e "SELECT 1" 2>/dev/null; do
echo "Waiting for MySQL..."
sleep 2
done
mysql -h ${DB_HOSTNAME} -P ${DB_PORT} -u root -p${MYSQL_ROOT_PASSWORD} <<EOF
CREATE DATABASE IF NOT EXISTS ${DB_DATABASE_NAME} CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
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;
EOF
echo "Done"

View File

@@ -0,0 +1,114 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: akaunting
namespace: akaunting
spec:
replicas: 1
strategy:
type: Recreate
selector:
matchLabels:
component: web
template:
metadata:
labels:
component: web
spec:
securityContext:
runAsUser: 0
runAsNonRoot: false
seccompProfile:
type: RuntimeDefault
containers:
- name: akaunting
image: akaunting/akaunting:3.1.21
ports:
- name: http
containerPort: 80
protocol: TCP
env:
- name: AKAUNTING_SETUP
value: "true"
- name: APP_URL
value: https://{{ .domain }}
- name: LOCALE
value: {{ .locale }}
- name: DB_HOST
value: {{ .db.host }}
- name: DB_PORT
value: "{{ .db.port }}"
- name: DB_NAME
value: {{ .db.name }}
- name: DB_USERNAME
value: {{ .db.user }}
- name: DB_PASSWORD
valueFrom:
secretKeyRef:
name: akaunting-secrets
key: dbPassword
- name: DB_PREFIX
value: {{ .dbPrefix }}
- name: COMPANY_NAME
value: "{{ .companyName }}"
- name: COMPANY_EMAIL
value: {{ .companyEmail }}
- name: ADMIN_EMAIL
value: {{ .adminEmail }}
- name: ADMIN_PASSWORD
valueFrom:
secretKeyRef:
name: akaunting-secrets
key: adminPassword
- name: MAIL_MAILER
value: smtp
- name: MAIL_HOST
value: {{ .smtp.host }}
- name: MAIL_PORT
value: "{{ .smtp.port }}"
- name: MAIL_USERNAME
value: {{ .smtp.user }}
- name: MAIL_PASSWORD
valueFrom:
secretKeyRef:
name: akaunting-secrets
key: smtpPassword
- name: MAIL_FROM_ADDRESS
value: {{ .smtp.from }}
resources:
limits:
cpu: "1"
ephemeral-storage: 1Gi
memory: 512Mi
requests:
cpu: 50m
ephemeral-storage: 50Mi
memory: 256Mi
volumeMounts:
- name: akaunting-data
mountPath: /var/www/html/storage
subPath: storage
- name: akaunting-data
mountPath: /var/www/html/public/uploads
subPath: uploads
livenessProbe:
tcpSocket:
port: 80
initialDelaySeconds: 120
timeoutSeconds: 10
periodSeconds: 30
failureThreshold: 6
readinessProbe:
tcpSocket:
port: 80
initialDelaySeconds: 60
timeoutSeconds: 5
periodSeconds: 15
failureThreshold: 3
securityContext:
readOnlyRootFilesystem: false
volumes:
- name: akaunting-data
persistentVolumeClaim:
claimName: akaunting-data
restartPolicy: Always

View File

@@ -0,0 +1,26 @@
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: 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:
ingressClassName: traefik
rules:
- host: {{ .domain }}
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: akaunting
port:
number: 80
tls:
- hosts:
- {{ .domain }}
secretName: {{ .tlsSecretName }}

View File

@@ -0,0 +1,16 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: akaunting
labels:
- includeSelectors: true
pairs:
app: akaunting
managedBy: kustomize
partOf: wild-cloud
resources:
- namespace.yaml
- db-init-job.yaml
- deployment.yaml
- service.yaml
- ingress.yaml
- pvc.yaml

View File

@@ -0,0 +1,31 @@
version: 3.1.21-1
requires:
- name: mysql
- name: smtp
defaultConfig:
namespace: akaunting
externalDnsDomain: '{{ .cloud.domain }}'
domain: akaunting.{{ .cloud.domain }}
tlsSecretName: wildcard-wild-cloud-tls
storage: 2Gi
locale: en-US
dbPrefix: aka_
companyName: My Company
companyEmail: '{{ .operator.email }}'
adminEmail: '{{ .operator.email }}'
db:
host: '{{ .apps.mysql.host }}'
port: "3306"
name: akaunting
user: akaunting
smtp:
host: '{{ .apps.smtp.host }}'
port: '{{ .apps.smtp.port }}'
from: '{{ .apps.smtp.from }}'
user: '{{ .apps.smtp.user }}'
defaultSecrets:
- key: dbPassword
- key: adminPassword
- key: smtpPassword
requiredSecrets:
- mysql.rootPassword

View File

@@ -0,0 +1,11 @@
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: akaunting-data
namespace: akaunting
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: {{ .storage }}

View File

@@ -0,0 +1,13 @@
apiVersion: v1
kind: Service
metadata:
name: akaunting
namespace: akaunting
spec:
selector:
component: web
ports:
- name: http
port: 80
targetPort: 80
protocol: TCP

6
aptly/app.yaml Normal file
View File

@@ -0,0 +1,6 @@
name: aptly
is: aptly
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
latest: "1"

View File

@@ -0,0 +1,84 @@
# Aptly
Aptly is a Debian/Ubuntu APT repository management tool. Use it to create and publish private APT repositories that your machines can install from.
## Configuration
Key settings in your instance's `config.yaml`:
- **domain** - Where aptly will be accessible (default: `aptly.{your-cloud-domain}`)
- **storage** - Persistent volume size for packages (default: `5Gi`)
## Access
After deployment:
- `https://aptly.{your-cloud-domain}/` — Published repository root (for APT clients, no auth required)
- `https://aptly.{your-cloud-domain}/api` — REST API for managing repos and snapshots (requires credentials)
- `https://aptly.{your-cloud-domain}/public.key` — GPG public key for APT clients to import
## Credentials
The API is protected by HTTP basic authentication. The username defaults to `aptly` (configurable via `apiUser` in `config.yaml`). The password is auto-generated and stored in your instance's `secrets.yaml` under `apps.aptly.apiPassword`.
To retrieve it:
```bash
wild secret get aptly apiPassword
```
To use the API with curl:
```bash
curl -u aptly:{your-api-password} https://aptly.{your-cloud-domain}/api/version
```
## GPG Signing
A GPG signing key is generated automatically on first deployment and stored on the persistent volume. The public key is exported to `/public.key` and is accessible without authentication so APT clients can import it:
```bash
curl -fsSL https://aptly.{your-cloud-domain}/public.key | \
sudo tee /usr/share/keyrings/my-repo.gpg > /dev/null
```
## Common Workflows
Aptly is managed through its REST API or the `aptly` CLI (run inside the pod). The REST API docs are at https://www.aptly.info/doc/api/.
### Running aptly commands inside the pod
```bash
kubectl -n aptly exec -it deploy/aptly -- bash
```
### Publish a local repository
```bash
# Create a local repo
aptly repo create my-packages
# Add packages
aptly repo add my-packages /path/to/package.deb
# Publish
aptly publish repo my-packages
```
The published repo will be available at `https://aptly.{your-cloud-domain}/`.
### Configure a machine to use the repository
```bash
# Import the signing key
curl -fsSL https://aptly.{your-cloud-domain}/public.key | \
sudo tee /usr/share/keyrings/my-repo.gpg > /dev/null
# Add the repo
echo "deb [signed-by=/usr/share/keyrings/my-repo.gpg] https://aptly.{your-cloud-domain}/ stable main" | \
sudo tee /etc/apt/sources.list.d/my-repo.list
sudo apt update
```
## Notes
- All aptly data (GPG keys, database, package pool, published repos) is stored on the persistent volume at `/opt/aptly`.
- The REST API (port 8080) and the nginx file server (port 80) both run inside the pod via supervisord. The ingress routes to nginx (port 80).

View File

@@ -0,0 +1,78 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: aptly
spec:
replicas: 1
strategy:
type: Recreate
selector:
matchLabels:
component: server
template:
metadata:
labels:
component: server
spec:
securityContext:
seccompProfile:
type: RuntimeDefault
initContainers:
- name: init-gpg
image: urpylka/aptly:1.6.2
command:
- sh
- -c
- |
export GNUPGHOME=/opt/aptly/gpg
mkdir -p "$GNUPGHOME" /opt/aptly/public
chmod 700 "$GNUPGHOME"
if gpg --list-secret-keys 2>/dev/null | grep -q '^sec'; then
echo "GPG key already exists, skipping"
else
echo "Generating GPG signing key..."
printf '%%no-protection\nKey-Type: RSA\nKey-Length: 4096\nName-Real: Wild Cloud APT Repository\nName-Email: {{ .operatorEmail }}\nExpire-Date: 0\n%%commit\n' | gpg --batch --gen-key
echo "GPG key generated"
fi
gpg --export --armor > /opt/aptly/public/public.key
securityContext:
allowPrivilegeEscalation: false
volumeMounts:
- name: data
mountPath: /opt/aptly
- name: init-htpasswd
image: httpd:alpine
command:
- sh
- -c
- htpasswd -bc /opt/aptly/api.htpasswd "$API_USER" "$API_PASSWORD"
env:
- name: API_USER
value: "{{ .apiUser }}"
- name: API_PASSWORD
valueFrom:
secretKeyRef:
name: aptly-secrets
key: apiPassword
securityContext:
allowPrivilegeEscalation: false
volumeMounts:
- name: data
mountPath: /opt/aptly
containers:
- name: aptly
image: urpylka/aptly:1.6.2
ports:
- containerPort: 80
name: http
- containerPort: 8080
name: api
securityContext:
allowPrivilegeEscalation: false
volumeMounts:
- name: data
mountPath: /opt/aptly
volumes:
- name: data
persistentVolumeClaim:
claimName: aptly-pvc

View File

@@ -0,0 +1,25 @@
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
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:
ingressClassName: traefik
rules:
- host: {{ .domain }}
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: aptly
port:
number: 80
tls:
- hosts:
- {{ .domain }}
secretName: {{ .tlsSecretName }}

View File

@@ -0,0 +1,15 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: "{{ .namespace }}"
labels:
- includeSelectors: true
pairs:
app: aptly
managedBy: kustomize
partOf: wild-cloud
resources:
- namespace.yaml
- deployment.yaml
- service.yaml
- ingress.yaml
- pvc.yaml

View File

@@ -0,0 +1,11 @@
version: 1.6.2-3
defaultConfig:
namespace: aptly
externalDnsDomain: "{{ .cloud.domain }}"
domain: "aptly.{{ .cloud.domain }}"
tlsSecretName: wildcard-wild-cloud-tls
storage: 5Gi
apiUser: aptly
operatorEmail: "{{ .operator.email }}"
defaultSecrets:
- key: apiPassword

12
aptly/versions/1/pvc.yaml Normal file
View File

@@ -0,0 +1,12 @@
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: aptly-pvc
spec:
storageClassName: longhorn
accessModes:
- ReadWriteOnce
volumeMode: Filesystem
resources:
requests:
storage: {{ .storage }}

View File

@@ -0,0 +1,14 @@
apiVersion: v1
kind: Service
metadata:
name: aptly
spec:
ports:
- port: 80
targetPort: 80
name: http
- port: 8080
targetPort: 8080
name: api
selector:
component: server

6
baserow/app.yaml Normal file
View File

@@ -0,0 +1,6 @@
name: 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.
category: business
icon: https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/baserow.svg
latest: "2"

View File

@@ -0,0 +1,38 @@
# Baserow
Baserow is an open-source no-code database platform — a self-hosted alternative to Airtable.
## Dependencies
- **PostgreSQL** - Database for storing tables and workspace data
- **Redis** - Used for caching and background job queuing
## Configuration
Key settings in `config.yaml`:
- **domain** - Where Baserow will be accessible
- **storage** - Persistent volume size (default: `2Gi`)
- **db.name** - Database name (default: `baserow`)
## First-Time Setup
1. Add and deploy the app:
```bash
wild app add baserow
wild app deploy baserow
```
2. Visit the app URL and sign up to create your account.
3. The first user to sign up can be promoted to staff/admin via the Django admin panel at `/api/admin/`.
## Notes
- To make a user a staff member (admin):
```bash
kubectl exec -n baserow deploy/baserow -- python /baserow/backend/manage.py shell \
-c "from django.contrib.auth import get_user_model; u = get_user_model().objects.get(email='your@email.com'); u.is_staff = True; u.save()"
```
- The `secretKey` is auto-generated in `secrets.yaml`
- Baserow supports importing data from CSV, JSON, and XML files

View File

@@ -0,0 +1,63 @@
apiVersion: batch/v1
kind: Job
metadata:
name: baserow-db-init
namespace: {{ .namespace }}
labels:
component: db-init
spec:
template:
metadata:
labels:
component: db-init
spec:
securityContext:
runAsNonRoot: true
runAsUser: 999
runAsGroup: 999
seccompProfile:
type: RuntimeDefault
containers:
- name: db-init
image: postgres:17
command: ["/bin/bash", "-c"]
args:
- |
PGPASSWORD=${POSTGRES_ADMIN_PASSWORD} psql -h ${DB_HOSTNAME} -U postgres <<EOF
DO \$\$
BEGIN
IF NOT EXISTS (SELECT FROM pg_catalog.pg_roles WHERE rolname = '${DB_USERNAME}') THEN
CREATE USER ${DB_USERNAME} WITH ENCRYPTED PASSWORD '${DB_PASSWORD}';
ELSE
ALTER USER ${DB_USERNAME} WITH ENCRYPTED PASSWORD '${DB_PASSWORD}';
END IF;
END
\$\$;
SELECT 'CREATE DATABASE ${DB_DATABASE_NAME}' WHERE NOT EXISTS (SELECT FROM pg_database WHERE datname = '${DB_DATABASE_NAME}')\gexec
ALTER DATABASE ${DB_DATABASE_NAME} OWNER TO ${DB_USERNAME};
GRANT ALL PRIVILEGES ON DATABASE ${DB_DATABASE_NAME} TO ${DB_USERNAME};
EOF
env:
- name: POSTGRES_ADMIN_PASSWORD
valueFrom:
secretKeyRef:
name: postgres-secrets
key: password
- name: DB_HOSTNAME
value: "{{ .db.host }}"
- name: DB_DATABASE_NAME
value: "{{ .db.name }}"
- name: DB_USERNAME
value: "{{ .db.user }}"
- name: DB_PASSWORD
valueFrom:
secretKeyRef:
name: baserow-secrets
key: dbPassword
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop:
- ALL
restartPolicy: OnFailure

View File

@@ -0,0 +1,98 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: baserow
namespace: {{ .namespace }}
spec:
replicas: 1
strategy:
type: Recreate
selector:
matchLabels:
component: web
template:
metadata:
labels:
component: web
spec:
securityContext:
runAsNonRoot: false
runAsUser: 0
seccompProfile:
type: RuntimeDefault
containers:
- name: baserow
image: baserow/baserow:2.2.2
ports:
- name: http
containerPort: 80
protocol: TCP
env:
- name: BASEROW_PUBLIC_URL
value: https://{{ .domain }}
- name: DATABASE_HOST
value: "{{ .db.host }}"
- name: DATABASE_PORT
value: "{{ .db.port }}"
- name: DATABASE_NAME
value: "{{ .db.name }}"
- name: DATABASE_USER
value: "{{ .db.user }}"
- name: DATABASE_PASSWORD
valueFrom:
secretKeyRef:
name: baserow-secrets
key: dbPassword
- name: REDIS_HOST
value: "{{ .redis.host }}"
- name: REDIS_PORT
value: "6379"
- name: REDIS_PASSWORD
valueFrom:
secretKeyRef:
name: baserow-secrets
key: redis.password
- name: SECRET_KEY
valueFrom:
secretKeyRef:
name: baserow-secrets
key: secretKey
resources:
limits:
cpu: "2"
ephemeral-storage: 1Gi
memory: 2Gi
requests:
cpu: 50m
ephemeral-storage: 50Mi
memory: 256Mi
volumeMounts:
- name: baserow-data
mountPath: /baserow/data
livenessProbe:
httpGet:
path: /api/_health/
port: 80
httpHeaders:
- name: Host
value: "{{ .domain }}"
initialDelaySeconds: 300
timeoutSeconds: 10
periodSeconds: 30
failureThreshold: 6
readinessProbe:
httpGet:
path: /api/_health/
port: 80
httpHeaders:
- name: Host
value: "{{ .domain }}"
initialDelaySeconds: 120
timeoutSeconds: 5
periodSeconds: 15
failureThreshold: 6
volumes:
- name: baserow-data
persistentVolumeClaim:
claimName: baserow-data
restartPolicy: Always

View File

@@ -0,0 +1,26 @@
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: baserow
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:
ingressClassName: traefik
rules:
- host: {{ .domain }}
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: baserow
port:
number: 80
tls:
- hosts:
- {{ .domain }}
secretName: {{ .tlsSecretName }}

View File

@@ -0,0 +1,16 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: {{ .namespace }}
labels:
- includeSelectors: true
pairs:
app: baserow
managedBy: kustomize
partOf: wild-cloud
resources:
- namespace.yaml
- deployment.yaml
- service.yaml
- ingress.yaml
- pvc.yaml
- db-init-job.yaml

View File

@@ -0,0 +1,23 @@
version: 2.2.2-1
requires:
- name: postgres
- name: redis
defaultConfig:
namespace: baserow
externalDnsDomain: "{{ .cloud.domain }}"
domain: baserow.{{ .cloud.domain }}
tlsSecretName: wildcard-wild-cloud-tls
storage: 2Gi
db:
host: "{{ .apps.postgres.host }}"
port: "5432"
name: baserow
user: baserow
redis:
host: "{{ .apps.redis.host }}"
defaultSecrets:
- key: dbPassword
- key: secretKey
requiredSecrets:
- postgres.password
- redis.password

View File

@@ -0,0 +1,11 @@
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: baserow-data
namespace: {{ .namespace }}
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: {{ .storage }}

View File

@@ -0,0 +1,13 @@
apiVersion: v1
kind: Service
metadata:
name: baserow
namespace: {{ .namespace }}
spec:
selector:
component: web
ports:
- name: http
port: 80
targetPort: 80
protocol: TCP

6
bookstack/app.yaml Normal file
View File

@@ -0,0 +1,6 @@
name: bookstack
is: bookstack
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
latest: "26"

View File

@@ -0,0 +1,39 @@
# BookStack
BookStack is a simple, self-hosted platform for organising and storing information in a hierarchical structure of Books, Chapters, and Pages.
## Dependencies
- **MySQL** - Database for storing content and users
- **SMTP** - For email notifications and password resets
## Configuration
Key settings in `config.yaml`:
- **domain** - Where BookStack will be accessible
- **storage** - Persistent volume size (default: `2Gi`)
- **db.name** - Database name (default: `bookstack`)
- **smtp** - Email settings inherited from your Wild Cloud SMTP service
## First-Time Setup
1. Add and deploy the app:
```bash
wild app add bookstack
wild app deploy bookstack
```
2. Log in with the default admin credentials:
- **Email**: `admin@admin.com`
- **Password**: `password`
3. **Immediately change the admin email and password** via **Settings → Your Profile**.
4. Start creating Books, Chapters, and Pages from the dashboard.
## Notes
- BookStack uses the linuxserver.io image which runs as root internally — this is expected and required for the image to function
- SMTP settings are configured via the `smtp` section in your config
- The `appKey` secret is auto-generated and used for encrypting data — do not change it after content has been created

View File

@@ -0,0 +1,64 @@
apiVersion: batch/v1
kind: Job
metadata:
name: bookstack-db-init
labels:
component: db-init
spec:
template:
metadata:
labels:
component: db-init
spec:
restartPolicy: OnFailure
securityContext:
runAsNonRoot: true
runAsUser: 999
runAsGroup: 999
seccompProfile:
type: RuntimeDefault
containers:
- name: mysql-init
image: mysql:9.1.0
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop:
- ALL
readOnlyRootFilesystem: false
env:
- name: MYSQL_ROOT_PASSWORD
valueFrom:
secretKeyRef:
name: bookstack-secrets
key: mysql.rootPassword
- name: DB_HOSTNAME
value: {{ .db.host }}
- name: DB_PORT
value: "{{ .db.port }}"
- name: DB_DATABASE_NAME
value: {{ .db.name }}
- name: DB_USERNAME
value: {{ .db.user }}
- name: DB_PASSWORD
valueFrom:
secretKeyRef:
name: bookstack-secrets
key: dbPassword
command:
- /bin/bash
- -c
- |
set -e
until mysql -h ${DB_HOSTNAME} -P ${DB_PORT} -u root -p${MYSQL_ROOT_PASSWORD} -e "SELECT 1" 2>/dev/null; do
echo "Waiting for MySQL..."
sleep 2
done
mysql -h ${DB_HOSTNAME} -P ${DB_PORT} -u root -p${MYSQL_ROOT_PASSWORD} <<EOF
CREATE DATABASE IF NOT EXISTS ${DB_DATABASE_NAME} CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
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;
EOF
echo "Done"

View File

@@ -0,0 +1,106 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: bookstack
namespace: {{ .namespace }}
spec:
replicas: 1
strategy:
type: Recreate
selector:
matchLabels:
component: web
template:
metadata:
labels:
component: web
spec:
securityContext:
runAsUser: 0
runAsNonRoot: false
seccompProfile:
type: RuntimeDefault
containers:
- name: bookstack
image: lscr.io/linuxserver/bookstack:26.05.1
ports:
- name: http
containerPort: 80
protocol: TCP
env:
- name: PUID
value: "1000"
- name: PGID
value: "1000"
- name: APP_URL
value: https://{{ .domain }}
- name: DB_HOST
value: {{ .db.host }}
- name: DB_PORT
value: "{{ .db.port }}"
- name: DB_DATABASE
value: {{ .db.name }}
- name: DB_USERNAME
value: {{ .db.user }}
- name: DB_PASSWORD
valueFrom:
secretKeyRef:
name: bookstack-secrets
key: dbPassword
- name: APP_KEY
valueFrom:
secretKeyRef:
name: bookstack-secrets
key: appKey
- name: MAIL_DRIVER
value: smtp
- name: MAIL_HOST
value: {{ .smtp.host }}
- name: MAIL_PORT
value: "{{ .smtp.port }}"
- name: MAIL_FROM
value: {{ .smtp.from }}
- name: MAIL_USERNAME
value: {{ .smtp.user }}
- name: MAIL_PASSWORD
valueFrom:
secretKeyRef:
name: bookstack-secrets
key: smtpPassword
- name: MAIL_ENCRYPTION
value: tls
resources:
limits:
cpu: "1"
ephemeral-storage: 1Gi
memory: 512Mi
requests:
cpu: 50m
ephemeral-storage: 50Mi
memory: 128Mi
volumeMounts:
- name: bookstack-data
mountPath: /config
livenessProbe:
httpGet:
path: /
port: 80
initialDelaySeconds: 90
timeoutSeconds: 5
periodSeconds: 15
failureThreshold: 6
readinessProbe:
httpGet:
path: /
port: 80
initialDelaySeconds: 60
timeoutSeconds: 3
periodSeconds: 10
failureThreshold: 3
securityContext:
readOnlyRootFilesystem: false
volumes:
- name: bookstack-data
persistentVolumeClaim:
claimName: bookstack-data
restartPolicy: Always

View File

@@ -0,0 +1,26 @@
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: bookstack
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:
ingressClassName: traefik
rules:
- host: {{ .domain }}
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: bookstack
port:
number: 80
tls:
- hosts:
- {{ .domain }}
secretName: {{ .tlsSecretName }}

View File

@@ -0,0 +1,16 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: {{ .namespace }}
labels:
- includeSelectors: true
pairs:
app: bookstack
managedBy: kustomize
partOf: wild-cloud
resources:
- namespace.yaml
- db-init-job.yaml
- deployment.yaml
- service.yaml
- ingress.yaml
- pvc.yaml

View File

@@ -0,0 +1,26 @@
version: 26.05.1-2
requires:
- name: mysql
- name: smtp
defaultConfig:
namespace: bookstack
externalDnsDomain: '{{ .cloud.domain }}'
domain: bookstack.{{ .cloud.domain }}
tlsSecretName: wildcard-wild-cloud-tls
storage: 2Gi
db:
host: '{{ .apps.mysql.host }}'
port: '3306'
name: bookstack
user: bookstack
smtp:
host: '{{ .apps.smtp.host }}'
port: '{{ .apps.smtp.port }}'
from: '{{ .apps.smtp.from }}'
user: '{{ .apps.smtp.user }}'
defaultSecrets:
- key: appKey
- key: dbPassword
- key: smtpPassword
requiredSecrets:
- mysql.rootPassword

View File

@@ -0,0 +1,11 @@
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: bookstack-data
namespace: {{ .namespace }}
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: {{ .storage }}

View File

@@ -0,0 +1,13 @@
apiVersion: v1
kind: Service
metadata:
name: bookstack
namespace: {{ .namespace }}
spec:
selector:
component: web
ports:
- name: http
port: 80
targetPort: 80
protocol: TCP

6
bookwyrm/app.yaml Normal file
View File

@@ -0,0 +1,6 @@
name: 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.
category: social
icon: https://raw.githubusercontent.com/bookwyrm-social/bookwyrm/main/bookwyrm/static/images/logo.png
latest: "0"

View File

@@ -0,0 +1,35 @@
# BookWyrm
BookWyrm is a federated social reading platform — a self-hosted alternative to Goodreads, connected to the ActivityPub network.
## Dependencies
- **PostgreSQL** - Database for storing books, reviews, and user data
- **Redis** - Used for caching and background job queuing
- **SMTP** - For account verification and notifications
## Configuration
Key settings in `config.yaml`:
- **domain** - Where BookWyrm will be accessible
- **storage** - Persistent volume size (default: `2Gi`)
- **smtp** - Email settings inherited from your Wild Cloud SMTP service
## First-Time Setup
1. Add and deploy the app:
```bash
wild app add bookwyrm
wild app deploy bookwyrm
```
2. Visit the app URL — on first load, a setup wizard will guide you through creating the admin account and configuring the instance.
3. After setup, log in and configure the instance settings (name, description, registration policy) from the admin panel at `/settings/`.
## Notes
- BookWyrm federates with other BookWyrm instances and Mastodon via ActivityPub — users on other instances can follow and interact with your users
- Book metadata is imported from OpenLibrary and Inventaire — internet access is required for book search to work
- The `secretKey` is auto-generated in `secrets.yaml`

View File

@@ -0,0 +1,83 @@
apiVersion: batch/v1
kind: Job
metadata:
name: bookwyrm-db-init
namespace: bookwyrm
spec:
template:
metadata:
labels:
component: db-init
spec:
securityContext:
runAsNonRoot: true
runAsUser: 999
runAsGroup: 999
seccompProfile:
type: RuntimeDefault
restartPolicy: OnFailure
containers:
- name: db-init
image: postgres:16-alpine
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop:
- ALL
readOnlyRootFilesystem: false
env:
- name: PGHOST
value: "{{ .db.host }}"
- name: PGPORT
value: "{{ .db.port }}"
- name: PGUSER
value: postgres
- name: PGPASSWORD
valueFrom:
secretKeyRef:
name: bookwyrm-secrets
key: postgres.password
- name: BOOKWYRM_DB_USER
value: "{{ .db.user }}"
- name: BOOKWYRM_DB_NAME
value: "{{ .db.name }}"
- name: BOOKWYRM_DB_PASSWORD
valueFrom:
secretKeyRef:
name: bookwyrm-secrets
key: dbPassword
command:
- /bin/sh
- -c
- |
echo "Initializing BookWyrm database..."
# Create user if it doesn't exist
psql -c "
DO \$\$
BEGIN
IF NOT EXISTS (SELECT FROM pg_catalog.pg_roles WHERE rolname = '$BOOKWYRM_DB_USER') THEN
CREATE USER \"$BOOKWYRM_DB_USER\" WITH PASSWORD '$BOOKWYRM_DB_PASSWORD';
ELSE
ALTER USER \"$BOOKWYRM_DB_USER\" WITH PASSWORD '$BOOKWYRM_DB_PASSWORD';
END IF;
END
\$\$;
"
# Create database if it doesn't exist
if ! psql -lqt | cut -d \| -f 1 | grep -qw "$BOOKWYRM_DB_NAME"; then
echo "Creating database $BOOKWYRM_DB_NAME..."
createdb -O "$BOOKWYRM_DB_USER" "$BOOKWYRM_DB_NAME"
else
echo "Database $BOOKWYRM_DB_NAME already exists."
fi
# Grant privileges
psql -d "$BOOKWYRM_DB_NAME" -c "
GRANT ALL PRIVILEGES ON DATABASE \"$BOOKWYRM_DB_NAME\" TO \"$BOOKWYRM_DB_USER\";
GRANT ALL ON SCHEMA public TO \"$BOOKWYRM_DB_USER\";
GRANT USAGE ON SCHEMA public TO \"$BOOKWYRM_DB_USER\";
"
echo "Database initialization completed."

View File

@@ -0,0 +1,120 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: bookwyrm-worker
namespace: bookwyrm
spec:
replicas: 1
strategy:
type: RollingUpdate
selector:
matchLabels:
component: worker
template:
metadata:
labels:
component: worker
spec:
securityContext:
runAsNonRoot: false
runAsUser: 0
seccompProfile:
type: RuntimeDefault
containers:
- name: celery-worker
image: ghcr.io/bookwyrm-social/bookwyrm:v0.8.6
command:
- celery
- -A
- celerywyrm
- worker
- --pool=threads
- --concurrency=10
- -l
- info
- -Q
- high_priority,medium_priority,low_priority,streams,images,suggested_users,email,connectors,lists,inbox,imports,import_triggered,broadcast,misc
env:
- name: SECRET_KEY
valueFrom:
secretKeyRef:
name: bookwyrm-secrets
key: secretKey
- name: DEBUG
value: "false"
- name: DOMAIN
value: {{ .domain }}
- name: ALLOWED_HOSTS
value: {{ .domain }}
- name: EMAIL
value: {{ .smtp.from }}
- name: POSTGRES_HOST
value: {{ .db.host }}
- name: PGPORT
value: "{{ .db.port }}"
- name: POSTGRES_DB
value: {{ .db.name }}
- name: POSTGRES_USER
value: {{ .db.user }}
- name: POSTGRES_PASSWORD
valueFrom:
secretKeyRef:
name: bookwyrm-secrets
key: dbPassword
- name: REDIS_ACTIVITY_HOST
value: {{ .redis.host }}
- name: REDIS_ACTIVITY_PORT
value: "6379"
- name: REDIS_ACTIVITY_PASSWORD
valueFrom:
secretKeyRef:
name: bookwyrm-secrets
key: redis.password
- name: REDIS_ACTIVITY_URL
value: redis://:$(REDIS_ACTIVITY_PASSWORD)@{{ .redis.host }}:6379/0
- name: REDIS_BROKER_HOST
value: {{ .redis.host }}
- name: REDIS_BROKER_PORT
value: "6379"
- name: REDIS_BROKER_PASSWORD
valueFrom:
secretKeyRef:
name: bookwyrm-secrets
key: redis.password
- name: REDIS_BROKER_URL
value: redis://:$(REDIS_BROKER_PASSWORD)@{{ .redis.host }}:6379/1
- name: EMAIL_HOST
value: {{ .smtp.host }}
- name: EMAIL_PORT
value: "{{ .smtp.port }}"
- name: EMAIL_HOST_USER
value: {{ .smtp.user }}
- name: EMAIL_HOST_PASSWORD
valueFrom:
secretKeyRef:
name: bookwyrm-secrets
key: smtpPassword
- name: EMAIL_USE_TLS
value: "true"
- name: EMAIL_USE_SSL
value: "false"
- name: MEDIA_ROOT
value: /app/images
- name: STATIC_ROOT
value: /app/static
resources:
limits:
cpu: 1000m
ephemeral-storage: 1Gi
memory: 1Gi
requests:
cpu: 50m
ephemeral-storage: 50Mi
memory: 256Mi
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop:
- ALL
readOnlyRootFilesystem: false
restartPolicy: Always

View File

@@ -0,0 +1,149 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: bookwyrm
namespace: bookwyrm
spec:
replicas: 1
strategy:
type: Recreate
selector:
matchLabels:
component: web
template:
metadata:
labels:
component: web
spec:
securityContext:
runAsNonRoot: false
runAsUser: 0
seccompProfile:
type: RuntimeDefault
containers:
- name: bookwyrm
image: ghcr.io/bookwyrm-social/bookwyrm:v0.8.6
command: ["gunicorn", "bookwyrm.wsgi:application"]
ports:
- name: http
containerPort: 8000
protocol: TCP
env:
- name: SECRET_KEY
valueFrom:
secretKeyRef:
name: bookwyrm-secrets
key: secretKey
- name: DEBUG
value: "false"
- name: DOMAIN
value: {{ .domain }}
- name: ALLOWED_HOSTS
value: {{ .domain }}
- name: EMAIL
value: {{ .smtp.from }}
- name: POSTGRES_HOST
value: {{ .db.host }}
- name: PGPORT
value: "{{ .db.port }}"
- name: POSTGRES_DB
value: {{ .db.name }}
- name: POSTGRES_USER
value: {{ .db.user }}
- name: POSTGRES_PASSWORD
valueFrom:
secretKeyRef:
name: bookwyrm-secrets
key: dbPassword
- name: REDIS_ACTIVITY_HOST
value: {{ .redis.host }}
- name: REDIS_ACTIVITY_PORT
value: "6379"
- name: REDIS_ACTIVITY_PASSWORD
valueFrom:
secretKeyRef:
name: bookwyrm-secrets
key: redis.password
- name: REDIS_ACTIVITY_URL
value: redis://:$(REDIS_ACTIVITY_PASSWORD)@{{ .redis.host }}:6379/0
- name: REDIS_BROKER_HOST
value: {{ .redis.host }}
- name: REDIS_BROKER_PORT
value: "6379"
- name: REDIS_BROKER_PASSWORD
valueFrom:
secretKeyRef:
name: bookwyrm-secrets
key: redis.password
- name: REDIS_BROKER_URL
value: redis://:$(REDIS_BROKER_PASSWORD)@{{ .redis.host }}:6379/1
- name: EMAIL_HOST
value: {{ .smtp.host }}
- name: EMAIL_PORT
value: "{{ .smtp.port }}"
- name: EMAIL_HOST_USER
value: {{ .smtp.user }}
- name: EMAIL_HOST_PASSWORD
valueFrom:
secretKeyRef:
name: bookwyrm-secrets
key: smtpPassword
- name: EMAIL_USE_TLS
value: "true"
- name: EMAIL_USE_SSL
value: "false"
- name: EMAIL_SENDER_NAME
value: BookWyrm
- name: EMAIL_SENDER_DOMAIN
value: {{ .domain }}
- name: MEDIA_ROOT
value: /app/images
- name: STATIC_ROOT
value: /app/static
- name: ENABLE_THUMBNAIL_GENERATION
value: "true"
resources:
limits:
cpu: 1000m
ephemeral-storage: 2Gi
memory: 1Gi
requests:
cpu: 50m
ephemeral-storage: 50Mi
memory: 256Mi
volumeMounts:
- name: bookwyrm-images
mountPath: /app/images
- name: bookwyrm-static
mountPath: /app/static
- name: bookwyrm-exports
mountPath: /app/exports
livenessProbe:
tcpSocket:
port: 8000
initialDelaySeconds: 120
timeoutSeconds: 10
periodSeconds: 30
failureThreshold: 6
readinessProbe:
tcpSocket:
port: 8000
initialDelaySeconds: 60
timeoutSeconds: 5
periodSeconds: 15
failureThreshold: 3
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop:
- ALL
readOnlyRootFilesystem: false
volumes:
- name: bookwyrm-images
persistentVolumeClaim:
claimName: bookwyrm-images
- name: bookwyrm-static
emptyDir: {}
- name: bookwyrm-exports
emptyDir: {}
restartPolicy: Always

View File

@@ -0,0 +1,26 @@
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: 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:
ingressClassName: traefik
rules:
- host: {{ .domain }}
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: bookwyrm
port:
number: 80
tls:
- hosts:
- {{ .domain }}
secretName: {{ .tlsSecretName }}

View File

@@ -0,0 +1,17 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: {{ .namespace }}
labels:
- includeSelectors: true
pairs:
app: bookwyrm
managedBy: kustomize
partOf: wild-cloud
resources:
- namespace.yaml
- deployment.yaml
- deployment-worker.yaml
- service.yaml
- ingress.yaml
- pvc.yaml
- db-init-job.yaml

View File

@@ -0,0 +1,30 @@
version: 0.8.7-1
requires:
- name: postgres
- name: redis
- name: smtp
defaultConfig:
namespace: bookwyrm
externalDnsDomain: '{{ .cloud.domain }}'
domain: bookwyrm.{{ .cloud.domain }}
tlsSecretName: wildcard-wild-cloud-tls
storage: 2Gi
db:
host: '{{ .apps.postgres.host }}'
port: '{{ .apps.postgres.port }}'
name: bookwyrm
user: bookwyrm
redis:
host: '{{ .apps.redis.host }}'
smtp:
host: '{{ .apps.smtp.host }}'
port: '{{ .apps.smtp.port }}'
from: '{{ .apps.smtp.from }}'
user: '{{ .apps.smtp.user }}'
defaultSecrets:
- key: secretKey
- key: dbPassword
- key: smtpPassword
requiredSecrets:
- postgres.password
- redis.password

View File

@@ -0,0 +1,11 @@
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: bookwyrm-images
namespace: bookwyrm
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: {{ .storage }}

View File

@@ -0,0 +1,13 @@
apiVersion: v1
kind: Service
metadata:
name: bookwyrm
namespace: bookwyrm
spec:
selector:
component: web
ports:
- name: http
port: 80
targetPort: 8000
protocol: TCP

5
cert-manager/app.yaml Normal file
View File

@@ -0,0 +1,5 @@
name: cert-manager
is: cert-manager
description: X.509 certificate management for Kubernetes
category: services
latest: "v1"

View File

@@ -0,0 +1,20 @@
# cert-manager
X.509 certificate management for Kubernetes using Let's Encrypt.
## Upstream
The `upstream/cert-manager.yaml` file is downloaded from the official cert-manager release:
- Source: https://github.com/cert-manager/cert-manager/releases/download/v1.17.2/cert-manager.yaml
- Version: v1.17.2
To update, download the new version and replace the file.
## DNS Configuration
The upstream cert-manager deployment is patched via kustomize overlay (`upstream/kustomization.yaml`) to use external DNS resolvers (1.1.1.1, 8.8.8.8) instead of cluster DNS. This is required for ACME DNS-01 challenge verification.
## Maintenance
The `scripts/repair-certificates.sh` script can fix stuck certificates, orphaned ACME orders, and Cloudflare DNS cleanup errors. Run it manually when certificate issuance has issues.

View File

@@ -0,0 +1,19 @@
---
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: wildcard-internal-wild-cloud
namespace: cert-manager
spec:
secretName: wildcard-internal-wild-cloud-tls
dnsNames:
- "*.{{ .internalDomain }}"
- "{{ .internalDomain }}"
issuerRef:
name: letsencrypt-prod
kind: ClusterIssuer
duration: 2160h # 90 days
renewBefore: 360h # 15 days
privateKey:
algorithm: RSA
size: 2048

View File

@@ -0,0 +1,9 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- namespace.yaml
- letsencrypt-staging-dns01.yaml
- letsencrypt-prod-dns01.yaml
- internal-wildcard-certificate.yaml
- wildcard-certificate.yaml

View File

@@ -0,0 +1,25 @@
---
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
name: letsencrypt-prod
spec:
acme:
email: {{ .email }}
privateKeySecretRef:
name: letsencrypt-prod
server: https://acme-v02.api.letsencrypt.org/directory
solvers:
# DNS-01 solver for wildcard certificates
- dns01:
cloudflare:
apiTokenSecretRef:
name: cloudflare-api-token
key: api-token
selector:
dnsZones:
- "{{ .cloudflareDomain }}"
# Keep the HTTP-01 solver for non-wildcard certificates
- http01:
ingress:
class: traefik

View File

@@ -0,0 +1,25 @@
---
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
name: letsencrypt-staging
spec:
acme:
email: {{ .email }}
privateKeySecretRef:
name: letsencrypt-staging
server: https://acme-staging-v02.api.letsencrypt.org/directory
solvers:
# DNS-01 solver for wildcard certificates
- dns01:
cloudflare:
apiTokenSecretRef:
name: cloudflare-api-token
key: api-token
selector:
dnsZones:
- "{{ .cloudflareDomain }}"
# Keep the HTTP-01 solver for non-wildcard certificates
- http01:
ingress:
class: traefik

View File

@@ -0,0 +1,26 @@
version: v1.17.2-1
requires:
- name: traefik
defaultConfig:
namespace: cert-manager
cloudDomain: "{{ .cloud.domain }}"
internalDomain: "{{ .cloud.internalDomain }}"
email: "{{ .operator.email }}"
cloudflareDomain: "{{ .cloud.baseDomain }}"
scripts:
- name: repair-certificates
path: scripts/repair-certificates.sh
description: Fix stuck certificates, orphaned ACME orders, and Cloudflare DNS cleanup errors
defaultSecrets:
- key: cloudflareToken
deploy:
phases:
- path: upstream
waitFor:
name: cert-manager-webhook
timeout: "120s"
- path: .
createSecrets:
- name: cloudflare-api-token
entries:
api-token: cloudflareToken

View File

@@ -0,0 +1,89 @@
#!/bin/bash
# Repair stuck certificates, orphaned ACME orders, and Cloudflare DNS errors.
# This is an operational maintenance script, not part of deployment.
# Run manually when cert-manager has issues with certificate issuance.
#
# Usage: KUBECONFIG=/path/to/kubeconfig ./repair-certificates.sh
set -e
set -o pipefail
if [ -z "${KUBECONFIG}" ]; then
echo "ERROR: KUBECONFIG is not set"
exit 1
fi
needs_restart=false
echo "=== cert-manager Certificate Repair ==="
echo ""
echo "Checking for certificates with failed issuance attempts..."
stuck_certs=$(kubectl get certificates --all-namespaces -o json 2>/dev/null | \
jq -r '.items[] | select(.status.conditions[]? | select(.type=="Issuing" and .status=="False" and (.message | contains("404")))) | "\(.metadata.namespace) \(.metadata.name)"')
if [ -n "$stuck_certs" ]; then
echo "WARNING: Found certificates stuck with non-existent orders, recreating them..."
echo "$stuck_certs" | while read ns name; do
echo "Recreating certificate $ns/$name..."
cert_spec=$(kubectl get certificate "$name" -n "$ns" -o json | jq '.spec')
kubectl delete certificate "$name" -n "$ns"
echo "{\"apiVersion\":\"cert-manager.io/v1\",\"kind\":\"Certificate\",\"metadata\":{\"name\":\"$name\",\"namespace\":\"$ns\"},\"spec\":$cert_spec}" | kubectl apply -f -
done
needs_restart=true
sleep 5
else
echo "No certificates stuck with failed orders"
fi
echo "Checking for orphaned ACME orders..."
orphaned_orders=$(kubectl logs -n cert-manager deployment/cert-manager --tail=200 2>/dev/null | \
grep -E "failed to retrieve the ACME order.*404" 2>/dev/null | \
sed -n 's/.*resource_name="\([^"]*\)".*/\1/p' | \
sort -u || true)
if [ -n "$orphaned_orders" ]; then
echo "WARNING: Found orphaned ACME orders from logs"
for order in $orphaned_orders; do
echo "Deleting orphaned order: $order"
orders_found=$(kubectl get orders --all-namespaces 2>/dev/null | grep "$order" 2>/dev/null || true)
if [ -n "$orders_found" ]; then
echo "$orders_found" | while read ns name rest; do
kubectl delete order "$name" -n "$ns" 2>/dev/null || true
done
fi
done
needs_restart=true
else
echo "No orphaned orders found in logs"
fi
echo "Checking for Cloudflare DNS cleanup errors..."
cloudflare_errors=$(kubectl logs -n cert-manager deployment/cert-manager --tail=200 2>/dev/null | \
grep -c "Error: 7003.*Could not route" 2>/dev/null || echo "0")
if [ "$cloudflare_errors" -gt "0" ]; then
echo "WARNING: Found $cloudflare_errors Cloudflare DNS cleanup errors (stale DNS record references)"
echo "Deleting stuck challenges and orders to allow fresh start"
kubectl delete challenges --all -n cert-manager 2>/dev/null || true
kubectl delete orders --all -n cert-manager 2>/dev/null || true
needs_restart=true
else
echo "No Cloudflare DNS cleanup errors"
fi
if [ "$needs_restart" = true ]; then
echo "Restarting cert-manager to clear internal state..."
kubectl rollout restart deployment cert-manager -n cert-manager
kubectl rollout status deployment/cert-manager -n cert-manager --timeout=120s
echo "Waiting for cert-manager to recreate fresh challenges..."
sleep 15
else
echo "No restart needed - cert-manager state is clean"
fi
echo ""
echo "Repair complete. Check certificate status with:"
echo " kubectl get certificates --all-namespaces"
echo " kubectl get clusterissuers"

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,30 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- cert-manager.yaml
patches:
- target:
kind: Deployment
name: cert-manager
namespace: cert-manager
patch: |-
apiVersion: apps/v1
kind: Deployment
metadata:
name: cert-manager
namespace: cert-manager
spec:
template:
spec:
dnsPolicy: None
dnsConfig:
nameservers:
- "1.1.1.1"
- "8.8.8.8"
searches:
- cert-manager.svc.cluster.local
- svc.cluster.local
- cluster.local
options:
- name: ndots
value: "5"

View File

@@ -0,0 +1,19 @@
---
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: wildcard-wild-cloud
namespace: cert-manager
spec:
secretName: wildcard-wild-cloud-tls
dnsNames:
- "*.{{ .cloudDomain }}"
- "{{ .cloudDomain }}"
issuerRef:
name: letsencrypt-prod
kind: ClusterIssuer
duration: 2160h # 90 days
renewBefore: 360h # 15 days
privateKey:
algorithm: RSA
size: 2048

6
chamilo/app.yaml Normal file
View File

@@ -0,0 +1,6 @@
name: chamilo
is: chamilo
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
latest: "1"

View File

@@ -0,0 +1,39 @@
# Chamilo
Chamilo is an open-source Learning Management System (LMS) for creating and delivering online courses.
## Dependencies
- **MySQL** - Database for storing courses, users, and activity
- **SMTP** - For account verification and course notifications
## Configuration
Key settings in `config.yaml`:
- **domain** - Where Chamilo will be accessible
- **storage** - Persistent volume size (default: `2Gi`)
- **siteName** - Display name for the LMS (default: `Chamilo LMS`)
- **adminUser** - Admin account username (default: `admin`)
- **adminEmail** - Admin account email (defaults to your operator email)
- **smtp** - Email settings inherited from your Wild Cloud SMTP service
## First-Time Setup
1. Add and deploy the app:
```bash
wild app add chamilo
wild app deploy chamilo
```
2. Log in with:
- **Username**: value of `adminUser` in your config (default: `admin`)
- **Password**: value of `adminPassword` in your `secrets.yaml`
3. From the admin panel, create courses, enroll learners, and configure the LMS settings.
## Notes
- The admin panel is accessible via **Administration** in the top navigation after logging in
- Courses can be assigned to categories, and users can self-enroll or be enrolled by teachers
- SMTP is needed for sending course completion certificates and user notifications

View File

@@ -0,0 +1,64 @@
apiVersion: batch/v1
kind: Job
metadata:
name: chamilo-db-init
labels:
component: db-init
spec:
template:
metadata:
labels:
component: db-init
spec:
restartPolicy: OnFailure
securityContext:
runAsNonRoot: true
runAsUser: 999
runAsGroup: 999
seccompProfile:
type: RuntimeDefault
containers:
- name: mysql-init
image: mysql:9.1.0
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop:
- ALL
readOnlyRootFilesystem: false
env:
- name: MYSQL_ROOT_PASSWORD
valueFrom:
secretKeyRef:
name: chamilo-secrets
key: mysql.rootPassword
- name: DB_HOSTNAME
value: {{ .db.host }}
- name: DB_PORT
value: "{{ .db.port }}"
- name: DB_DATABASE_NAME
value: {{ .db.name }}
- name: DB_USERNAME
value: {{ .db.user }}
- name: DB_PASSWORD
valueFrom:
secretKeyRef:
name: chamilo-secrets
key: dbPassword
command:
- /bin/bash
- -c
- |
set -e
until mysql -h ${DB_HOSTNAME} -P ${DB_PORT} -u root -p${MYSQL_ROOT_PASSWORD} -e "SELECT 1" 2>/dev/null; do
echo "Waiting for MySQL..."
sleep 2
done
mysql -h ${DB_HOSTNAME} -P ${DB_PORT} -u root -p${MYSQL_ROOT_PASSWORD} <<EOF
CREATE DATABASE IF NOT EXISTS ${DB_DATABASE_NAME} CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
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;
EOF
echo "Done"

View File

@@ -0,0 +1,76 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: chamilo
namespace: {{ .namespace }}
spec:
replicas: 1
strategy:
type: Recreate
selector:
matchLabels:
component: web
template:
metadata:
labels:
component: web
spec:
securityContext:
runAsNonRoot: false
runAsUser: 0
seccompProfile:
type: RuntimeDefault
containers:
- name: chamilo
image: ipeos/chamilo:1.11.28
ports:
- name: http
containerPort: 80
protocol: TCP
env:
- name: PHP_TIMEZONE
value: UTC
- name: PHP_MEMORY_LIMIT
value: 256M
- name: PHP_MAX_EXECUTION_TIME
value: "300"
- name: PHP_UPLOAD_MAX_FILESIZE
value: 100M
- name: PHP_POST_MAX_SIZE
value: 100M
resources:
limits:
cpu: "1"
ephemeral-storage: 1Gi
memory: 512Mi
requests:
cpu: 50m
ephemeral-storage: 50Mi
memory: 256Mi
volumeMounts:
- name: chamilo-data
mountPath: /var/www/html
livenessProbe:
httpGet:
path: /
port: 80
initialDelaySeconds: 120
timeoutSeconds: 10
periodSeconds: 30
failureThreshold: 6
readinessProbe:
httpGet:
path: /
port: 80
initialDelaySeconds: 60
timeoutSeconds: 5
periodSeconds: 10
failureThreshold: 3
securityContext:
allowPrivilegeEscalation: true
readOnlyRootFilesystem: false
volumes:
- name: chamilo-data
persistentVolumeClaim:
claimName: chamilo-data
restartPolicy: Always

View File

@@ -0,0 +1,26 @@
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: chamilo
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:
ingressClassName: traefik
rules:
- host: {{ .domain }}
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: chamilo
port:
number: 80
tls:
- hosts:
- {{ .domain }}
secretName: {{ .tlsSecretName }}

View File

@@ -0,0 +1,16 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: {{ .namespace }}
labels:
- includeSelectors: true
pairs:
app: chamilo
managedBy: kustomize
partOf: wild-cloud
resources:
- namespace.yaml
- db-init-job.yaml
- deployment.yaml
- service.yaml
- ingress.yaml
- pvc.yaml

View File

@@ -0,0 +1,29 @@
version: 1.11.28-1
requires:
- name: mysql
- name: smtp
defaultConfig:
namespace: chamilo
externalDnsDomain: '{{ .cloud.domain }}'
domain: chamilo.{{ .cloud.domain }}
tlsSecretName: wildcard-wild-cloud-tls
storage: 2Gi
siteName: Chamilo LMS
adminUser: admin
adminEmail: '{{ .operator.email }}'
db:
host: '{{ .apps.mysql.host }}'
port: "3306"
name: chamilo
user: chamilo
smtp:
host: '{{ .apps.smtp.host }}'
port: '{{ .apps.smtp.port }}'
from: '{{ .apps.smtp.from }}'
user: '{{ .apps.smtp.user }}'
defaultSecrets:
- key: adminPassword
- key: dbPassword
- key: smtpPassword
requiredSecrets:
- mysql.rootPassword

View File

@@ -0,0 +1,11 @@
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: chamilo-data
namespace: {{ .namespace }}
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: {{ .storage }}

View File

@@ -0,0 +1,13 @@
apiVersion: v1
kind: Service
metadata:
name: chamilo
namespace: {{ .namespace }}
spec:
selector:
component: web
ports:
- name: http
port: 80
targetPort: 80
protocol: TCP

View File

@@ -0,0 +1,5 @@
name: 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.
category: community
latest: "0"

View File

@@ -0,0 +1,84 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: community-search
spec:
replicas: 1
strategy:
type: Recreate
selector:
matchLabels:
component: web
template:
metadata:
labels:
component: web
spec:
securityContext:
runAsNonRoot: true
runAsUser: 999
runAsGroup: 999
fsGroup: 999
seccompProfile:
type: RuntimeDefault
containers:
- name: community-search
image: payneio/community-search:0.1.2
imagePullPolicy: Always
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop:
- ALL
readOnlyRootFilesystem: false
ports:
- name: http
containerPort: 8080
env:
- name: COMMUNITY_SEARCH_BIND_ADDR
value: "0.0.0.0"
- name: COMMUNITY_SEARCH_PORT
value: "8080"
- name: COMMUNITY_SEARCH_DATA_DIR
value: "/data"
- name: COMMUNITY_SEARCH_INDEX_PATH
value: "/data/index"
- name: COMMUNITY_SEARCH_ADMIN_TOKEN
valueFrom:
secretKeyRef:
name: community-search-secrets
key: adminToken
- name: SELF_URL
value: "{{ .selfUrl }}"
- name: SELF_NAME
value: "{{ .selfName }}"
- name: RUST_LOG
value: "community_search=info,tower_http=warn"
volumeMounts:
- name: data
mountPath: /data
resources:
requests:
cpu: 50m
memory: 256Mi
limits:
cpu: 1
memory: 1Gi
readinessProbe:
httpGet:
path: /api/collections
port: http
initialDelaySeconds: 10
periodSeconds: 10
timeoutSeconds: 5
livenessProbe:
httpGet:
path: /api/collections
port: http
initialDelaySeconds: 30
periodSeconds: 30
timeoutSeconds: 5
volumes:
- name: data
persistentVolumeClaim:
claimName: community-search-data

View File

@@ -0,0 +1,25 @@
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
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:
ingressClassName: traefik
rules:
- host: {{ .domain }}
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: community-search
port:
number: 80
tls:
- hosts:
- {{ .domain }}
secretName: {{ .tlsSecretName }}

View File

@@ -0,0 +1,15 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: community-search
labels:
- includeSelectors: true
pairs:
app: community-search
managedBy: kustomize
partOf: wild-cloud
resources:
- namespace.yaml
- deployment.yaml
- service.yaml
- ingress.yaml
- pvc.yaml

View File

@@ -0,0 +1,12 @@
version: 0.1.2_1
requires: []
defaultConfig:
namespace: community-search
externalDnsDomain: '{{ .cloud.domain }}'
domain: search.{{ .cloud.domain }}
tlsSecretName: wildcard-wild-cloud-tls
storage: 2Gi
selfUrl: 'https://search.{{ .cloud.domain }}'
selfName: 'Community Search'
defaultSecrets:
- key: adminToken

View File

@@ -0,0 +1,10 @@
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: community-search-data
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: {{ .storage }}

View File

@@ -0,0 +1,13 @@
apiVersion: v1
kind: Service
metadata:
name: community-search
spec:
type: ClusterIP
ports:
- name: http
port: 80
protocol: TCP
targetPort: 8080
selector:
component: web

5
coredns/app.yaml Normal file
View File

@@ -0,0 +1,5 @@
name: coredns
is: coredns
description: DNS server for internal cluster DNS resolution
category: services
latest: "v1"

View File

@@ -0,0 +1,45 @@
# CoreDNS
- https://kubernetes.io/docs/concepts/services-networking/dns-pod-service/
- https://github.com/kubernetes/dns/blob/master/docs/specification.md
- https://coredns.io/
CoreDNS has the `kubernetes` plugin, so it returns all k8s service endpoints in well-known format.
All services and pods are registered in CoreDNS.
- <service-name>.<namespace>.svc.cluster.local
- <service-name>.<namespace>
- <service-name> (if in the same namespace)
- <pod-ipv4-address>.<namespace>.pod.cluster.local
- <pod-ipv4-address>.<service-name>.<namespace>.svc.cluster.local
Any query for a resource in the `internal.$DOMAIN` domain will be given the IP of the Traefik proxy. We expose the CoreDNS server in the LAN via MetalLB just for this capability.
## Default CoreDNS Configuration
This is the default CoreDNS configuration, for reference:
```txt
.:53 {
errors
health { lameduck 5s }
ready
log . { class error }
prometheus :9153
kubernetes cluster.local in-addr.arpa ip6.arpa {
pods insecure
fallthrough in-addr.arpa ip6.arpa
ttl 30
}
forward . /etc/resolv.conf { max_concurrent 1000 }
cache 30 {
disable success cluster.local
disable denial cluster.local
}
loop
reload
loadbalance
}
```

View File

@@ -0,0 +1,28 @@
---
apiVersion: v1
kind: ConfigMap
metadata:
name: coredns-custom
namespace: kube-system
data:
# Custom server block for internal domains. All internal domains should
# resolve to the cluster proxy.
internal.server: |
{{ .internalDomain }} {
errors
cache 30
reload
template IN A {
match (.*)\.{{ .internalDomain | strings.ReplaceAll "." "\\." }}\.
answer "{{`{{ .Name }}`}} 60 IN A {{ .loadBalancerIp }}"
}
template IN AAAA {
match (.*)\.{{ .internalDomain | strings.ReplaceAll "." "\\." }}\.
rcode NXDOMAIN
}
}
# Custom override to set external resolvers.
external.override: |
forward . {{ .externalResolver }} {
max_concurrent 1000
}

View File

@@ -0,0 +1,5 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- coredns-custom-config.yaml

View File

@@ -0,0 +1,13 @@
version: v1.12.0-1
requires:
- name: metallb
defaultConfig:
namespace: kube-system
internalDomain: "{{ .cloud.internalDomain }}"
loadBalancerIp: "{{ .apps.metallb.loadBalancerIp }}"
externalResolver: "8.8.8.8"
deploy:
restartDeployments:
- coredns
waitForRollout:
name: coredns

5
crowdsec/app.yaml Normal file
View File

@@ -0,0 +1,5 @@
name: crowdsec
is: crowdsec
description: CrowdSec security engine with Traefik bouncer for threat detection and rate limiting
category: services
latest: "v1"

View File

@@ -0,0 +1,118 @@
# CrowdSec Security Service
CrowdSec is an open-source security engine that analyzes traffic patterns and blocks malicious actors. This service integrates CrowdSec with Traefik to provide automatic threat detection and rate limiting for all Wild Cloud ingresses.
## Components
- **CrowdSec Agent**: Analyzes traffic patterns, maintains decision lists, and connects to the CrowdSec threat intelligence network
- **Traefik Bouncer**: Integrates with Traefik via ForwardAuth to enforce CrowdSec decisions
- **Security Middlewares**: Traefik middleware for rate limiting and security headers
## Default Protection
After installation, **all ingresses are automatically protected** with:
- Threat detection (blocks known malicious IPs and attack patterns)
- Rate limiting (100 requests per minute per IP)
- Security headers (HSTS, XSS protection, content-type sniffing prevention)
## Configuration
Configuration is stored in `config.yaml` under `apps.crowdsec`:
```yaml
apps:
crowdsec:
rateLimitAverage: "100"
rateLimitBurst: "100"
```
## Secrets
Secrets are stored in `secrets.yaml` under `apps.crowdsec`:
```yaml
apps:
crowdsec:
agentPassword: <auto-generated>
bouncerApiKey: <auto-generated>
```
## Opting Out
To disable CrowdSec protection for a specific ingress (e.g., webhooks, health checks):
```yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
annotations:
traefik.ingress.kubernetes.io/router.middlewares: ""
```
## Using Only Rate Limiting
To use rate limiting without threat detection:
```yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
annotations:
traefik.ingress.kubernetes.io/router.middlewares: crowdsec-rate-limit@kubernetescrd
```
## Monitoring
View active decisions (blocked IPs):
```bash
kubectl exec -n crowdsec deploy/crowdsec -- cscli decisions list
```
View registered bouncers:
```bash
kubectl exec -n crowdsec deploy/crowdsec -- cscli bouncers list
```
View alerts:
```bash
kubectl exec -n crowdsec deploy/crowdsec -- cscli alerts list
```
View metrics (Prometheus format):
```bash
kubectl port-forward -n crowdsec svc/crowdsec-lapi 6060:6060
curl http://localhost:6060/metrics
```
## Threat Intelligence
CrowdSec includes these detection collections:
- `crowdsecurity/traefik` - Traefik-specific detections
- `crowdsecurity/http-cve` - Known HTTP CVE exploits
- `crowdsecurity/whitelist-good-actors` - Whitelist for known good actors (search engines, etc.)
Enabled scenarios:
- HTTP probing and path traversal detection
- Bad user agent detection
- Sensitive file access attempts
- HTTP crawling detection
- SSH brute force (if exposed)
## Troubleshooting
**Bouncer not connecting to agent:**
```bash
kubectl logs -n crowdsec deploy/traefik-crowdsec-bouncer
kubectl exec -n crowdsec deploy/crowdsec -- cscli bouncers list
```
**Check if middleware is applied:**
```bash
kubectl get middleware -n crowdsec
kubectl describe ingressroute -n <app-namespace> <route-name>
```
**View CrowdSec logs:**
```bash
kubectl logs -n crowdsec deploy/crowdsec
```

View File

@@ -0,0 +1,43 @@
apiVersion: v1
kind: ConfigMap
metadata:
name: crowdsec-config
namespace: crowdsec
labels:
app: crowdsec
managedBy: kustomize
partOf: wild-cloud
data:
acquis.yaml: |
filenames:
- /var/log/containers/traefik-*_traefik_*.log
force_inotify: true
poll_without_inotify: true
labels:
type: containerd
program: traefik
profiles.yaml: |
name: default_ip_remediation
debug: false
filters:
- Alert.Remediation == true && Alert.GetScope() == "Ip"
decisions:
- type: ban
duration: 4h
on_success: break
---
name: default_range_remediation
debug: false
filters:
- Alert.Remediation == true && Alert.GetScope() == "Range"
decisions:
- type: ban
duration: 4h
scope: Range
on_success: break
postoverflows.yaml: |
# Post-overflow configuration for crowdsec
name: "rdns"
debug: false
filter: "evt.Enriched.IsoCode != ''"
# Add reverse DNS enrichment

View File

@@ -0,0 +1,134 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: crowdsec
namespace: crowdsec
labels:
app: crowdsec
managedBy: kustomize
partOf: wild-cloud
spec:
replicas: 1
strategy:
type: Recreate
selector:
matchLabels:
app: crowdsec
managedBy: kustomize
partOf: wild-cloud
template:
metadata:
labels:
app: crowdsec
managedBy: kustomize
partOf: wild-cloud
spec:
serviceAccountName: crowdsec
affinity:
podAffinity:
preferredDuringSchedulingIgnoredDuringExecution:
- weight: 100
podAffinityTerm:
labelSelector:
matchLabels:
app: traefik
topologyKey: kubernetes.io/hostname
securityContext:
runAsUser: 0
runAsNonRoot: false
fsGroup: 0
seccompProfile:
type: RuntimeDefault
containers:
- name: crowdsec
image: crowdsecurity/crowdsec:v1.7.8
env:
- name: COLLECTIONS
value: "crowdsecurity/traefik crowdsecurity/http-cve crowdsecurity/whitelist-good-actors crowdsecurity/iptables crowdsecurity/linux"
- name: PARSERS
value: "crowdsecurity/traefik-logs crowdsecurity/http-logs crowdsecurity/nginx-logs"
- name: SCENARIOS
value: "crowdsecurity/http-crawl-non_statics crowdsecurity/http-probing crowdsecurity/http-sensitive-files crowdsecurity/http-bad-user-agent crowdsecurity/http-path-traversal-probing crowdsecurity/ssh-bf crowdsecurity/ssh-slow-bf"
- name: POSTOVERFLOWS
value: "crowdsecurity/rdns crowdsecurity/cdn-whitelist"
- name: GID
value: "1000"
- name: LEVEL_TRACE
value: "false"
- name: LEVEL_DEBUG
value: "false"
- name: LEVEL_INFO
value: "true"
- name: AGENT_USERNAME
value: "kubernetes-cluster"
- name: AGENT_PASSWORD
valueFrom:
secretKeyRef:
name: crowdsec-agent-secret
key: password
- name: BOUNCER_KEY_traefik
valueFrom:
secretKeyRef:
name: crowdsec-secrets
key: bouncerApiKey
optional: true
ports:
- name: lapi
containerPort: 8080
protocol: TCP
- name: prometheus
containerPort: 6060
protocol: TCP
livenessProbe:
httpGet:
path: /health
port: 8080
initialDelaySeconds: 30
periodSeconds: 30
readinessProbe:
httpGet:
path: /health
port: 8080
initialDelaySeconds: 10
periodSeconds: 10
resources:
requests:
cpu: 50m
memory: 200Mi
limits:
cpu: 500m
memory: 512Mi
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop:
- ALL
runAsNonRoot: false
volumeMounts:
- name: crowdsec-config
mountPath: /etc/crowdsec/acquis.yaml
subPath: acquis.yaml
readOnly: true
- name: crowdsec-config
mountPath: /etc/crowdsec/profiles.yaml
subPath: profiles.yaml
readOnly: true
- name: crowdsec-data
mountPath: /var/lib/crowdsec/data
- name: crowdsec-config-dir
mountPath: /etc/crowdsec/config
- name: varlog
mountPath: /var/log
readOnly: true
volumes:
- name: crowdsec-config
configMap:
name: crowdsec-config
- name: crowdsec-data
persistentVolumeClaim:
claimName: crowdsec-data
- name: crowdsec-config-dir
emptyDir: {}
- name: varlog
hostPath:
path: /var/log

View File

@@ -0,0 +1,24 @@
apiVersion: v1
kind: Service
metadata:
name: crowdsec-lapi
namespace: crowdsec
labels:
app: crowdsec
managedBy: kustomize
partOf: wild-cloud
spec:
type: ClusterIP
selector:
app: crowdsec
managedBy: kustomize
partOf: wild-cloud
ports:
- name: lapi
port: 8080
targetPort: 8080
protocol: TCP
- name: prometheus
port: 6060
targetPort: 6060
protocol: TCP

View File

@@ -0,0 +1,17 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: "{{ .namespace }}"
labels:
- includeSelectors: true
pairs:
app: crowdsec
managedBy: kustomize
partOf: wild-cloud
resources:
- namespace.yaml
- serviceaccount.yaml
- configmap.yaml
- pvc.yaml
- crowdsec-deployment.yaml
- crowdsec-service.yaml
- middleware.yaml

View File

@@ -0,0 +1,26 @@
version: v1.7.8-1
requires:
- name: longhorn
- name: traefik
defaultConfig:
namespace: crowdsec
rateLimitAverage: "100"
rateLimitBurst: "100"
defaultSecrets:
- key: agentPassword
- key: bouncerApiKey
deploy:
createSecrets:
- name: crowdsec-agent-secret
entries:
password: agentPassword
- name: crowdsec-bouncer-secret
entries:
api-key: bouncerApiKey
- name: crowdsec-bouncer-secret
namespace: traefik
entries:
api-key: bouncerApiKey
waitForRollout:
name: crowdsec
timeout: "120s"

View File

@@ -0,0 +1,94 @@
---
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
name: crowdsec-bouncer
namespace: crowdsec
labels:
app: crowdsec
managedBy: kustomize
partOf: wild-cloud
spec:
plugin:
bouncer:
crowdsecLapiScheme: http
crowdsecLapiHost: crowdsec-lapi.crowdsec.svc.cluster.local:8080
crowdsecLapiKeyFile: /etc/traefik/crowdsec/api-key
crowdsecMode: stream
updateIntervalSeconds: 15
defaultDecisionSeconds: 60
crowdsecAppsecEnabled: false
---
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
name: rate-limit
namespace: crowdsec
labels:
app: crowdsec
managedBy: kustomize
partOf: wild-cloud
spec:
rateLimit:
average: {{ .rateLimitAverage }}
burst: {{ .rateLimitBurst }}
period: 1m
---
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
name: security-headers
namespace: crowdsec
labels:
app: crowdsec
managedBy: kustomize
partOf: wild-cloud
spec:
headers:
browserXssFilter: true
contentTypeNosniff: true
forceSTSHeader: true
sslRedirect: true
stsIncludeSubdomains: true
stsPreload: true
stsSeconds: 31536000
addVaryHeader: true
accessControlAllowMethods:
- GET
- POST
- PUT
- DELETE
- OPTIONS
accessControlAllowOriginList:
- "*"
accessControlAllowHeaders:
- X-Requested-With
- Content-Type
- Authorization
- Date
accessControlMaxAge: 100
customRequestHeaders:
X-Forwarded-Proto: https
customResponseHeaders:
Server: ""
X-Frame-Options: SAMEORIGIN
X-Robots-Tag: noindex,nofollow,nosnippet,noarchive,notranslate,noimageindex
---
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
name: security-chain
namespace: crowdsec
labels:
app: crowdsec
managedBy: kustomize
partOf: wild-cloud
spec:
chain:
middlewares:
- name: security-headers
namespace: crowdsec
- name: rate-limit
namespace: crowdsec
- name: crowdsec-bouncer
namespace: crowdsec

Some files were not shown because too many files have changed in this diff Show More