Compare commits

...

11 Commits

Author SHA1 Message Date
Paul Payne
763ac922d5 Add synapse federation Ingress for serverName path routing 2026-07-10 06:12:32 +00:00
Paul Payne
1b3200a5f1 Idempotent db-init passwords. 2026-07-10 05:14:24 +00:00
Paul Payne
dc5518b57f Externalize networking (welcome Wild Central!) 2026-07-10 03:49:38 +00:00
Paul Payne
bb5a76b864 Crowsec uses external LAPI. 2026-07-10 03:37:56 +00:00
Paul Payne
219a28bc65 Fixes synapse. 2026-07-10 03:36:15 +00:00
Paul Payne
387838819b Adds wild-directory server. 2026-07-10 03:35:56 +00:00
Paul Payne
c80d8dc411 Adds plausible. 2026-07-10 03:35:41 +00:00
Paul Payne
4d983819c9 feat(supabase): add services and statefulset for database management
feat(synapse): update ingress to use traefik ingress class and bump version

feat(syncthing-discovery): introduce syncthing discovery service with deployment and ingress

feat(syncthing-relay): add syncthing relay server with deployment and ingress configuration

fix(taiga): update liveness and readiness probes to use tcpSocket for health checks

fix(taiga): change PVC access mode to ReadWriteMany for media and static storage

feat(traefik): add icon and ignore rules for traefik service

docs(ushahidi): add notes for Redis configuration and Laravel startup probe adjustments

feat(ushahidi): implement dedicated Redis deployment for Ushahidi

fix(vllm): update deployment strategy and readiness/liveness probes for improved stability

fix(writefreely): pin writefreely image version to v0.15.1 for consistency

docs(zulip): add notes for TLS-terminating reverse proxy configuration and expected behavior
2026-07-02 21:34:27 +00:00
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
295 changed files with 5879 additions and 2461 deletions

View File

@@ -1,254 +0,0 @@
# Adding Apps - Notes & Documentation Improvements
## Documentation Improvement Candidates
Issues and gaps discovered while adding apps. These should be folded back into ADDING-APPS.md.
### 1. Node.js app memory requirements
Node.js apps (NocoDB, Outline, etc.) need more than 512Mi memory. NocoDB crashed with OOM at 512Mi.
- Recommended: 1Gi limit, 512Mi request for Node.js apps
- Set `NODE_OPTIONS=--max-old-space-size=768` to keep heap within the limit
- **Doc suggestion**: Add a section in ADDING-APPS.md about runtime-specific resource defaults:
- Node.js apps: 1Gi limit, 512Mi request; add NODE_OPTIONS env var
- JVM apps (Java/Kotlin): 1Gi+ limit
- Python/Ruby apps: 512Mi typically sufficient
- Go/Rust apps: 256-512Mi typically sufficient
### 2. db-init-job is repeated boilerplate
The db-init-job.yaml for PostgreSQL apps is nearly identical across all apps. Only the app name, namespace, and secret name change.
- **Doc suggestion**: Provide a copy-paste template for the standard PostgreSQL db-init-job with placeholders marked clearly.
### 4. Apps requiring hex-encoded secrets
Some apps (Outline) require secrets in specific formats (e.g., exactly 64 hex chars). The default Wild Cloud `GenerateSecret` produces alphanumeric (not hex) strings.
- **Solution**: Use `crypto.SHA256` gomplate function in the manifest `default` field to generate a valid 64-char hex string from another secret:
```yaml
defaultSecrets:
- key: utilsSecret
- key: secretKey
default: '{{ crypto.SHA256 .secrets.utilsSecret }}'
```
- **Doc suggestion**: Add this pattern to ADDING-APPS.md with examples of apps that need it. Note that the order of `defaultSecrets` matters — referenced secrets must come first.
### 6. Always use `?sslmode=disable` in PostgreSQL connection strings
The Wild Cloud internal PostgreSQL does not have SSL enabled. Apps that construct PostgreSQL connection strings must include `?sslmode=disable`, and deployments should set `PGSSLMODE=disable`. Without it, apps crash with "The server does not support SSL connections."
- Already done correctly in: listmonk, gitea, discourse
- Missing from: outline (fixed), any new app
- **Doc suggestion**: Add to the ADDING-APPS.md dbUrl template examples: always append `?sslmode=disable` to postgres connection strings.
### 5. Re-running `wild app add` is required after template changes
When you modify files in wild-directory (e.g., fix a template), you must re-run `wild app add <app>` before deploying. The compiled templates in the instance `apps/` directory are NOT automatically updated when wild-directory changes. Only then will `wild app deploy` use the updated templates.
- **Doc suggestion**: Add to ADDING-APPS.md: "After modifying templates in wild-directory, always re-run `wild app add <app>` to recompile before deploying."
### 7. WriteFreely requires root user (jrasanen/writefreely image)
The `jrasanen/writefreely` image startup script creates `config.ini` in `/writefreely/` which is a root-owned directory. The image's default user is non-root, causing "Permission denied" errors at startup.
- **Fix**: Set `runAsUser: 0` and `runAsNonRoot: false` in the pod securityContext
- Still safe with `allowPrivilegeEscalation: false` and `capabilities.drop: ALL`
- **Doc suggestion**: Note that some community images require root. When an image fails with permission denied on startup, check if it needs root and add the security context override with capabilities dropped.
### 8. Redis URL must include password for authenticated Redis
Wild Cloud's Redis requires authentication. Apps that take a Redis URL must include the password in the URL: `redis://:password@host:6379`.
- **Pattern**: Add `redis.password` to `requiredSecrets`, then use K8s env var expansion:
```yaml
- name: REDIS_PASSWORD
valueFrom:
secretKeyRef:
name: myapp-secrets
key: redis.password
- name: REDIS_URL
value: redis://:$(REDIS_PASSWORD)@{{ .redis.host }}:6379
```
- The `$(VAR_NAME)` syntax is supported by Kubernetes in container env values (evaluated at pod start)
- **Doc suggestion**: Add this pattern to ADDING-APPS.md under "Redis Authentication" section.
### 3. Consistent health check paths across apps
Different apps use different health check paths (`/health`, `/healthz`, `/_health`, `/api/v1/health`). This should be documented per app type or noted that you must verify the actual path from the app's Docker docs.
- For apps where no valid HTTP health path exists (e.g., Typebot viewer which only serves chatbot URLs), use `tcpSocket` probe instead of `httpGet`.
### 9. Linuxserver.io and s6-overlay images require root + capabilities
Images using s6-overlay init system (most linuxserver.io images) must run as root and need full capabilities to switch to their internal "abc" user.
- Set `runAsUser: 0, runAsNonRoot: false` in pod securityContext
- Do NOT set `allowPrivilegeEscalation: false` or `capabilities.drop: ALL` in container securityContext
- Only keep `readOnlyRootFilesystem: false`
- Same applies to Nextcloud (uses rsync/chown during setup)
- **Affects**: BookStack (linuxserver.io), any linuxserver.io image
### 10. CryptPad needs emptyDir + initContainer for config directory
The CryptPad image's `/cryptpad/config/` directory is in the image overlay filesystem and not writable by the container process even when running as root.
- **Fix**: Mount an emptyDir at `/cryptpad/config`, and use an initContainer to pre-seed `config.example.js` from the image into the emptyDir.
- The initContainer mounts the emptyDir at a different path (e.g., `/config-dest`) and copies the file.
- **CPAD_CONF** env var must be set to `/cryptpad/config/config.js` (startup script copies config.example.js to config.js if config.js doesn't exist).
### 11. Pixelfed image requires authentication (ghcr.io access restriction)
~~`ghcr.io/pixelfed/pixelfed` returns 403 Forbidden for anonymous pulls.~~
- **Resolved**: Use `ghcr.io/mattlqx/docker-pixelfed:v0.12.7-nginx` (community image, publicly accessible)
- Note: the mattlqx image uses port 80 (nginx), not 8080. Update `containerPort` and service `targetPort` accordingly.
### 12. Bitnami images no longer on Docker Hub
Bitnami moved their images from Docker Hub (`bitnami/appname`) to their own OCI registry. Apps that were packaged using Bitnami images (e.g., Ghost) need to be updated to use the official upstream image or Bitnami's new registry.
- **Ghost fix**: Switched from `bitnami/ghost:5.x` to official `ghost:5.130.6-alpine`
- The official ghost image uses different env vars (`database__connection__host` instead of `GHOST_DATABASE_HOST`) and a different mount path (`/var/lib/ghost/content` instead of `/bitnami/ghost`)
- Check image availability before packaging: `docker manifest inspect docker.io/bitnami/appname:tag`
### 13. Required secrets belong in app-secrets, not dep-secrets
Apps that use `requiredSecrets` (e.g., `mysql.rootPassword`) receive those secrets copied into their own `<app>-secrets` K8s Secret, under the key `<dep>.<key>` (e.g., `mysql.rootPassword`). db-init jobs and deployments must reference `<app>-secrets`, NOT `mysql-secrets`.
```yaml
# WRONG - mysql-secrets doesn't exist in the app namespace
secretKeyRef:
name: mysql-secrets
key: rootPassword
# CORRECT - the required secret is copied into ghost-secrets
secretKeyRef:
name: ghost-secrets
key: mysql.rootPassword
```
### 15. mattlqx/docker-pixelfed requires APP_PORT env var
The `ghcr.io/mattlqx/docker-pixelfed` community image generates nginx config from env vars using `sed`. The `listen` directive uses `APP_PORT`, which must be set explicitly or nginx crashes with "invalid number of arguments in 'listen' directive".
- Add `APP_PORT: "80"` to the deployment env vars
- The image listens on port 80 (nginx), not 8080 as the original pixelfed image did
- Both the web and worker deployments must mount the shared storage PVC — use ReadWriteMany access mode, not ReadWriteOnce, since both pods need it simultaneously
### 14. Your Priorities and Polis have no public Docker images
- `ghcr.io/citizensfoundation/your-priorities-app` — 403 Forbidden, images require auth
- `compdemocracy/polis-server` — private, no public Docker Hub or ghcr.io images
- Both apps require building custom images from source before they can be packaged
- Attempting to add these to wild-directory results in ImagePullBackOff
### 16. PHP/Laravel and other heavy-framework apps need extended probe delays
Laravel's startup process runs package discovery, autoload optimization, and encryption key generation before the HTTP server is ready. This commonly takes 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>`.
## App Status Tracking
| App | Status | Notes |
|-----|--------|-------|
| Firefly III | ✅ working | |
| Wiki.js | ✅ working | |
| Outline | ✅ working | Redis URL needs password via K8s env var expansion |
| WriteFreely | ✅ working | Needs runAsUser: 0 (community image quirk) |
| NocoDB | ✅ working | Node.js needs 2Gi memory |
| Mattermost | ✅ working | |
| Etherpad | ✅ working | |
| CryptPad | ✅ working | Needs emptyDir + initContainer for config seeding |
| MediaWiki | ✅ working | Post-deploy web installer required for LocalSettings.php |
| Nextcloud | ✅ working | |
| PeerTube | ✅ working | |
| BookStack | ✅ working | linuxserver.io needs root + no capabilities.drop |
| Typebot | ✅ working | Builder: /signin health path; Viewer: tcpSocket probe |
| Pixelfed | ✅ working | Use ghcr.io/mattlqx/docker-pixelfed:v0.12.7-nginx (community), port 80 not 8080 |
| Wekan | pending | needs MongoDB |
| Mautic | ✅ working | |
| Taiga | ✅ working | |
| Pol.is | ❌ blocked | Private AWS ECR images, no public alternative |
| Zulip | ✅ working | Bundles own postgres/rabbitmq/memcached; first-run migrations take ~10min |
| Rocket.Chat | pending | needs MongoDB |
| LimeSurvey | ✅ working | |
| Gancio | ✅ working | Re-deploy: must drop DB schema first if prior data exists (see note 18) |
| Mobilizon | ✅ working | |
| Formbricks | ✅ working | |
| Jitsi | ✅ working | |
| Mastodon | already done | |
| Chamilo | ✅ working | |
| Moodle | ✅ working | |
| Open edX | pending | complex |
| Pretix | ✅ working | |
| Indico | ✅ working | |
| Eventyay | ✅ working | No versioned Docker tags upstream — main tag is intentional |
| Leihs | pending | |
| LibreBooking | ✅ working | |
| uMap | ✅ working | Host header needed in health probes (Django ALLOWED_HOSTS) |
| MapComplete | pending | |
| Terrastories | pending | |
| Karrot | already done | |
| LiquidFeedback | pending | |
| Your Priorities | ❌ blocked | No public Docker images (ghcr.io requires auth) |
| Helios Voting | pending | |
| Belenios | pending | |
| Decidim | already done | |
| Alaveteli | pending | |
| FixMyStreet | pending | |
| Ghost | ✅ working | Bitnami image gone; use official ghost:alpine; different env vars + mount path (see note 12) |
| Ushahidi | ✅ working | Client: runAsUser:0 + CHOWN/SETUID/SETGID; API: liveness initialDelay 120s (Laravel slow start) |
| CiviCRM | pending | |
| Open Collective | pending | |
| Open Food Network | pending | |
| Resonate | pending | |
| BookWyrm | ✅ working | |
| Lemmy | already done | |
| Akaunting | ✅ working | |
| GnuCash | skip | desktop app, not containerizable |
| OhMyForm | ✅ working | |
| Odoo | ✅ working | Needs `-d DATABASE -i base` args; uses startupProbe (20min window) for first-run DB init |
| Headscale | ✅ working | SQLite, ConfigMap-based config, no secrets; v0.29 CLI uses numeric user IDs (see note 20) |
| Baserow | ✅ working | |
| Keila | already done | |
| Listmonk | already done | |
| Discourse | already done | |
| Gitea | already done | |
| Immich | already done | |
| Loomio | already done | |
| Synapse | already done | App renamed from matrix to synapse |
| Open WebUI | already done | |
| OpenProject | already done | |
| vLLM | already done | |

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,7 @@
- @README.md - @README.md
- ADDING-APPS.md is the expert advice you need when adding new apps to the Wild Cloud Directory. - ADDING-APPS.md is the expert advice you need when adding new apps to the Wild Cloud Directory.
- docs/ contains deep-dive guides — runtime-specific (nodejs, python, php, ruby, jvm, nginx, linuxserver) and topic-specific (database, redis, traefik, scripts).
## Finding good sources of documentation for adding a new app to the Wild Cloud Directory ## Finding good sources of documentation for adding a new app to the Wild Cloud Directory

32
admin/scripts/validate-app.sh Executable file
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}';

View File

@@ -3,10 +3,6 @@ kind: Ingress
metadata: metadata:
name: akaunting name: akaunting
namespace: akaunting namespace: akaunting
annotations:
external-dns.alpha.kubernetes.io/target: {{ .externalDnsDomain }}
external-dns.alpha.kubernetes.io/cloudflare-proxied: "false"
external-dns.alpha.kubernetes.io/ttl: "60"
spec: spec:
ingressClassName: traefik ingressClassName: traefik
rules: rules:
@@ -20,7 +16,3 @@ spec:
name: akaunting name: akaunting
port: port:
number: 80 number: 80
tls:
- hosts:
- {{ .domain }}
secretName: {{ .tlsSecretName }}

View File

@@ -1,12 +1,10 @@
version: 3.1.21-1 version: 3.1.21-2
requires: requires:
- name: mysql - name: mysql
- name: smtp - name: smtp
defaultConfig: defaultConfig:
namespace: akaunting namespace: akaunting
externalDnsDomain: '{{ .cloud.domain }}'
domain: akaunting.{{ .cloud.domain }} domain: akaunting.{{ .cloud.domain }}
tlsSecretName: wildcard-wild-cloud-tls
storage: 2Gi storage: 2Gi
locale: en-US locale: en-US
dbPrefix: aka_ dbPrefix: aka_
@@ -15,7 +13,7 @@ defaultConfig:
adminEmail: '{{ .operator.email }}' adminEmail: '{{ .operator.email }}'
db: db:
host: '{{ .apps.mysql.host }}' host: '{{ .apps.mysql.host }}'
port: "3306" port: '3306'
name: akaunting name: akaunting
user: akaunting user: akaunting
smtp: smtp:
@@ -24,8 +22,8 @@ defaultConfig:
from: '{{ .apps.smtp.from }}' from: '{{ .apps.smtp.from }}'
user: '{{ .apps.smtp.user }}' user: '{{ .apps.smtp.user }}'
defaultSecrets: defaultSecrets:
- key: dbPassword - key: dbPassword
- key: adminPassword - key: adminPassword
- key: smtpPassword - key: smtpPassword
requiredSecrets: requiredSecrets:
- mysql.rootPassword - mysql.rootPassword

View File

@@ -2,10 +2,6 @@ apiVersion: networking.k8s.io/v1
kind: Ingress kind: Ingress
metadata: metadata:
name: aptly name: aptly
annotations:
external-dns.alpha.kubernetes.io/target: {{ .externalDnsDomain }}
external-dns.alpha.kubernetes.io/cloudflare-proxied: "false"
external-dns.alpha.kubernetes.io/ttl: "60"
spec: spec:
ingressClassName: traefik ingressClassName: traefik
rules: rules:
@@ -19,7 +15,3 @@ spec:
name: aptly name: aptly
port: port:
number: 80 number: 80
tls:
- hosts:
- {{ .domain }}
secretName: {{ .tlsSecretName }}

View File

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

View File

@@ -69,6 +69,15 @@ spec:
volumeMounts: volumeMounts:
- name: baserow-data - name: baserow-data
mountPath: /baserow/data mountPath: /baserow/data
securityContext:
runAsNonRoot: false
runAsUser: 0
allowPrivilegeEscalation: false
capabilities:
drop: [ALL]
readOnlyRootFilesystem: false
seccompProfile:
type: RuntimeDefault
livenessProbe: livenessProbe:
httpGet: httpGet:
path: /api/_health/ path: /api/_health/

View File

@@ -3,10 +3,6 @@ kind: Ingress
metadata: metadata:
name: baserow name: baserow
namespace: {{ .namespace }} namespace: {{ .namespace }}
annotations:
external-dns.alpha.kubernetes.io/target: {{ .externalDnsDomain }}
external-dns.alpha.kubernetes.io/cloudflare-proxied: "false"
external-dns.alpha.kubernetes.io/ttl: "60"
spec: spec:
ingressClassName: traefik ingressClassName: traefik
rules: rules:
@@ -20,7 +16,3 @@ spec:
name: baserow name: baserow
port: port:
number: 80 number: 80
tls:
- hosts:
- {{ .domain }}
secretName: {{ .tlsSecretName }}

View File

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

View File

@@ -3,10 +3,6 @@ kind: Ingress
metadata: metadata:
name: bookstack name: bookstack
namespace: {{ .namespace }} namespace: {{ .namespace }}
annotations:
external-dns.alpha.kubernetes.io/target: {{ .externalDnsDomain }}
external-dns.alpha.kubernetes.io/cloudflare-proxied: "false"
external-dns.alpha.kubernetes.io/ttl: "60"
spec: spec:
ingressClassName: traefik ingressClassName: traefik
rules: rules:
@@ -20,7 +16,3 @@ spec:
name: bookstack name: bookstack
port: port:
number: 80 number: 80
tls:
- hosts:
- {{ .domain }}
secretName: {{ .tlsSecretName }}

View File

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

38
bookwyrm/notes.md Normal file
View File

@@ -0,0 +1,38 @@
# BookWyrm — Notes
## SCSS themes must be compiled before collectstatic
BookWyrm ships SCSS source files instead of pre-compiled CSS. The static root is an `emptyDir`
(ephemeral), so it is empty on every pod restart. Running `collectstatic` alone fails:
```
ValueError: Missing staticfiles manifest entry for 'css/themes/...'
```
**Fix**: compile themes before collecting static files:
```yaml
command: ["/bin/sh", "-c"]
args:
- |
python manage.py compile_themes \
&& python manage.py collectstatic --noinput \
&& python manage.py migrate \
&& exec gunicorn bookwyrm.wsgi:application ...
```
Check the image's `Dockerfile` or `docker_start.sh` to confirm the current required build steps.
## PostgreSQL extensions require superuser
BookWyrm uses extensions (`bloom`, `pg_trgm`, etc.) that require superuser to install. Migrations
fail with `permission denied to create extension "bloom"` if the app DB user is not a superuser.
**Fix**: create the extensions in the `db-init-job`, which connects as the `postgres` superuser:
```bash
psql -d "$APP_DB_NAME" -c "CREATE EXTENSION IF NOT EXISTS bloom;"
psql -d "$APP_DB_NAME" -c "CREATE EXTENSION IF NOT EXISTS pg_trgm;"
```
This must run before migrations (i.e. in `db-init-job`, not in the app startup command).

View File

@@ -80,4 +80,7 @@ spec:
GRANT USAGE ON SCHEMA public TO \"$BOOKWYRM_DB_USER\"; GRANT USAGE ON SCHEMA public TO \"$BOOKWYRM_DB_USER\";
" "
# Create bloom extension (required by bookwyrm migrations, needs superuser)
psql -d "$BOOKWYRM_DB_NAME" -c "CREATE EXTENSION IF NOT EXISTS bloom;"
echo "Database initialization completed." echo "Database initialization completed."

View File

@@ -23,7 +23,7 @@ spec:
containers: containers:
- name: bookwyrm - name: bookwyrm
image: ghcr.io/bookwyrm-social/bookwyrm:v0.8.6 image: ghcr.io/bookwyrm-social/bookwyrm:v0.8.6
command: ["gunicorn", "bookwyrm.wsgi:application"] command: ["/bin/sh", "-c", "python manage.py compile_themes && python manage.py collectstatic --noinput && python manage.py migrate && exec gunicorn bookwyrm.wsgi:application"]
ports: ports:
- name: http - name: http
containerPort: 8000 containerPort: 8000

View File

@@ -3,10 +3,6 @@ kind: Ingress
metadata: metadata:
name: bookwyrm name: bookwyrm
namespace: bookwyrm namespace: bookwyrm
annotations:
external-dns.alpha.kubernetes.io/target: {{ .externalDnsDomain }}
external-dns.alpha.kubernetes.io/cloudflare-proxied: "false"
external-dns.alpha.kubernetes.io/ttl: "60"
spec: spec:
ingressClassName: traefik ingressClassName: traefik
rules: rules:
@@ -20,7 +16,3 @@ spec:
name: bookwyrm name: bookwyrm
port: port:
number: 80 number: 80
tls:
- hosts:
- {{ .domain }}
secretName: {{ .tlsSecretName }}

View File

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

View File

@@ -2,4 +2,5 @@ name: cert-manager
is: cert-manager is: cert-manager
description: X.509 certificate management for Kubernetes description: X.509 certificate management for Kubernetes
category: services category: services
icon: https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/cert-manager.svg
latest: "v1" latest: "v1"

View File

@@ -3,10 +3,6 @@ kind: Ingress
metadata: metadata:
name: chamilo name: chamilo
namespace: {{ .namespace }} namespace: {{ .namespace }}
annotations:
external-dns.alpha.kubernetes.io/cloudflare-proxied: "false"
external-dns.alpha.kubernetes.io/target: {{ .externalDnsDomain }}
external-dns.alpha.kubernetes.io/ttl: "60"
spec: spec:
ingressClassName: traefik ingressClassName: traefik
rules: rules:
@@ -20,7 +16,3 @@ spec:
name: chamilo name: chamilo
port: port:
number: 80 number: 80
tls:
- hosts:
- {{ .domain }}
secretName: {{ .tlsSecretName }}

View File

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

View File

@@ -3,3 +3,5 @@ is: community-search
description: Community Search is a federated, self-hosted search engine built on community-curated indexes rather than global web crawling. description: Community Search is a federated, self-hosted search engine built on community-curated indexes rather than global web crawling.
category: community category: community
latest: "0" latest: "0"
ignoreRules:
- WC-ICON # no established icon available

View File

@@ -2,10 +2,6 @@ apiVersion: networking.k8s.io/v1
kind: Ingress kind: Ingress
metadata: metadata:
name: community-search name: community-search
annotations:
external-dns.alpha.kubernetes.io/cloudflare-proxied: "false"
external-dns.alpha.kubernetes.io/target: {{ .externalDnsDomain }}
external-dns.alpha.kubernetes.io/ttl: "60"
spec: spec:
ingressClassName: traefik ingressClassName: traefik
rules: rules:
@@ -19,7 +15,3 @@ spec:
name: community-search name: community-search
port: port:
number: 80 number: 80
tls:
- hosts:
- {{ .domain }}
secretName: {{ .tlsSecretName }}

View File

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

View File

@@ -2,4 +2,5 @@ name: coredns
is: coredns is: coredns
description: DNS server for internal cluster DNS resolution description: DNS server for internal cluster DNS resolution
category: services category: services
icon: https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/coredns.svg
latest: "v1" latest: "v1"

View File

@@ -2,4 +2,5 @@ name: crowdsec
is: crowdsec is: crowdsec
description: CrowdSec security engine with Traefik bouncer for threat detection and rate limiting description: CrowdSec security engine with Traefik bouncer for threat detection and rate limiting
category: services category: services
icon: https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/crowdsec.svg
latest: "v1" latest: "v1"

View File

@@ -60,7 +60,7 @@ spec:
- name: LEVEL_INFO - name: LEVEL_INFO
value: "true" value: "true"
- name: AGENT_USERNAME - name: AGENT_USERNAME
value: "kubernetes-cluster" value: "{{ if .agentUsername }}{{ .agentUsername }}{{ else }}kubernetes-cluster{{ end }}"
- name: AGENT_PASSWORD - name: AGENT_PASSWORD
valueFrom: valueFrom:
secretKeyRef: secretKeyRef:
@@ -72,6 +72,12 @@ spec:
name: crowdsec-secrets name: crowdsec-secrets
key: bouncerApiKey key: bouncerApiKey
optional: true optional: true
{{ if .centralLapiUrl }}
- name: DISABLE_LOCAL_API
value: "true"
- name: LOCAL_API_URL
value: "{{ .centralLapiUrl }}"
{{ end }}
ports: ports:
- name: lapi - name: lapi
containerPort: 8080 containerPort: 8080
@@ -79,6 +85,7 @@ spec:
- name: prometheus - name: prometheus
containerPort: 6060 containerPort: 6060
protocol: TCP protocol: TCP
{{ if not .centralLapiUrl }}
livenessProbe: livenessProbe:
httpGet: httpGet:
path: /health path: /health
@@ -91,6 +98,7 @@ spec:
port: 8080 port: 8080
initialDelaySeconds: 10 initialDelaySeconds: 10
periodSeconds: 10 periodSeconds: 10
{{ end }}
resources: resources:
requests: requests:
cpu: 50m cpu: 50m

View File

@@ -1,4 +1,4 @@
version: v1.7.8-1 version: v1.7.8-4
requires: requires:
- name: longhorn - name: longhorn
- name: traefik - name: traefik
@@ -6,6 +6,8 @@ defaultConfig:
namespace: crowdsec namespace: crowdsec
rateLimitAverage: "100" rateLimitAverage: "100"
rateLimitBurst: "100" rateLimitBurst: "100"
centralLapiUrl: ""
agentUsername: ""
defaultSecrets: defaultSecrets:
- key: agentPassword - key: agentPassword
- key: bouncerApiKey - key: bouncerApiKey

View File

@@ -12,7 +12,7 @@ spec:
plugin: plugin:
bouncer: bouncer:
crowdsecLapiScheme: http crowdsecLapiScheme: http
crowdsecLapiHost: crowdsec-lapi.crowdsec.svc.cluster.local:8080 crowdsecLapiHost: {{ if .centralLapiUrl }}{{ strings.TrimPrefix "http://" .centralLapiUrl }}{{ else }}crowdsec-lapi.crowdsec.svc.cluster.local:8080{{ end }}
crowdsecLapiKeyFile: /etc/traefik/crowdsec/api-key crowdsecLapiKeyFile: /etc/traefik/crowdsec/api-key
crowdsecMode: stream crowdsecMode: stream
updateIntervalSeconds: 15 updateIntervalSeconds: 15
@@ -48,7 +48,6 @@ spec:
browserXssFilter: true browserXssFilter: true
contentTypeNosniff: true contentTypeNosniff: true
forceSTSHeader: true forceSTSHeader: true
frameDeny: true
sslRedirect: true sslRedirect: true
stsIncludeSubdomains: true stsIncludeSubdomains: true
stsPreload: true stsPreload: true
@@ -72,6 +71,7 @@ spec:
X-Forwarded-Proto: https X-Forwarded-Proto: https
customResponseHeaders: customResponseHeaders:
Server: "" Server: ""
X-Frame-Options: SAMEORIGIN
X-Robots-Tag: noindex,nofollow,nosnippet,noarchive,notranslate,noimageindex X-Robots-Tag: noindex,nofollow,nosnippet,noarchive,notranslate,noimageindex
--- ---
apiVersion: traefik.io/v1alpha1 apiVersion: traefik.io/v1alpha1

25
cryptpad/notes.md Normal file
View File

@@ -0,0 +1,25 @@
# CryptPad — Notes
## Config directory requires emptyDir + initContainer
CryptPad's `/cryptpad/config/` directory is in the image overlay filesystem and is not writable
by the container process even when running as root.
**Fix**: mount an `emptyDir` at `/cryptpad/config` and use an initContainer to pre-seed
`config.example.js` from the image into the emptyDir:
```yaml
initContainers:
- name: seed-config
image: cryptpad/cryptpad:version-X.Y.Z
command: [sh, -c, "cp /cryptpad/config/config.example.js /config-dest/config.example.js"]
volumeMounts:
- name: cryptpad-config
mountPath: /config-dest
volumes:
- name: cryptpad-config
emptyDir: {}
```
Set `CPAD_CONF=/cryptpad/config/config.js` — the startup script copies `config.example.js` to
`config.js` on first run if `config.js` doesn't exist.

View File

@@ -22,7 +22,7 @@ spec:
type: RuntimeDefault type: RuntimeDefault
initContainers: initContainers:
- name: seed-config - name: seed-config
image: cryptpad/cryptpad:latest image: cryptpad/cryptpad:version-2026.5.1
command: command:
- sh - sh
- -c - -c
@@ -33,9 +33,18 @@ spec:
volumeMounts: volumeMounts:
- name: cryptpad-config - name: cryptpad-config
mountPath: /config-dest mountPath: /config-dest
securityContext:
runAsNonRoot: false
runAsUser: 0
allowPrivilegeEscalation: false
capabilities:
drop: [ALL]
readOnlyRootFilesystem: false
seccompProfile:
type: RuntimeDefault
containers: containers:
- name: cryptpad - name: cryptpad
image: cryptpad/cryptpad:latest image: cryptpad/cryptpad:version-2026.5.1
ports: ports:
- name: http - name: http
containerPort: 3000 containerPort: 3000
@@ -80,7 +89,14 @@ spec:
periodSeconds: 10 periodSeconds: 10
failureThreshold: 3 failureThreshold: 3
securityContext: securityContext:
runAsNonRoot: false
runAsUser: 0
allowPrivilegeEscalation: false
capabilities:
drop: [ALL]
readOnlyRootFilesystem: false readOnlyRootFilesystem: false
seccompProfile:
type: RuntimeDefault
volumes: volumes:
- name: cryptpad-data - name: cryptpad-data
persistentVolumeClaim: persistentVolumeClaim:

View File

@@ -3,10 +3,6 @@ kind: Ingress
metadata: metadata:
name: cryptpad name: cryptpad
namespace: cryptpad namespace: cryptpad
annotations:
external-dns.alpha.kubernetes.io/target: {{ .externalDnsDomain }}
external-dns.alpha.kubernetes.io/cloudflare-proxied: "false"
external-dns.alpha.kubernetes.io/ttl: "60"
spec: spec:
ingressClassName: traefik ingressClassName: traefik
rules: rules:
@@ -30,8 +26,3 @@ spec:
name: cryptpad name: cryptpad
port: port:
number: 80 number: 80
tls:
- hosts:
- {{ .domain }}
- {{ .sandboxDomain }}
secretName: {{ .tlsSecretName }}

View File

@@ -1,10 +1,8 @@
version: 2024.x-1 version: 2026.5.1-3
defaultConfig: defaultConfig:
namespace: cryptpad namespace: cryptpad
externalDnsDomain: '{{ .cloud.domain }}'
domain: cryptpad.{{ .cloud.domain }} domain: cryptpad.{{ .cloud.domain }}
sandboxDomain: cryptpad-sandbox.{{ .cloud.domain }} sandboxDomain: cryptpad-sandbox.{{ .cloud.domain }}
tlsSecretName: wildcard-wild-cloud-tls
storage: 2Gi storage: 2Gi
defaultSecrets: defaultSecrets:
- key: adminKey - key: adminKey

View File

@@ -4,15 +4,8 @@ kind: Ingress
metadata: metadata:
name: decidim name: decidim
namespace: decidim namespace: decidim
annotations:
external-dns.alpha.kubernetes.io/target: {{ .externalDnsDomain }}
external-dns.alpha.kubernetes.io/cloudflare-proxied: "false"
spec: spec:
ingressClassName: traefik ingressClassName: traefik
tls:
- hosts:
- {{ .domain }}
secretName: {{ .tlsSecretName }}
rules: rules:
- host: {{ .domain }} - host: {{ .domain }}
http: http:

View File

@@ -1,16 +1,14 @@
version: 0.31.0-2 version: 0.31.0-4
requires: requires:
- name: postgres - name: postgres
- name: redis - name: redis
- name: smtp - name: smtp
defaultConfig: defaultConfig:
namespace: decidim namespace: decidim
externalDnsDomain: '{{ .cloud.domain }}'
storage: 2Gi storage: 2Gi
systemAdminEmail: '{{ .operator.email }}' systemAdminEmail: '{{ .operator.email }}'
siteName: 'Decidim' siteName: Decidim
domain: decidim.{{ .cloud.domain }} domain: decidim.{{ .cloud.domain }}
tlsSecretName: wildcard-wild-cloud-tls
db: db:
host: '{{ .apps.postgres.host }}' host: '{{ .apps.postgres.host }}'
port: '{{ .apps.postgres.port }}' port: '{{ .apps.postgres.port }}'
@@ -27,13 +25,14 @@ defaultConfig:
tls: '{{ .apps.smtp.tls }}' tls: '{{ .apps.smtp.tls }}'
startTls: '{{ .apps.smtp.startTls }}' startTls: '{{ .apps.smtp.startTls }}'
defaultSecrets: defaultSecrets:
- key: systemAdminPassword - key: systemAdminPassword
- key: secretKeyBase - key: secretKeyBase
default: "{{ random.AlphaNum 128 }}" default: '{{ random.AlphaNum 128 }}'
- key: dbPassword - key: dbPassword
- key: dbUrl - key: dbUrl
default: "postgres://{{ .app.db.user }}:{{ .secrets.dbPassword }}@{{ .app.db.host }}:{{ .app.db.port }}/{{ .app.db.name }}" default: postgres://{{ .app.db.user }}:{{ .secrets.dbPassword }}@{{ .app.db.host
}}:{{ .app.db.port }}/{{ .app.db.name }}?sslmode=disable
requiredSecrets: requiredSecrets:
- postgres.password - postgres.password
- redis.password - redis.password
- smtp.password - smtp.password

View File

@@ -69,6 +69,7 @@ spec:
END IF; END IF;
END END
\$\$; \$\$;
ALTER USER $DISCOURSE_DB_USER WITH PASSWORD '$DISCOURSE_DB_PASSWORD';
GRANT ALL PRIVILEGES ON DATABASE $DISCOURSE_DB_NAME TO $DISCOURSE_DB_USER; GRANT ALL PRIVILEGES ON DATABASE $DISCOURSE_DB_NAME TO $DISCOURSE_DB_USER;
GRANT ALL ON SCHEMA public TO $DISCOURSE_DB_USER; GRANT ALL ON SCHEMA public TO $DISCOURSE_DB_USER;
GRANT USAGE ON SCHEMA public TO $DISCOURSE_DB_USER; GRANT USAGE ON SCHEMA public TO $DISCOURSE_DB_USER;

View File

@@ -5,10 +5,8 @@ kind: Ingress
metadata: metadata:
name: discourse name: discourse
namespace: "{{ .namespace }}" namespace: "{{ .namespace }}"
annotations:
external-dns.alpha.kubernetes.io/cloudflare-proxied: "false"
external-dns.alpha.kubernetes.io/target: "{{ .externalDnsDomain }}"
spec: spec:
ingressClassName: traefik
rules: rules:
- host: "{{ .domain }}" - host: "{{ .domain }}"
http: http:
@@ -20,7 +18,3 @@ spec:
name: discourse name: discourse
port: port:
name: http name: http
tls:
- hosts:
- "{{ .domain }}"
secretName: wildcard-external-wild-cloud-tls

View File

@@ -1,17 +1,15 @@
version: 3.5.3-3 version: 3.5.3-5
requires: requires:
- name: postgres - name: postgres
- name: redis - name: redis
- name: smtp - name: smtp
defaultConfig: defaultConfig:
namespace: discourse namespace: discourse
externalDnsDomain: '{{ .cloud.domain }}'
storage: 2Gi storage: 2Gi
adminEmail: '{{ .operator.email }}' adminEmail: '{{ .operator.email }}'
adminUsername: admin adminUsername: admin
siteName: 'Community' siteName: Community
domain: discourse.{{ .cloud.domain }} domain: discourse.{{ .cloud.domain }}
tlsSecretName: wildcard-wild-cloud-tls
db: db:
host: '{{ .apps.postgres.host }}' host: '{{ .apps.postgres.host }}'
port: '{{ .apps.postgres.port }}' port: '{{ .apps.postgres.port }}'
@@ -28,13 +26,14 @@ defaultConfig:
tls: '{{ .apps.smtp.tls }}' tls: '{{ .apps.smtp.tls }}'
startTls: '{{ .apps.smtp.startTls }}' startTls: '{{ .apps.smtp.startTls }}'
defaultSecrets: defaultSecrets:
- key: adminPassword - key: adminPassword
- key: secretKeyBase - key: secretKeyBase
default: "{{ random.Hex 32 }}" default: '{{ random.Hex 32 }}'
- key: smtpPassword - key: smtpPassword
- key: dbPassword - key: dbPassword
- key: dbUrl - key: dbUrl
default: "postgres://{{ .app.db.user }}:{{ .secrets.dbPassword }}@{{ .app.db.host }}:{{ .app.db.port }}/{{ .app.db.name }}?sslmode=disable" default: postgres://{{ .app.db.user }}:{{ .secrets.dbPassword }}@{{ .app.db.host
}}:{{ .app.db.port }}/{{ .app.db.name }}?sslmode=disable
requiredSecrets: requiredSecrets:
- postgres.password - postgres.password
- redis.password - redis.password

View File

@@ -2,4 +2,8 @@ name: docker-registry
is: docker-registry is: docker-registry
description: Private Docker image registry for cluster description: Private Docker image registry for cluster
category: services category: services
icon: https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/docker.svg
latest: "3" latest: "3"
ignoreRules:
- WC-DNS # internal registry; external DNS not needed

View File

@@ -11,10 +11,7 @@ spec:
matchLabels: matchLabels:
app: docker-registry app: docker-registry
strategy: strategy:
rollingUpdate: type: Recreate
maxSurge: 0
maxUnavailable: 1
type: RollingUpdate
template: template:
metadata: metadata:
labels: labels:

View File

@@ -3,6 +3,7 @@ kind: Ingress
metadata: metadata:
name: docker-registry name: docker-registry
spec: spec:
ingressClassName: traefik
rules: rules:
- host: {{ .host }} - host: {{ .host }}
http: http:
@@ -14,7 +15,3 @@ spec:
name: docker-registry name: docker-registry
port: port:
number: 5000 number: 5000
tls:
- hosts:
- {{ .host }}
secretName: wildcard-internal-wild-cloud-tls

View File

@@ -5,7 +5,8 @@ labels:
- includeSelectors: true - includeSelectors: true
pairs: pairs:
app: docker-registry app: docker-registry
managedBy: wild-cloud managedBy: kustomize
partOf: wild-cloud
resources: resources:
- deployment.yaml - deployment.yaml
- ingress.yaml - ingress.yaml

View File

@@ -1,8 +1,5 @@
version: "3.0.0" version: 3.0.0-2
requires:
- name: traefik
- name: cert-manager
defaultConfig: defaultConfig:
namespace: docker-registry namespace: docker-registry
host: "registry.{{ .cloud.internalDomain }}" host: registry.{{ .cloud.internalDomain }}
storage: 5Gi storage: 5Gi

78
docs/database.md Normal file
View File

@@ -0,0 +1,78 @@
# Database Patterns
## PostgreSQL
### Always use `?sslmode=disable` `[WC-SSL]`
Wild Cloud's internal PostgreSQL has no SSL configured. Without `?sslmode=disable`, connections fail with "The server does not support SSL connections."
```yaml
defaultSecrets:
- key: dbUrl
default: "postgresql://{{ .app.db.user }}:{{ .secrets.dbPassword }}@{{ .app.db.host }}:{{ .app.db.port }}/{{ .app.db.name }}?sslmode=disable"
```
Also set `PGSSLMODE=disable` for apps that use libpq directly.
### db-init-job
Include a `db-init-job.yaml` for every app that uses PostgreSQL. See `immich`, `gitea`, or `openproject` for reference implementations. The job must:
- Create the database if it doesn't exist
- Create/update the user with correct credentials
- Grant permissions
- Install required extensions (`vector`, `pg_trgm`, etc.)
- Use `restartPolicy: OnFailure` and `runAsUser: 999`
- Be idempotent — safe to re-run after redeploy
### Database URL secrets
When an app needs a connection URL with embedded credentials, use a `dbUrl` secret — do not construct URLs inline:
```yaml
# Wrong: Kustomize cannot do runtime env var substitution
- name: DB_URL
value: "postgresql://user:$(DB_PASSWORD)@host/db"
# Correct: use a secret with the full URL
- name: DB_URL
valueFrom:
secretKeyRef:
name: myapp-secrets
key: dbUrl
```
## MySQL
### db-init user password idempotency `[WC-DBIN]`
`CREATE USER IF NOT EXISTS` only sets the password on first creation. On redeploy against an existing database the password stays stale, causing "Access denied".
Always follow `CREATE USER` with `ALTER USER`:
```sql
CREATE USER IF NOT EXISTS '${DB_USERNAME}'@'%' IDENTIFIED BY '${DB_PASSWORD}';
ALTER USER '${DB_USERNAME}'@'%' IDENTIFIED BY '${DB_PASSWORD}';
GRANT ALL PRIVILEGES ON ${DB_DATABASE_NAME}.* TO '${DB_USERNAME}'@'%';
FLUSH PRIVILEGES;
```
`ALTER USER` is a no-op when the user was just created — it is safe to always include it.
### Required secrets reference
MySQL secrets are copied into `<app>-secrets`, not `mysql-secrets`. Reference them as:
```yaml
secretKeyRef:
name: myapp-secrets
key: mysql.rootPassword # not mysql-secrets / rootPassword
```
## Database env var naming
Name database-related env vars so the backup system can identify them:
- **Database name**: include `DATABASE`, `DB_NAME`, `DBNAME`, or `__DATABASE`
- **Database URLs**: value must contain `://`
- **Usernames**: include `USER` — these are not patched on restore

44
docs/jvm.md Normal file
View File

@@ -0,0 +1,44 @@
# JVM (Spring Boot / Scala / Kotlin)
## Startup delay
Spring Boot loads the full application context before accepting HTTP. Cold starts typically take 60120 seconds depending on the number of beans and auto-configuration classes.
```yaml
livenessProbe:
tcpSocket:
port: 8080
initialDelaySeconds: 120
periodSeconds: 30
failureThreshold: 6
readinessProbe:
httpGet:
path: /actuator/health
port: 8080
initialDelaySeconds: 90
periodSeconds: 15
failureThreshold: 3
```
If the app exposes a Spring Actuator health endpoint, prefer it for readiness over the root path.
## Heap limit
Set `-Xmx` to keep the heap within the container memory limit. The JVM does not automatically respect cgroup limits on older JDK versions.
```yaml
env:
- name: JAVA_OPTS
value: "-Xmx512m -Xms256m"
resources:
limits:
memory: 768Mi
requests:
memory: 512Mi
```
Keep `-Xmx` 2533% below the container limit to leave room for off-heap memory (metaspace, direct buffers, GC overhead).
## Resources
512Mi1Gi request is typical. JVM startup can spike higher — use `requests` to reserve and `limits` to cap.

44
docs/linuxserver.md Normal file
View File

@@ -0,0 +1,44 @@
# linuxserver.io / s6-overlay images
## Security context
Images using the s6-overlay init system (all linuxserver.io images) must run as root and need full capabilities to switch to their internal `abc` user. **Do not drop capabilities** — s6-overlay's user-switching will fail.
```yaml
spec:
template:
spec:
securityContext: # pod level
runAsNonRoot: false
runAsUser: 0
seccompProfile:
type: RuntimeDefault
containers:
- name: app
securityContext: # container level — only this field
readOnlyRootFilesystem: false
```
Do NOT set `allowPrivilegeEscalation: false` or `capabilities.drop: ALL` for these images.
Suppress `WC-SC-POD` and `WC-SC-CTR` in `app.yaml`:
```yaml
ignoreRules:
- WC-SC-POD # s6-overlay requires root
- WC-SC-CTR # s6-overlay requires full capabilities
```
## PUID / PGID
linuxserver.io images accept `PUID` and `PGID` env vars to set the internal `abc` user's UID/GID. Set them explicitly for consistent file ownership on PVCs:
```yaml
env:
- name: PUID
value: "1000"
- name: PGID
value: "1000"
```
**Affected apps**: BookStack, and any app using a linuxserver.io image.

54
docs/nginx.md Normal file
View File

@@ -0,0 +1,54 @@
# nginx-based images
## Security context
nginx binds to port 80 (or 443) and needs to `chown` files before dropping to `www-data`. It must run as root but can drop most capabilities after startup.
```yaml
spec:
template:
spec:
securityContext: # pod level
runAsNonRoot: false
runAsUser: 0
seccompProfile:
type: RuntimeDefault
containers:
- name: app
securityContext: # container level
allowPrivilegeEscalation: false
capabilities:
drop: [ALL]
add: [CHOWN, SETUID, SETGID, NET_BIND_SERVICE]
readOnlyRootFilesystem: false
```
Add `NET_BIND_SERVICE` only if the container binds to a port below 1024. Drop it if the app uses a high port (8080, 3000, etc.).
## Docker DNS resolver (`WC-DKRDNS`)
nginx proxy configs shipped for Docker often contain:
```nginx
resolver 127.0.0.11 valid=3s;
set $backend app-backend:8000;
proxy_pass http://$backend$request_uri;
```
`127.0.0.11` is Docker's embedded DNS resolver — it does not exist in Kubernetes. This fails with `send() failed (111: Connection refused) while resolving`.
There's a second trap: **any nginx variable anywhere in the `proxy_pass` URL** (even `$request_uri` in the path) forces runtime DNS resolution and requires a `resolver` directive. Removing the resolver line but keeping `proxy_pass http://backend:8000$request_uri;` still fails with `no resolver defined to resolve backend`.
**Fix**: override the nginx config template via a ConfigMap mounted with `subPath`, and remove the variable from `proxy_pass` entirely:
```nginx
# Instead of:
resolver 127.0.0.11 valid=3s;
set $backend ${BACKEND};
proxy_pass http://$backend$request_uri;
# Use:
proxy_pass http://${BACKEND};
```
With no nginx variable in `proxy_pass`, nginx resolves the hostname at startup using the pod's `/etc/resolv.conf` (which points to CoreDNS). The full request URI is still forwarded automatically in a regex `location ~` block. Mount the ConfigMap with `subPath` to override just the template file without replacing the whole directory.

20
docs/nodejs.md Normal file
View File

@@ -0,0 +1,20 @@
# Node.js
## Memory limits
Node.js apps frequently OOM-crash at the default 512Mi limit. Set a higher limit and cap the V8 heap so it stays under it:
```yaml
resources:
limits:
memory: 1Gi
requests:
memory: 512Mi
env:
- name: NODE_OPTIONS
value: "--max-old-space-size=768"
```
`--max-old-space-size` is in MiB. Keep it 2533% below the container limit so the process has headroom for non-heap memory (buffers, native modules, etc.).
**Applies to**: NocoDB, Outline, Gitea (web), and any Electron/Express/Next.js app.

31
docs/php.md Normal file
View File

@@ -0,0 +1,31 @@
# PHP (Laravel / Symfony)
## Startup delay
Laravel's startup sequence (autoload optimization, key generation, migrations, package discovery) 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.
**Symptom**: pod shows `Running` for ~2 minutes, then gets `Killing` due to liveness probe failure, then a new pod starts the same cycle.
Use a `tcpSocket` liveness probe (avoids the Host header issue) with an extended initial delay:
```yaml
livenessProbe:
tcpSocket:
port: 8080
initialDelaySeconds: 120
periodSeconds: 30
failureThreshold: 6
readinessProbe:
httpGet:
path: /
port: 8080
initialDelaySeconds: 60
periodSeconds: 15
failureThreshold: 3
```
Symfony has the same behavior. Adjust `initialDelaySeconds` up if migrations are slow.
## Resources
256512Mi request is typical for a PHP-FPM or Artisan-served app. Workers (queue:work, horizon) are separate processes — give them their own Deployment and 256512Mi each.

65
docs/python.md Normal file
View File

@@ -0,0 +1,65 @@
# Python (Django / FastAPI)
## Health probe host header
Kubernetes sends probe requests directly to the pod IP. Django's `ALLOWED_HOSTS` rejects requests without a matching `Host` header, causing every probe to return 400 and the pod to restart. Add a `Host` header to all HTTP probes:
```yaml
livenessProbe:
httpGet:
path: /
port: 8000
httpHeaders:
- name: Host
value: "{{ .domain }}"
readinessProbe:
httpGet:
path: /
port: 8000
httpHeaders:
- name: Host
value: "{{ .domain }}"
```
FastAPI and Flask apps with explicit host validation have the same issue.
## Startup delay (apps that run migrations)
Django apps that run `migrate` on startup need extra time before the liveness probe fires. The default 60-second delay is too short for large migration sets.
```yaml
livenessProbe:
httpGet:
path: /
port: 8000
httpHeaders:
- name: Host
value: "{{ .domain }}"
initialDelaySeconds: 90
periodSeconds: 30
failureThreshold: 6
readinessProbe:
httpGet:
path: /
port: 8000
httpHeaders:
- name: Host
value: "{{ .domain }}"
initialDelaySeconds: 60
periodSeconds: 15
failureThreshold: 3
```
## Celery workers
Celery workers are full Python processes. Size them at 256512Mi and deploy them as a separate Deployment from the web process. They do not need an HTTP probe — use a `tcpSocket` or `exec` check if a liveness probe is needed at all.
## Non-interactive superuser creation
```bash
kubectl exec -n <ns> <pod> -- \
env DJANGO_SUPERUSER_PASSWORD="${PASSWORD}" \
python manage.py createsuperuser --email "${EMAIL}" --noinput
```
`--noinput` reads the password from `DJANGO_SUPERUSER_PASSWORD`.

33
docs/redis.md Normal file
View File

@@ -0,0 +1,33 @@
# Redis
## Authenticated Redis URL
Wild Cloud's Redis requires a password. Apps that take a Redis URL must embed the password. Use Kubernetes env var expansion so the password isn't hardcoded:
```yaml
requiredSecrets:
- redis.password
# In deployment env:
- name: REDIS_PASSWORD
valueFrom:
secretKeyRef:
name: myapp-secrets
key: redis.password
- name: REDIS_URL
value: "redis://:$(REDIS_PASSWORD)@{{ .redis.host }}:6379"
```
`$(REDIS_PASSWORD)` is evaluated by Kubernetes at pod start from other env vars in the same container spec.
## Apps that don't support Redis passwords
Some apps only accept `REDIS_HOST` and `REDIS_PORT` with no password option (e.g. Ushahidi). Deploy a dedicated unauthenticated Redis sidecar for those apps and remove `redis` from `requires` in `manifest.yaml`:
```yaml
- name: redis
image: redis:7-alpine
args: ["--save", ""] # disable persistence
```
Point the app's `REDIS_HOST` at the sidecar's service name.

32
docs/ruby.md Normal file
View File

@@ -0,0 +1,32 @@
# Ruby (Rails)
## Startup delay
Rails startup (bundle exec, asset precompilation, initializer chain) typically takes 3090 seconds. Extend the liveness probe delay to avoid restart loops:
```yaml
livenessProbe:
tcpSocket:
port: 3000
initialDelaySeconds: 90
periodSeconds: 30
failureThreshold: 6
readinessProbe:
httpGet:
path: /
port: 3000
initialDelaySeconds: 60
periodSeconds: 15
failureThreshold: 3
```
## Sidekiq workers
Sidekiq is a full Ruby process, not a thread pool. Deploy it as a separate Deployment from the web process with 256512Mi memory. It does not need an HTTP liveness probe — omit it or use a simple `exec` check.
## Non-interactive rails console / rake tasks
```bash
kubectl exec -n <ns> <pod> -- bundle exec rake db:migrate
kubectl exec -n <ns> <pod> -- bundle exec rails runner "User.create!(...)"
```

45
docs/scripts.md Normal file
View File

@@ -0,0 +1,45 @@
# Post-Deploy Scripts
Some apps require a management command after first deploy to create an admin account, register a node, or invite the first user. Package these as shell scripts in `scripts/` alongside the kustomize files.
## Registering scripts
Register every script in `manifest.yaml` — the web UI shows a button with a parameter form for each one:
```yaml
scripts:
- name: create-superuser
path: scripts/create-superuser.sh
description: "Create the initial admin account."
params:
- name: EMAIL
required: true
- name: PASSWORD
description: Leave blank to generate a random one
```
## Script conventions
Follow `synapse/versions/v1/scripts/create-user.sh` as the reference implementation:
- Require `KUBECONFIG`, `WILD_INSTANCE`, and `WILD_API_DATA_DIR`; exit with a clear error if missing
- Read `namespace` from `config.yaml` via `yq` — never hardcode it
- Auto-generate passwords with `openssl rand` if `PASSWORD` is not supplied
- Find the running pod by label — never hardcode a pod name
- Print credentials at the end with a "save this — it won't be shown again" warning
## Common commands
**Django non-interactive superuser**:
```bash
kubectl exec -n <ns> <pod> -- \
env DJANGO_SUPERUSER_PASSWORD="${PASSWORD}" \
python manage.py createsuperuser --email "${EMAIL}" --noinput
```
**Rails**:
```bash
kubectl exec -n <ns> <pod> -- bundle exec rake db:migrate
```
**Affected apps**: Eventyay, Synapse, Headscale.

47
docs/traefik.md Normal file
View File

@@ -0,0 +1,47 @@
# Traefik
## "No available server" after redeploy `[WC-SEL]`
If a Deployment was originally created without kustomize labels in its selector, re-deploying cannot fix it — `spec.selector` is immutable once set. The Service selector updates (it's mutable) but pods won't match it, leaving the Service with no endpoints.
**Symptom**: Traefik returns "No available server" even though pods are Running.
**Diagnosis**:
```bash
kubectl get endpoints <app> -n <namespace>
# shows <none> — pods aren't matching the service
kubectl get pods -n <namespace> --show-labels
# pod labels don't include app: <name> / managedBy: kustomize / partOf: wild-cloud
```
**Fix**: delete the Deployment and re-deploy:
```bash
kubectl delete deployment <name> -n <ns>
wild app deploy <app>
```
## Same-origin iframes
The global Traefik `security-headers` middleware sets `X-Frame-Options: SAMEORIGIN`. Apps that embed their own sub-pages in iframes (e.g. Etherpad's pad editor) work correctly with this setting.
`DENY` was the previous setting and broke same-origin iframes. If you see:
```
Blocked a frame with origin "https://..." from accessing a cross-origin frame
```
the app is likely creating a same-origin iframe that a stale `DENY` header is blocking. Verify the cluster's Traefik middleware is using `SAMEORIGIN`.
**Note**: route-level Traefik middlewares run before entrypoint middlewares on the response path. A per-app middleware cannot override the global one.
## TLS-terminating reverse proxy (DISABLE_HTTPS)
Some apps (e.g. Zulip) redirect port 80 → HTTPS internally. When Traefik terminates TLS and forwards plain HTTP, this causes an infinite redirect loop.
Set these env vars to disable the internal redirect and trust the forwarded proto:
```yaml
- name: DISABLE_HTTPS
value: "true"
- name: LOADBALANCER_IPS
value: "10.244.0.0/16" # Kubernetes pod CIDR (Flannel default)
```
Also update liveness/readiness probes from HTTPS port 443 to HTTP port 80.

View File

@@ -3,6 +3,8 @@ is: e2e-test-app
description: End-to-end test application for automated integration testing. Includes PVC and PostgreSQL dependency to exercise all backup strategies. description: End-to-end test application for automated integration testing. Includes PVC and PostgreSQL dependency to exercise all backup strategies.
category: services category: services
latest: "2" latest: "2"
ignoreRules:
- WC-ICON # test/QA app; not user-facing
upgrade: upgrade:
from: from:
- version: ">=1.0.0" - version: ">=1.0.0"

View File

@@ -1,11 +1,9 @@
version: 1.0.0-1 version: 1.0.0-2
requires: requires:
- name: postgres - name: postgres
defaultConfig: defaultConfig:
namespace: e2e-test-app namespace: e2e-test-app
domain: e2e-test-app.{{ .cloud.domain }} domain: e2e-test-app.{{ .cloud.domain }}
externalDnsDomain: '{{ .cloud.domain }}'
tlsSecretName: wildcard-wild-cloud-tls
storage: 1Gi storage: 1Gi
db: db:
host: '{{ .apps.postgres.host }}' host: '{{ .apps.postgres.host }}'
@@ -13,8 +11,9 @@ defaultConfig:
name: e2e_test_app name: e2e_test_app
user: e2e_test_app user: e2e_test_app
defaultSecrets: defaultSecrets:
- key: dbPassword - key: dbPassword
- key: dbUrl - key: dbUrl
default: "postgres://{{ .app.db.user }}:{{ .secrets.dbPassword }}@{{ .app.db.host }}:{{ .app.db.port }}/{{ .app.db.name }}?sslmode=disable" default: postgres://{{ .app.db.user }}:{{ .secrets.dbPassword }}@{{ .app.db.host
}}:{{ .app.db.port }}/{{ .app.db.name }}?sslmode=disable
requiredSecrets: requiredSecrets:
- postgres.password - postgres.password

View File

@@ -1,12 +1,10 @@
version: 2.0.0-1 version: 2.0.0-2
requires: requires:
- name: postgres - name: postgres
- name: mysql - name: mysql
defaultConfig: defaultConfig:
namespace: e2e-test-app namespace: e2e-test-app
domain: e2e-test-app.{{ .cloud.domain }} domain: e2e-test-app.{{ .cloud.domain }}
externalDnsDomain: '{{ .cloud.domain }}'
tlsSecretName: wildcard-wild-cloud-tls
storage: 1Gi storage: 1Gi
db: db:
host: '{{ .apps.postgres.host }}' host: '{{ .apps.postgres.host }}'
@@ -19,10 +17,11 @@ defaultConfig:
name: e2e_test_app name: e2e_test_app
user: e2e_test_app user: e2e_test_app
defaultSecrets: defaultSecrets:
- key: dbPassword - key: dbPassword
- key: dbUrl - key: dbUrl
default: "postgres://{{ .app.db.user }}:{{ .secrets.dbPassword }}@{{ .app.db.host }}:{{ .app.db.port }}/{{ .app.db.name }}?sslmode=disable" default: postgres://{{ .app.db.user }}:{{ .secrets.dbPassword }}@{{ .app.db.host
- key: mysqlPassword }}:{{ .app.db.port }}/{{ .app.db.name }}?sslmode=disable
- key: mysqlPassword
requiredSecrets: requiredSecrets:
- postgres.password - postgres.password
- mysql.rootPassword - mysql.rootPassword

View File

@@ -3,10 +3,6 @@ kind: Ingress
metadata: metadata:
name: etherpad name: etherpad
namespace: etherpad namespace: etherpad
annotations:
external-dns.alpha.kubernetes.io/target: {{ .externalDnsDomain }}
external-dns.alpha.kubernetes.io/cloudflare-proxied: "false"
external-dns.alpha.kubernetes.io/ttl: "60"
spec: spec:
ingressClassName: traefik ingressClassName: traefik
rules: rules:
@@ -20,7 +16,3 @@ spec:
name: etherpad name: etherpad
port: port:
number: 80 number: 80
tls:
- hosts:
- {{ .domain }}
secretName: {{ .tlsSecretName }}

View File

@@ -1,11 +1,9 @@
version: 2.2.7-1 version: 2.2.7-2
requires: requires:
- name: postgres - name: postgres
defaultConfig: defaultConfig:
namespace: etherpad namespace: etherpad
externalDnsDomain: '{{ .cloud.domain }}'
domain: etherpad.{{ .cloud.domain }} domain: etherpad.{{ .cloud.domain }}
tlsSecretName: wildcard-wild-cloud-tls
storage: 2Gi storage: 2Gi
title: Etherpad title: Etherpad
db: db:
@@ -14,9 +12,10 @@ defaultConfig:
name: etherpad name: etherpad
user: etherpad user: etherpad
defaultSecrets: defaultSecrets:
- key: adminPassword - key: adminPassword
- key: dbPassword - key: dbPassword
- key: dbUrl - key: dbUrl
default: 'postgresql://{{ .app.db.user }}:{{ .secrets.dbPassword }}@{{ .app.db.host }}:{{ .app.db.port }}/{{ .app.db.name }}?sslmode=disable' default: postgresql://{{ .app.db.user }}:{{ .secrets.dbPassword }}@{{ .app.db.host
}}:{{ .app.db.port }}/{{ .app.db.name }}?sslmode=disable
requiredSecrets: requiredSecrets:
- postgres.password - postgres.password

View File

@@ -4,3 +4,5 @@ description: Eventyay is an open-source event management platform covering ticke
category: community category: community
icon: https://raw.githubusercontent.com/fossasia/eventyay/main/app/eventyay/static/common/img/logo.svg icon: https://raw.githubusercontent.com/fossasia/eventyay/main/app/eventyay/static/common/img/logo.svg
latest: "1" latest: "1"
ignoreRules:
- WC-IMG # no versioned Docker tags upstream; main tag is intentional

View File

@@ -27,7 +27,7 @@ Key settings in `config.yaml`:
2. Create the superuser account: 2. Create the superuser account:
```bash ```bash
kubectl exec -n eventyay deploy/eventyay -- python manage.py createsuperuser EMAIL=you@example.com scripts/create-superuser.sh
``` ```
3. Log in at `/orga/login/` with the credentials you just created. 3. Log in at `/orga/login/` with the credentials you just created.

View File

@@ -122,26 +122,48 @@ spec:
mountPath: /home/app/.gunicorn mountPath: /home/app/.gunicorn
livenessProbe: livenessProbe:
httpGet: httpGet:
path: / path: /static/pretixbase/img/eventyay-icon.svg
port: 8000 port: 8080
httpHeaders:
- name: Host
value: "{{ .domain }}"
initialDelaySeconds: 120 initialDelaySeconds: 120
timeoutSeconds: 10 timeoutSeconds: 10
periodSeconds: 30 periodSeconds: 30
failureThreshold: 6 failureThreshold: 6
readinessProbe: readinessProbe:
httpGet: httpGet:
path: / path: /static/pretixbase/img/eventyay-icon.svg
port: 8000 port: 8080
httpHeaders:
- name: Host
value: "{{ .domain }}"
initialDelaySeconds: 60 initialDelaySeconds: 60
timeoutSeconds: 5 timeoutSeconds: 5
periodSeconds: 15 periodSeconds: 15
failureThreshold: 3 failureThreshold: 3
- name: nginx
image: nginxinc/nginx-unprivileged:1.27-alpine
ports:
- name: nginx-http
containerPort: 8080
protocol: TCP
volumeMounts:
- name: eventyay-static
mountPath: /static
readOnly: true
- name: nginx-config
mountPath: /etc/nginx/conf.d
readOnly: true
resources:
limits:
cpu: 200m
memory: 128Mi
requests:
cpu: 10m
memory: 32Mi
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop:
- ALL
add:
- NET_BIND_SERVICE
readOnlyRootFilesystem: false
volumes: volumes:
- name: eventyay-data - name: eventyay-data
persistentVolumeClaim: persistentVolumeClaim:
@@ -150,6 +172,9 @@ spec:
emptyDir: {} emptyDir: {}
- name: eventyay-gunicorn - name: eventyay-gunicorn
emptyDir: {} emptyDir: {}
- name: nginx-config
configMap:
name: eventyay-nginx
restartPolicy: Always restartPolicy: Always
--- ---
apiVersion: apps/v1 apiVersion: apps/v1
@@ -237,9 +262,9 @@ spec:
limits: limits:
cpu: "2" cpu: "2"
ephemeral-storage: 1Gi ephemeral-storage: 1Gi
memory: 1Gi memory: 4Gi
requests: requests:
cpu: 50m cpu: 50m
ephemeral-storage: 50Mi ephemeral-storage: 50Mi
memory: 512Mi memory: 2Gi
restartPolicy: Always restartPolicy: Always

View File

@@ -3,10 +3,6 @@ kind: Ingress
metadata: metadata:
name: eventyay name: eventyay
namespace: {{ .namespace }} namespace: {{ .namespace }}
annotations:
external-dns.alpha.kubernetes.io/target: {{ .externalDnsDomain }}
external-dns.alpha.kubernetes.io/cloudflare-proxied: "false"
external-dns.alpha.kubernetes.io/ttl: "60"
spec: spec:
ingressClassName: traefik ingressClassName: traefik
rules: rules:
@@ -20,7 +16,3 @@ spec:
name: eventyay name: eventyay
port: port:
number: 80 number: 80
tls:
- hosts:
- {{ .domain }}
secretName: {{ .tlsSecretName }}

View File

@@ -10,6 +10,7 @@ labels:
resources: resources:
- namespace.yaml - namespace.yaml
- pvc.yaml - pvc.yaml
- nginx-configmap.yaml
- deployment.yaml - deployment.yaml
- service.yaml - service.yaml
- ingress.yaml - ingress.yaml

View File

@@ -1,13 +1,11 @@
version: main-2 version: main-6
requires: requires:
- name: postgres - name: postgres
- name: redis - name: redis
- name: smtp - name: smtp
defaultConfig: defaultConfig:
namespace: eventyay namespace: eventyay
externalDnsDomain: '{{ .cloud.domain }}'
domain: eventyay.{{ .cloud.domain }} domain: eventyay.{{ .cloud.domain }}
tlsSecretName: wildcard-wild-cloud-tls
storage: 2Gi storage: 2Gi
timezone: UTC timezone: UTC
db: db:
@@ -23,10 +21,20 @@ defaultConfig:
port: '{{ .apps.smtp.port }}' port: '{{ .apps.smtp.port }}'
from: '{{ .apps.smtp.from }}' from: '{{ .apps.smtp.from }}'
user: '{{ .apps.smtp.user }}' user: '{{ .apps.smtp.user }}'
scripts:
- name: create-superuser
path: scripts/create-superuser.sh
description: Create an Eventyay superuser account for first-time setup.
params:
- name: EMAIL
description: Email address for the superuser account
required: true
- name: PASSWORD
description: Password (leave blank to generate a random one)
defaultSecrets: defaultSecrets:
- key: dbPassword - key: dbPassword
- key: djangoSecret - key: djangoSecret
requiredSecrets: requiredSecrets:
- postgres.password - postgres.password
- redis.password - redis.password
- smtp.password - smtp.password

View File

@@ -0,0 +1,31 @@
apiVersion: v1
kind: ConfigMap
metadata:
name: eventyay-nginx
namespace: {{ .namespace }}
data:
nginx.conf: |
server {
listen 8080;
server_name _;
client_max_body_size 100m;
location /static/ {
alias /static/;
expires 30d;
add_header Cache-Control "public, immutable";
}
location /media/ {
alias /media/;
}
location / {
proxy_pass http://127.0.0.1:8000;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto;
proxy_read_timeout 120s;
}
}

View File

@@ -0,0 +1,56 @@
#!/bin/bash
# Create an Eventyay superuser (organizer admin account).
#
# Required for first-time setup — the web UI cannot be accessed until
# at least one superuser exists.
#
# Runs on the worker pod because the web pod's memory limit is too tight
# to run an additional Django process alongside gunicorn workers.
set -e
set -o pipefail
if [ -z "${KUBECONFIG}" ] || [ -z "${WILD_INSTANCE}" ] || [ -z "${WILD_API_DATA_DIR}" ]; then
echo "ERROR: KUBECONFIG, WILD_INSTANCE, and WILD_API_DATA_DIR must be set"
exit 1
fi
if [ -z "${EMAIL}" ]; then
echo "ERROR: EMAIL is required"
exit 1
fi
CONFIG_FILE="${WILD_API_DATA_DIR}/instances/${WILD_INSTANCE}/config.yaml"
NAMESPACE="$(yq '.apps.eventyay.namespace' "${CONFIG_FILE}" | tr -d '"')"
NAMESPACE="${NAMESPACE:-eventyay}"
# Run on the worker pod — same image as web, but with a higher memory limit
# which is needed to run manage.py alongside the existing gunicorn workers.
POD="$(kubectl get pods -n "${NAMESPACE}" -l component=worker --field-selector=status.phase=Running -o jsonpath='{.items[0].metadata.name}' 2>/dev/null)"
if [ -z "${POD}" ]; then
echo "ERROR: No running Eventyay worker pod found in namespace ${NAMESPACE}"
exit 1
fi
if [ -z "${PASSWORD}" ]; then
PASSWORD="$(openssl rand -base64 24 | tr -d '/+=' | head -c 24)"
fi
echo "Creating superuser '${EMAIL}'..."
kubectl exec -n "${NAMESPACE}" "${POD}" -- \
python manage.py shell -c "
from django.contrib.auth import get_user_model
User = get_user_model()
if User.objects.filter(email='${EMAIL}').exists():
raise SystemExit('ERROR: User ${EMAIL} already exists')
User.objects.create_superuser(email='${EMAIL}', password='${PASSWORD}')
"
echo ""
echo "=== Superuser created ==="
echo "Email: ${EMAIL}"
echo "Password: ${PASSWORD}"
echo ""
echo "Log in at /orga/login/ with these credentials."
echo "Save this password — it will not be shown again."

View File

@@ -9,5 +9,5 @@ spec:
ports: ports:
- name: http - name: http
port: 80 port: 80
targetPort: 8000 targetPort: 8080
protocol: TCP protocol: TCP

View File

@@ -3,3 +3,6 @@ is: example
description: An example application that is deployed with internal-only access. description: An example application that is deployed with internal-only access.
category: services category: services
latest: "1" latest: "1"
ignoreRules:
- WC-ICON # example/demo app
- WC-IMG # example/demo app intentionally uses latest for simplicity

View File

@@ -15,6 +15,9 @@ spec:
labels: labels:
app: example-admin app: example-admin
spec: spec:
securityContext:
seccompProfile:
type: RuntimeDefault
containers: containers:
- name: example-admin - name: example-admin
image: nginx:latest image: nginx:latest
@@ -28,6 +31,15 @@ spec:
requests: requests:
cpu: 50m cpu: 50m
memory: 10Mi memory: 10Mi
securityContext:
runAsNonRoot: false
runAsUser: 0
allowPrivilegeEscalation: false
capabilities:
drop: [ALL]
readOnlyRootFilesystem: false
seccompProfile:
type: RuntimeDefault
livenessProbe: livenessProbe:
httpGet: httpGet:
path: / path: /

View File

@@ -4,6 +4,7 @@ kind: Ingress
metadata: metadata:
name: example-admin name: example-admin
spec: spec:
ingressClassName: traefik
rules: rules:
- host: "{{ .host }}" - host: "{{ .host }}"
http: http:
@@ -15,7 +16,3 @@ spec:
name: example-admin name: example-admin
port: port:
number: 80 number: 80
tls:
- hosts:
- "{{ .host }}"
secretName: wildcard-internal-wild-cloud-tls

View File

@@ -1,6 +1,4 @@
version: 1.0.0-1 version: 1.0.0-3
defaultConfig: defaultConfig:
namespace: example-admin namespace: example-admin
externalDnsDomain: '{{ .cloud.domain }}'
host: '{{ .host }}' host: '{{ .host }}'
tlsSecretName: wildcard-internal-wild-cloud-tls

View File

@@ -3,3 +3,5 @@ is: example
description: An example application that is deployed with public access. description: An example application that is deployed with public access.
category: services category: services
latest: "1" latest: "1"
ignoreRules:
- WC-ICON # example/demo app

View File

@@ -13,6 +13,9 @@ spec:
labels: labels:
app: example-app app: example-app
spec: spec:
securityContext:
seccompProfile:
type: RuntimeDefault
containers: containers:
- name: example-app - name: example-app
image: nginx:alpine image: nginx:alpine
@@ -26,6 +29,15 @@ spec:
requests: requests:
cpu: 50m cpu: 50m
memory: 32Mi memory: 32Mi
securityContext:
runAsNonRoot: false
runAsUser: 0
allowPrivilegeEscalation: false
capabilities:
drop: [ALL]
readOnlyRootFilesystem: false
seccompProfile:
type: RuntimeDefault
livenessProbe: livenessProbe:
httpGet: httpGet:
path: / path: /

View File

@@ -4,8 +4,6 @@ kind: Ingress
metadata: metadata:
name: example-app name: example-app
annotations: annotations:
external-dns.alpha.kubernetes.io/target: "{{ .cloud.externalDnsTarget }}"
external-dns.alpha.kubernetes.io/cloudflare-proxied: "false"
# Optional: Enable basic auth # Optional: Enable basic auth
# traefik.ingress.kubernetes.io/auth-type: basic # traefik.ingress.kubernetes.io/auth-type: basic
# traefik.ingress.kubernetes.io/auth-secret: basic-auth # traefik.ingress.kubernetes.io/auth-secret: basic-auth
@@ -22,7 +20,3 @@ spec:
name: example-app name: example-app
port: port:
number: 80 number: 80
tls:
- hosts:
- "{{ .host }}"
secretName: wildcard-wild-cloud-tls

View File

@@ -1,6 +1,4 @@
version: 1.0.0-1 version: 1.0.0-2
defaultConfig: defaultConfig:
namespace: example-app namespace: example-app
externalDnsDomain: '{{ .cloud.domain }}'
host: example-app.{{ .cloud.domain }} host: example-app.{{ .cloud.domain }}
tlsSecretName: wildcard-wild-cloud-tls

View File

@@ -3,3 +3,7 @@ is: externaldns
description: Automatically configures DNS records for services description: Automatically configures DNS records for services
category: services category: services
latest: "v0" latest: "v0"
ignoreRules:
- WC-ICON # infrastructure service; not user-facing
- WC-SEL # infrastructure service; selectors are managed by the upstream manifest
- WC-NS # fixed namespace: externaldns

View File

@@ -16,6 +16,10 @@ spec:
app: external-dns app: external-dns
spec: spec:
serviceAccountName: external-dns serviceAccountName: external-dns
securityContext:
runAsNonRoot: true
seccompProfile:
type: RuntimeDefault
containers: containers:
- name: external-dns - name: external-dns
image: registry.k8s.io/external-dns/external-dns:v0.13.4 image: registry.k8s.io/external-dns/external-dns:v0.13.4
@@ -24,12 +28,21 @@ spec:
- --source=ingress - --source=ingress
- --txt-owner-id={{ .ownerId }} - --txt-owner-id={{ .ownerId }}
- --provider=cloudflare - --provider=cloudflare
- --domain-filter=payne.io {{- with (index . "domainFilter") }}
#- --exclude-domains=internal.${DOMAIN} - --domain-filter={{ . }}
{{- end }}
{{- with (index . "internalDomain") }}
- --exclude-domains={{ . }}
{{- end }}
- --cloudflare-dns-records-per-page=5000 - --cloudflare-dns-records-per-page=5000
- --publish-internal-services - --publish-internal-services
- --no-cloudflare-proxied - --no-cloudflare-proxied
- --log-level=debug - --log-level=debug
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop: [ALL]
readOnlyRootFilesystem: true
env: env:
- name: CF_API_TOKEN - name: CF_API_TOKEN
valueFrom: valueFrom:

View File

@@ -1,10 +1,11 @@
version: v0.13.4-1 version: v0.13.4-6
deploymentName: external-dns deploymentName: external-dns
requires: requires:
- name: cert-manager - name: cert-manager
defaultConfig: defaultConfig:
namespace: externaldns namespace: externaldns
ownerId: "wild-cloud-{{ .cluster.name }}" ownerId: "wild-cloud-{{ .cluster.name }}"
internalDomain: "{{ .cloud.internalDomain }}"
defaultSecrets: defaultSecrets:
- key: cloudflareToken - key: cloudflareToken
requiredSecrets: requiredSecrets:

View File

@@ -3,10 +3,6 @@ kind: Ingress
metadata: metadata:
name: firefly-iii name: firefly-iii
namespace: firefly-iii namespace: firefly-iii
annotations:
external-dns.alpha.kubernetes.io/target: {{ .externalDnsDomain }}
external-dns.alpha.kubernetes.io/cloudflare-proxied: "false"
external-dns.alpha.kubernetes.io/ttl: "60"
spec: spec:
ingressClassName: traefik ingressClassName: traefik
rules: rules:
@@ -20,7 +16,3 @@ spec:
name: firefly-iii name: firefly-iii
port: port:
number: 80 number: 80
tls:
- hosts:
- {{ .domain }}
secretName: {{ .tlsSecretName }}

View File

@@ -1,11 +1,9 @@
version: 6.6.3-1 version: 6.6.3-2
requires: requires:
- name: postgres - name: postgres
defaultConfig: defaultConfig:
namespace: firefly-iii namespace: firefly-iii
externalDnsDomain: '{{ .cloud.domain }}'
domain: firefly-iii.{{ .cloud.domain }} domain: firefly-iii.{{ .cloud.domain }}
tlsSecretName: wildcard-wild-cloud-tls
storage: 1Gi storage: 1Gi
timezone: UTC timezone: UTC
adminEmail: '{{ .operator.email }}' adminEmail: '{{ .operator.email }}'
@@ -15,7 +13,7 @@ defaultConfig:
name: fireflyiii name: fireflyiii
user: fireflyiii user: fireflyiii
defaultSecrets: defaultSecrets:
- key: appKey - key: appKey
- key: dbPassword - key: dbPassword
requiredSecrets: requiredSecrets:
- postgres.password - postgres.password

View File

@@ -3,10 +3,6 @@ kind: Ingress
metadata: metadata:
name: formbricks name: formbricks
namespace: {{ .namespace }} namespace: {{ .namespace }}
annotations:
external-dns.alpha.kubernetes.io/target: {{ .externalDnsDomain }}
external-dns.alpha.kubernetes.io/cloudflare-proxied: "false"
external-dns.alpha.kubernetes.io/ttl: "60"
spec: spec:
ingressClassName: traefik ingressClassName: traefik
rules: rules:
@@ -20,7 +16,3 @@ spec:
name: formbricks name: formbricks
port: port:
number: 80 number: 80
tls:
- hosts:
- {{ .domain }}
secretName: {{ .tlsSecretName }}

View File

@@ -1,27 +1,26 @@
version: 3.6.0-1 version: 3.6.0-2
requires: requires:
- name: postgres - name: postgres
- name: redis - name: redis
defaultConfig: defaultConfig:
namespace: formbricks namespace: formbricks
externalDnsDomain: "{{ .cloud.domain }}"
domain: formbricks.{{ .cloud.domain }} domain: formbricks.{{ .cloud.domain }}
tlsSecretName: wildcard-wild-cloud-tls
storage: 2Gi storage: 2Gi
db: db:
host: "{{ .apps.postgres.host }}" host: '{{ .apps.postgres.host }}'
port: "{{ .apps.postgres.port }}" port: '{{ .apps.postgres.port }}'
name: formbricks name: formbricks
user: formbricks user: formbricks
redis: redis:
host: "{{ .apps.redis.host }}" host: '{{ .apps.redis.host }}'
defaultSecrets: defaultSecrets:
- key: dbPassword - key: dbPassword
- key: dbUrl - key: dbUrl
default: "postgresql://{{ .app.db.user }}:{{ .secrets.dbPassword }}@{{ .app.db.host }}:{{ .app.db.port }}/{{ .app.db.name }}?sslmode=disable" default: postgresql://{{ .app.db.user }}:{{ .secrets.dbPassword }}@{{ .app.db.host
- key: nextauthSecret }}:{{ .app.db.port }}/{{ .app.db.name }}?sslmode=disable
- key: encryptionKey - key: nextauthSecret
- key: cronSecret - key: encryptionKey
- key: cronSecret
requiredSecrets: requiredSecrets:
- postgres.password - postgres.password
- redis.password - redis.password

21
gancio/notes.md Normal file
View File

@@ -0,0 +1,21 @@
# Gancio — Notes
## "Non empty db" crash on re-deploy
Gancio stores its setup state in `config.json` on the data PVC. If the PVC is lost or the app is
deleted and redeployed against an existing database, Gancio finds a non-empty DB but no
`config.json` and refuses to start: `"Non empty db! Please move your current db elsewhere than retry."`
**Fix**: drop and recreate the public schema, then restart the deployment:
```bash
kubectl exec -n postgres <postgres-pod> -- psql -U postgres gancio \
-c "DROP SCHEMA public CASCADE; CREATE SCHEMA public; GRANT ALL ON SCHEMA public TO gancio; GRANT ALL ON SCHEMA public TO public;"
kubectl scale deployment -n gancio gancio --replicas=0
# wait for pod to terminate
kubectl scale deployment -n gancio gancio --replicas=1
```
Scale down before dropping the schema to release the database connection first.
This only affects re-deployments; fresh installs work fine.

View File

@@ -28,6 +28,6 @@ Key settings in `config.yaml`:
## Notes ## Notes
- **Re-deploy warning**: If the app is deleted and redeployed with an existing database, Gancio will refuse to start with a "Non empty db" error. Fix by dropping and recreating the public schema in the `gancio` database before restarting. See ADDING-APPS-NOTES.md note 18 for the exact commands. - **Re-deploy warning**: If the app is deleted and redeployed with an existing database, Gancio will refuse to start with a "Non empty db" error. See `gancio/notes.md` for the fix.
- Gancio stores its setup state in `config.json` on the data PVC — do not delete the PVC without also clearing the database - Gancio stores its setup state in `config.json` on the data PVC — do not delete the PVC without also clearing the database
- Events are federated to Mastodon and other ActivityPub platforms - Events are federated to Mastodon and other ActivityPub platforms

View File

@@ -3,10 +3,6 @@ kind: Ingress
metadata: metadata:
name: gancio name: gancio
namespace: gancio namespace: gancio
annotations:
external-dns.alpha.kubernetes.io/target: {{ .externalDnsDomain }}
external-dns.alpha.kubernetes.io/cloudflare-proxied: "false"
external-dns.alpha.kubernetes.io/ttl: "60"
spec: spec:
ingressClassName: traefik ingressClassName: traefik
rules: rules:
@@ -20,7 +16,3 @@ spec:
name: gancio name: gancio
port: port:
number: 80 number: 80
tls:
- hosts:
- {{ .domain }}
secretName: {{ .tlsSecretName }}

View File

@@ -1,11 +1,9 @@
version: 1.28.2-1 version: 1.28.2-2
requires: requires:
- name: postgres - name: postgres
defaultConfig: defaultConfig:
namespace: gancio namespace: gancio
externalDnsDomain: '{{ .cloud.domain }}'
domain: gancio.{{ .cloud.domain }} domain: gancio.{{ .cloud.domain }}
tlsSecretName: wildcard-wild-cloud-tls
storage: 2Gi storage: 2Gi
db: db:
host: '{{ .apps.postgres.host }}' host: '{{ .apps.postgres.host }}'
@@ -13,8 +11,9 @@ defaultConfig:
name: gancio name: gancio
user: gancio user: gancio
defaultSecrets: defaultSecrets:
- key: dbPassword - key: dbPassword
- key: dbUrl - key: dbUrl
default: 'postgresql://{{ .app.db.user }}:{{ .secrets.dbPassword }}@{{ .app.db.host }}:{{ .app.db.port }}/{{ .app.db.name }}?sslmode=disable' default: postgresql://{{ .app.db.user }}:{{ .secrets.dbPassword }}@{{ .app.db.host
}}:{{ .app.db.port }}/{{ .app.db.name }}?sslmode=disable
requiredSecrets: requiredSecrets:
- postgres.password - postgres.password

15
ghost/notes.md Normal file
View File

@@ -0,0 +1,15 @@
# Ghost — Notes
## Bitnami image migration
Ghost was originally packaged using `bitnami/ghost`. Bitnami moved their images off Docker Hub
in 2023, so the Bitnami image no longer pulls without authentication.
**Fix**: use the official `ghost:X.Y.Z-alpine` image. The official image has different conventions:
| | Bitnami | Official |
|---|---|---|
| DB host env | `GHOST_DATABASE_HOST` | `database__connection__host` |
| Content path | `/bitnami/ghost` | `/var/lib/ghost/content` |
Check `docker manifest inspect ghost:latest` for the current version tag before updating.

View File

@@ -19,6 +19,7 @@ spec:
mysql -h ${DB_HOSTNAME} -P ${DB_PORT} -u root -p${MYSQL_ROOT_PASSWORD} <<EOF mysql -h ${DB_HOSTNAME} -P ${DB_PORT} -u root -p${MYSQL_ROOT_PASSWORD} <<EOF
CREATE DATABASE IF NOT EXISTS ${DB_DATABASE_NAME}; CREATE DATABASE IF NOT EXISTS ${DB_DATABASE_NAME};
CREATE USER IF NOT EXISTS '${DB_USERNAME}'@'%' IDENTIFIED BY '${DB_PASSWORD}'; 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}'@'%'; GRANT ALL PRIVILEGES ON ${DB_DATABASE_NAME}.* TO '${DB_USERNAME}'@'%';
FLUSH PRIVILEGES; FLUSH PRIVILEGES;
EOF EOF

View File

@@ -3,10 +3,6 @@ kind: Ingress
metadata: metadata:
name: ghost name: ghost
namespace: ghost namespace: ghost
annotations:
external-dns.alpha.kubernetes.io/cloudflare-proxied: "false"
external-dns.alpha.kubernetes.io/target: {{ .externalDnsDomain }}
external-dns.alpha.kubernetes.io/ttl: "60"
spec: spec:
ingressClassName: traefik ingressClassName: traefik
rules: rules:
@@ -20,7 +16,3 @@ spec:
name: ghost name: ghost
port: port:
number: 80 number: 80
tls:
- hosts:
- {{ .domain }}
secretName: {{ .tlsSecretName }}

View File

@@ -1,12 +1,10 @@
version: 5.130.6-1 version: 5.130.6-2
requires: requires:
- name: mysql - name: mysql
- name: smtp - name: smtp
defaultConfig: defaultConfig:
namespace: ghost namespace: ghost
externalDnsDomain: '{{ .cloud.domain }}'
domain: ghost.{{ .cloud.domain }} domain: ghost.{{ .cloud.domain }}
tlsSecretName: wildcard-wild-cloud-tls
storage: 2Gi storage: 2Gi
db: db:
host: '{{ .apps.mysql.host }}' host: '{{ .apps.mysql.host }}'
@@ -19,7 +17,7 @@ defaultConfig:
from: '{{ .apps.smtp.from }}' from: '{{ .apps.smtp.from }}'
user: '{{ .apps.smtp.user }}' user: '{{ .apps.smtp.user }}'
defaultSecrets: defaultSecrets:
- key: dbPassword - key: dbPassword
- key: smtpPassword - key: smtpPassword
requiredSecrets: requiredSecrets:
- mysql.rootPassword - mysql.rootPassword

View File

@@ -19,7 +19,11 @@ spec:
partOf: wild-cloud partOf: wild-cloud
spec: spec:
securityContext: securityContext:
runAsNonRoot: true
runAsUser: 1000
fsGroup: 1000 fsGroup: 1000
seccompProfile:
type: RuntimeDefault
terminationGracePeriodSeconds: 60 terminationGracePeriodSeconds: 60
containers: containers:
- name: gitea - name: gitea
@@ -78,7 +82,10 @@ spec:
resources: resources:
{} {}
securityContext: securityContext:
{} allowPrivilegeEscalation: false
capabilities:
drop: [ALL]
readOnlyRootFilesystem: false
volumeMounts: volumeMounts:
- name: temp - name: temp
mountPath: /tmp mountPath: /tmp

View File

@@ -3,10 +3,8 @@ kind: Ingress
metadata: metadata:
name: gitea-public name: gitea-public
namespace: gitea namespace: gitea
annotations:
external-dns.alpha.kubernetes.io/cloudflare-proxied: "false"
external-dns.alpha.kubernetes.io/target: "{{ .externalDnsDomain }}"
spec: spec:
ingressClassName: traefik
rules: rules:
- host: "{{ .domain }}" - host: "{{ .domain }}"
http: http:
@@ -18,7 +16,3 @@ spec:
name: gitea-http name: gitea-http
port: port:
number: 3000 number: 3000
tls:
- secretName: "{{ .tlsSecretName }}"
hosts:
- "{{ .domain }}"

View File

@@ -1,16 +1,14 @@
version: 1.24.3-2 version: 1.24.3-5
requires: requires:
- name: postgres - name: postgres
- name: smtp - name: smtp
defaultConfig: defaultConfig:
namespace: gitea namespace: gitea
externalDnsDomain: '{{ .cloud.domain }}'
appName: Gitea appName: Gitea
domain: gitea.{{ .cloud.domain }} domain: gitea.{{ .cloud.domain }}
tlsSecretName: wildcard-wild-cloud-tls
storage: 2Gi storage: 2Gi
adminUser: admin adminUser: admin
adminEmail: "{{ .operator.email }}" adminEmail: '{{ .operator.email }}'
db: db:
name: gitea name: gitea
user: gitea user: gitea
@@ -22,10 +20,10 @@ defaultConfig:
user: '{{ .apps.smtp.user }}' user: '{{ .apps.smtp.user }}'
from: '{{ .apps.smtp.from }}' from: '{{ .apps.smtp.from }}'
defaultSecrets: defaultSecrets:
- key: adminPassword - key: adminPassword
- key: dbPassword - key: dbPassword
- key: secretKey - key: secretKey
- key: jwtSecret - key: jwtSecret
- key: smtpPassword - key: smtpPassword
requiredSecrets: requiredSecrets:
- postgres.password - postgres.password

View File

@@ -2,4 +2,5 @@ name: headlamp
is: headlamp is: headlamp
description: Modern Kubernetes web UI (SIG UI) with in-cluster authentication description: Modern Kubernetes web UI (SIG UI) with in-cluster authentication
category: services category: services
icon: https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/headlamp.svg
latest: "v0" latest: "v0"

View File

@@ -1,64 +1,18 @@
--- apiVersion: networking.k8s.io/v1
apiVersion: traefik.io/v1alpha1 kind: Ingress
kind: Middleware
metadata: metadata:
name: internal-only name: headlamp
namespace: headlamp namespace: headlamp
spec: spec:
ipWhiteList: ingressClassName: traefik
sourceRange: rules:
- 127.0.0.1/32 - host: headlamp.{{ .internalDomain }}
- 10.0.0.0/8 http:
- 172.16.0.0/12 paths:
- 192.168.0.0/16 - path: /
pathType: Prefix
--- backend:
apiVersion: traefik.io/v1alpha1 service:
kind: Middleware name: headlamp
metadata: port:
name: headlamp-redirect-scheme number: 80
namespace: headlamp
spec:
redirectScheme:
scheme: https
permanent: true
---
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
name: headlamp-https
namespace: headlamp
spec:
entryPoints:
- websecure
routes:
- match: Host(`headlamp.{{ .internalDomain }}`)
kind: Rule
middlewares:
- name: internal-only
namespace: headlamp
services:
- name: headlamp
port: 80
tls:
secretName: wildcard-internal-wild-cloud-tls
---
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
name: headlamp-http
namespace: headlamp
spec:
entryPoints:
- web
routes:
- match: Host(`headlamp.{{ .internalDomain }}`)
kind: Rule
middlewares:
- name: headlamp-redirect-scheme
namespace: headlamp
services:
- name: headlamp
port: 80

View File

@@ -1,11 +1,13 @@
version: v0.42.0 version: v0.42.0-1
requires:
- name: traefik
- name: cert-manager
defaultConfig: defaultConfig:
namespace: headlamp namespace: headlamp
internalDomain: "{{ .cloud.internalDomain }}" internalDomain: '{{ .cloud.internalDomain }}'
central:
ipAllow:
- 10.0.0.0/8
- 172.16.0.0/12
- 192.168.0.0/16
deploy: deploy:
waitForRollout: waitForRollout:
name: headlamp name: headlamp
timeout: "120s" timeout: 120s

36
headscale/notes.md Normal file
View File

@@ -0,0 +1,36 @@
# Headscale — Notes
## v0.29+ CLI: inconsistent user identifier types
Different commands in headscale v0.29 accept different user identifiers:
| Command | Accepts |
|---|---|
| `preauthkeys create --user <id>` | numeric ID only |
| `auth register --user <username>` | username string |
| `users destroy --identifier <id>` | numeric ID only (not positional) |
Always list users first to get the ID:
```bash
headscale users list
headscale preauthkeys create --user 1 # numeric ID
headscale auth register --auth-id <id> --user payne # username OK
headscale users destroy --identifier 1 # numeric ID
```
## Node registration via browser
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:
```bash
kubectl exec -n headscale deploy/headscale -- \
headscale auth register --auth-id <id> --user <username>
```

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