Compare commits
1 Commits
4d983819c9
...
mailu
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9b0c56f720 |
846
ADDING-APPS.md
846
ADDING-APPS.md
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,5 @@
|
|||||||
- @README.md
|
- @README.md
|
||||||
|
- @ADDING-APPS.md
|
||||||
- ADDING-APPS.md is the expert advice you need when adding new apps to the Wild Cloud Directory.
|
|
||||||
- docs/ contains deep-dive guides — runtime-specific (nodejs, python, php, ruby, jvm, nginx, linuxserver) and topic-specific (database, redis, traefik, scripts).
|
|
||||||
|
|
||||||
## Finding good sources of documentation for adding a new app to the Wild Cloud Directory
|
## Finding good sources of documentation for adding a new app to the Wild Cloud Directory
|
||||||
|
|
||||||
|
|||||||
@@ -1,692 +0,0 @@
|
|||||||
# Wild Directory Versioning and Upgrade System
|
|
||||||
|
|
||||||
Design specification for how Wild Cloud packages its third-party applications,
|
|
||||||
tracks versions, and manages upgrades between versions.
|
|
||||||
|
|
||||||
## Problem
|
|
||||||
|
|
||||||
Wild Cloud packages third-party applications as Kustomize templates. Each
|
|
||||||
application needs:
|
|
||||||
|
|
||||||
1. **Identity** that doesn't change between versions (name, description, icon)
|
|
||||||
2. **Version-specific files** (k8s manifests, config schema, secrets schema)
|
|
||||||
3. **Upgrade routing** when breaking changes prevent direct version jumps
|
|
||||||
4. A clear, low-friction workflow for maintainers bumping versions
|
|
||||||
|
|
||||||
The system must handle both simple apps (any version replaces any other) and
|
|
||||||
complex apps (database migrations, mandatory stepping stones between major
|
|
||||||
versions).
|
|
||||||
|
|
||||||
## Design
|
|
||||||
|
|
||||||
### Two-level structure: `app.yaml` + `versions/`
|
|
||||||
|
|
||||||
Each app in the Wild Directory has a root-level `app.yaml` for identity and a
|
|
||||||
`versions/` directory containing one or more version slots:
|
|
||||||
|
|
||||||
```
|
|
||||||
ghost/
|
|
||||||
+-- app.yaml
|
|
||||||
+-- versions/
|
|
||||||
+-- 5/
|
|
||||||
+-- manifest.yaml
|
|
||||||
+-- kustomization.yaml
|
|
||||||
+-- deployment.yaml
|
|
||||||
+-- ...
|
|
||||||
```
|
|
||||||
|
|
||||||
**`app.yaml`** holds version-independent fields: name, description, icon,
|
|
||||||
category, the `latest` pointer, and upgrade routing rules. These are facts
|
|
||||||
about the app itself, not about any particular release.
|
|
||||||
|
|
||||||
**`versions/{slot}/manifest.yaml`** holds version-specific fields: the precise
|
|
||||||
version string, dependency declarations, default config, default secrets,
|
|
||||||
deploy configuration, and per-version migration jobs. These are facts about a
|
|
||||||
particular release.
|
|
||||||
|
|
||||||
When the API installs an app, it reads both files and merges them. The
|
|
||||||
installed manifest in the instance data directory contains the complete picture
|
|
||||||
(name, description, icon, version, config, etc.).
|
|
||||||
|
|
||||||
### Version slots vs version strings
|
|
||||||
|
|
||||||
A version directory is a **slot**, not a precise version identifier.
|
|
||||||
|
|
||||||
The directory name is a stable label chosen by the maintainer. The actual
|
|
||||||
version string lives in `manifest.yaml` inside the directory. These are
|
|
||||||
intentionally decoupled:
|
|
||||||
|
|
||||||
| Concept | Where it lives | Example |
|
|
||||||
|---------|---------------|---------|
|
|
||||||
| Slot name | Directory name under `versions/` | `5` |
|
|
||||||
| Actual version | `version` field in `manifest.yaml` | `5.118.1-2` |
|
|
||||||
| Latest pointer | `latest` field in `app.yaml` | `5` |
|
|
||||||
| Waypoint pointer | `via` field in upgrade rules | `2` |
|
|
||||||
|
|
||||||
The slot name should be the simplest stable identifier that distinguishes it
|
|
||||||
from other slots. For semver apps, use the major version (`1`, `2`, `3`). For
|
|
||||||
apps with non-semver schemes, use whatever upstream version boundary makes
|
|
||||||
sense (`5`, `v4`, etc.).
|
|
||||||
|
|
||||||
**Packaging revisions** (`-1`, `-2`, etc.) never appear in directory names.
|
|
||||||
They only appear in `manifest.yaml`'s `version` field. A packaging revision
|
|
||||||
is a Wild Cloud-side fix (template improvement, security context change, config
|
|
||||||
restructure) that doesn't change the upstream software.
|
|
||||||
|
|
||||||
**Minor and patch versions** also don't require new directories unless they
|
|
||||||
introduce a breaking change that requires a waypoint. Updating Ghost from
|
|
||||||
5.118.1 to 5.119.0 means editing files inside `versions/5/` and bumping the
|
|
||||||
`version` field. The directory stays the same.
|
|
||||||
|
|
||||||
This follows how established package systems work:
|
|
||||||
|
|
||||||
| System | Directory/file identity | Where version lives |
|
|
||||||
|--------|------------------------|-------------------|
|
|
||||||
| Debian source packages | Package name (stable) | `debian/changelog` |
|
|
||||||
| Helm charts | Chart name directory | `Chart.yaml` |
|
|
||||||
| Homebrew | Formula file per package | Version attribute in file |
|
|
||||||
| Nix packages | Package name directory | Derivation attribute |
|
|
||||||
| FreeBSD ports | Port name directory | `Makefile` variable |
|
|
||||||
| **Wild Cloud** | **Slot directory** | **`manifest.yaml`** |
|
|
||||||
|
|
||||||
Wild Directory is a source package collection (templates compiled at install
|
|
||||||
time), not an artifact repository (pre-built binaries stored per version). The
|
|
||||||
source package pattern is the right fit.
|
|
||||||
|
|
||||||
### When directories are created and destroyed
|
|
||||||
|
|
||||||
**Most apps have exactly one version directory.** This is the common case for
|
|
||||||
apps where any version can replace any other (Ghost, Redis, most stateless
|
|
||||||
services).
|
|
||||||
|
|
||||||
**A second directory appears only when a waypoint is needed** -- when a
|
|
||||||
breaking change means some installed versions can't jump directly to the
|
|
||||||
latest and must pass through an intermediate version first.
|
|
||||||
|
|
||||||
**A directory is removed when it's no longer needed as a waypoint.** If
|
|
||||||
version `2/` was a waypoint for upgrading from 1.x to 3.x, but you later
|
|
||||||
decide to drop support for 1.x entirely, you can remove `2/` and update the
|
|
||||||
routing rules to block `<2.0.0`.
|
|
||||||
|
|
||||||
### `app.yaml` specification
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
# Required
|
|
||||||
name: ghost # Must match directory name
|
|
||||||
is: ghost # Unique type id, used for `requires` matching
|
|
||||||
description: "Ghost is a..." # Shown in app listings
|
|
||||||
latest: "5" # Slot name pointing to a directory in versions/
|
|
||||||
|
|
||||||
# Optional
|
|
||||||
icon: "https://..." # URL to app icon
|
|
||||||
category: infrastructure # Category for filtering
|
|
||||||
|
|
||||||
# Optional -- only needed for apps with breaking upgrade paths
|
|
||||||
upgrade:
|
|
||||||
from:
|
|
||||||
- version: ">=3.0.0" # Constraint against installed version
|
|
||||||
- version: ">=2.0.0"
|
|
||||||
via: "2" # Slot name of waypoint directory
|
|
||||||
- version: "<2.0.0"
|
|
||||||
blocked: true
|
|
||||||
notes: "Upgrade to 2.x first"
|
|
||||||
preUpgrade:
|
|
||||||
backup: recommended # "none", "recommended", or "required"
|
|
||||||
```
|
|
||||||
|
|
||||||
Fields:
|
|
||||||
|
|
||||||
| Field | Type | Required | Description |
|
|
||||||
|-------|------|----------|-------------|
|
|
||||||
| `name` | string | yes | App identifier, must match directory name |
|
|
||||||
| `is` | string | yes | Type id for dependency matching |
|
|
||||||
| `description` | string | yes | Human-readable description |
|
|
||||||
| `latest` | string | yes | Slot name -- directory name under `versions/` |
|
|
||||||
| `icon` | string | no | URL to icon image |
|
|
||||||
| `category` | string | no | Grouping category (e.g. `infrastructure`) |
|
|
||||||
| `upgrade` | object | no | Routing rules for version upgrades |
|
|
||||||
| `upgrade.from` | list | no | Ordered list of version constraint rules |
|
|
||||||
| `upgrade.from[].version` | string | yes | Version constraint: `>=`, `>`, `<=`, `<`, `=`, or `>0` |
|
|
||||||
| `upgrade.from[].via` | string | no | Slot name of waypoint to pass through |
|
|
||||||
| `upgrade.from[].blocked` | bool | no | If true, upgrade is blocked |
|
|
||||||
| `upgrade.from[].notes` | string | no | Human-readable message |
|
|
||||||
| `upgrade.preUpgrade` | object | no | Pre-upgrade requirements |
|
|
||||||
| `upgrade.preUpgrade.backup` | string | no | `"none"`, `"recommended"`, or `"required"` |
|
|
||||||
|
|
||||||
**`latest` is a slot name, not a version string.** It tells the API which
|
|
||||||
directory to look in. The actual version is read from the manifest inside that
|
|
||||||
directory.
|
|
||||||
|
|
||||||
**`via` is also a slot name.** It tells the upgrade planner which waypoint
|
|
||||||
directory to route through.
|
|
||||||
|
|
||||||
### Version `manifest.yaml` specification
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
# Required
|
|
||||||
version: 5.118.1-2
|
|
||||||
|
|
||||||
# Optional
|
|
||||||
requires:
|
|
||||||
- name: pg
|
|
||||||
alias: db
|
|
||||||
- name: redis
|
|
||||||
defaultConfig:
|
|
||||||
namespace: ghost
|
|
||||||
domain: ghost.{{ .cloud.domain }}
|
|
||||||
# ...
|
|
||||||
defaultSecrets:
|
|
||||||
- key: password
|
|
||||||
- key: dbUrl
|
|
||||||
default: "postgresql://..."
|
|
||||||
requiredSecrets:
|
|
||||||
- db.password
|
|
||||||
deploy:
|
|
||||||
# ...
|
|
||||||
scripts:
|
|
||||||
# ...
|
|
||||||
|
|
||||||
# Optional -- only when this version has step-specific upgrade behavior
|
|
||||||
upgrade:
|
|
||||||
migrations:
|
|
||||||
pre:
|
|
||||||
- migrations/pre-deploy.yaml
|
|
||||||
post:
|
|
||||||
- migrations/post-deploy.yaml
|
|
||||||
configMigrations:
|
|
||||||
oldKey: new.key
|
|
||||||
```
|
|
||||||
|
|
||||||
Fields:
|
|
||||||
|
|
||||||
| Field | Type | Required | Description |
|
|
||||||
|-------|------|----------|-------------|
|
|
||||||
| `version` | string | yes | Precise version string (e.g. `5.118.1-2`) |
|
|
||||||
| `requires` | list | no | Dependencies with optional aliases |
|
|
||||||
| `defaultConfig` | map | yes | Configuration schema merged into instance config |
|
|
||||||
| `defaultSecrets` | list | no | App's own secrets |
|
|
||||||
| `requiredSecrets` | list | no | Secrets needed from dependencies |
|
|
||||||
| `deploy` | object | no | Deployment behavior (CRDs, phases, etc.) |
|
|
||||||
| `scripts` | list | no | Operational scripts |
|
|
||||||
| `upgrade.migrations.pre` | list | no | K8s Job YAMLs to run before deploying this version |
|
|
||||||
| `upgrade.migrations.post` | list | no | K8s Job YAMLs to run after deploying this version |
|
|
||||||
| `upgrade.configMigrations` | map | no | Old config key -> new config key renames |
|
|
||||||
|
|
||||||
**Note the field split:** Identity and routing live in `app.yaml`. Installation
|
|
||||||
details live here. The version manifest never contains `name`, `is`,
|
|
||||||
`description`, `icon`, `category`, or `upgrade.from` routing rules.
|
|
||||||
|
|
||||||
### Versioning convention
|
|
||||||
|
|
||||||
Wild Cloud uses a two-part version scheme: `<upstream>-<revision>`.
|
|
||||||
|
|
||||||
- **Upstream** tracks the third-party software version: `5.118.1`, `v4.0.18`,
|
|
||||||
`1.135.3`
|
|
||||||
- **Revision** tracks Wild Cloud packaging changes: `-1`, `-2`, etc.
|
|
||||||
|
|
||||||
A version without a revision suffix (e.g., `5.118.1`) is the initial
|
|
||||||
packaging. Revision `-1` is the first packaging fix that doesn't change
|
|
||||||
upstream software.
|
|
||||||
|
|
||||||
This is the same convention Debian uses for its source packages.
|
|
||||||
|
|
||||||
## Upgrade system
|
|
||||||
|
|
||||||
### The 90% case: no routing rules needed
|
|
||||||
|
|
||||||
Most apps can upgrade from any version to any other directly. The app has no
|
|
||||||
`upgrade` block in `app.yaml`. When the API detects a version mismatch between
|
|
||||||
installed and latest, it produces a single-step plan: install the latest
|
|
||||||
version over the current one.
|
|
||||||
|
|
||||||
### The 10% case: routing rules in `app.yaml`
|
|
||||||
|
|
||||||
Apps with breaking changes between versions need routing rules. These live in
|
|
||||||
`app.yaml`, centralized for all versions. The rules are evaluated iteratively:
|
|
||||||
|
|
||||||
1. Compare installed version against `upgrade.from` rules (first match wins)
|
|
||||||
2. If the matching rule has `via`, step to that waypoint
|
|
||||||
3. Re-evaluate the same rules with the waypoint's version as the new
|
|
||||||
"installed" version
|
|
||||||
4. Repeat until reaching latest or hitting a block
|
|
||||||
|
|
||||||
This is iterative re-evaluation, not recursive descent into waypoint
|
|
||||||
manifests. The routing logic lives in one place (`app.yaml`), not scattered
|
|
||||||
across version manifests.
|
|
||||||
|
|
||||||
### Rule evaluation
|
|
||||||
|
|
||||||
Rules are evaluated in order. First match wins. This means more-specific
|
|
||||||
rules must come before less-specific ones:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
upgrade:
|
|
||||||
from:
|
|
||||||
- version: ">=3.0.0" # Already past the breaking change, direct
|
|
||||||
- version: ">=2.0.0"
|
|
||||||
via: "2" # Must step through 2.x waypoint first
|
|
||||||
- version: "<2.0.0"
|
|
||||||
blocked: true # Too old, no supported path
|
|
||||||
```
|
|
||||||
|
|
||||||
An installed version of `2.5.0` matches `>=2.0.0`, steps to waypoint `2/`
|
|
||||||
(which contains, say, version `2.8.0`). Re-evaluation with `2.8.0` matches
|
|
||||||
`>=2.0.0` again... but this time the waypoint IS the current version, so the
|
|
||||||
visited-set check catches the cycle.
|
|
||||||
|
|
||||||
To avoid this, ensure a rule exists that matches post-waypoint versions
|
|
||||||
and routes them directly:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
upgrade:
|
|
||||||
from:
|
|
||||||
- version: ">=2.5.0" # Post-waypoint versions go direct
|
|
||||||
- version: ">=2.0.0"
|
|
||||||
via: "2" # Pre-waypoint versions step through
|
|
||||||
- version: "<2.0.0"
|
|
||||||
blocked: true
|
|
||||||
```
|
|
||||||
|
|
||||||
After stepping to waypoint `2/` (version `2.8.0`), re-evaluation matches
|
|
||||||
`>=2.5.0` (no `via`), which means direct upgrade to latest.
|
|
||||||
|
|
||||||
### Version constraints
|
|
||||||
|
|
||||||
Supported operators: `>=`, `>`, `<=`, `<`, `=`, and the special `>0` (matches
|
|
||||||
any version). Constraints compare major.minor.patch numerically. The packaging
|
|
||||||
revision is ignored in constraint matching so `>=5.118.0` matches `5.118.1-2`.
|
|
||||||
|
|
||||||
### Waypoints
|
|
||||||
|
|
||||||
A waypoint is a version slot that exists solely as a stepping stone in an
|
|
||||||
upgrade path. It contains a complete app package (manifest, kustomize, k8s
|
|
||||||
resources) that can be installed and run.
|
|
||||||
|
|
||||||
Waypoints are created when an upstream app has breaking changes that require
|
|
||||||
sequential upgrades. For example, Discourse requires stepping through each
|
|
||||||
major version. A database schema migration might require running version N
|
|
||||||
before jumping to version N+2.
|
|
||||||
|
|
||||||
Waypoint directories are referenced by slot name in `via` fields:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
# app.yaml
|
|
||||||
latest: "3"
|
|
||||||
upgrade:
|
|
||||||
from:
|
|
||||||
- version: ">=2.5.0"
|
|
||||||
- version: ">=2.0.0"
|
|
||||||
via: "2"
|
|
||||||
```
|
|
||||||
|
|
||||||
```
|
|
||||||
myapp/
|
|
||||||
+-- app.yaml
|
|
||||||
+-- versions/
|
|
||||||
+-- 3/ # Latest (version: 3.0.0)
|
|
||||||
+-- 2/ # Waypoint (version: 2.8.0)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Per-version migrations
|
|
||||||
|
|
||||||
Migration jobs (database schema changes, data transformations) are version-
|
|
||||||
specific, not app-level. They live in the version's directory and are
|
|
||||||
referenced from that version's `manifest.yaml`:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
# versions/3/manifest.yaml
|
|
||||||
version: 3.0.0
|
|
||||||
upgrade:
|
|
||||||
migrations:
|
|
||||||
pre:
|
|
||||||
- migrations/pre-deploy.yaml
|
|
||||||
post:
|
|
||||||
- migrations/post-deploy.yaml
|
|
||||||
```
|
|
||||||
|
|
||||||
```
|
|
||||||
versions/3/
|
|
||||||
+-- manifest.yaml
|
|
||||||
+-- migrations/
|
|
||||||
| +-- pre-deploy.yaml # K8s Job: runs before deploying 3.0.0
|
|
||||||
| +-- post-deploy.yaml # K8s Job: runs after deploying 3.0.0
|
|
||||||
+-- kustomization.yaml
|
|
||||||
+-- deployment.yaml
|
|
||||||
+-- ...
|
|
||||||
```
|
|
||||||
|
|
||||||
Migration jobs must be idempotent (safe to re-run on retry). Use
|
|
||||||
`CREATE IF NOT EXISTS`, `ALTER TABLE IF NOT EXISTS`, etc.
|
|
||||||
|
|
||||||
**Pre-migrations** run before deploying the new version's manifests. Use for
|
|
||||||
schema changes the new code depends on.
|
|
||||||
|
|
||||||
**Post-migrations** run after deploying. Use for data backfills or cleanup
|
|
||||||
the old code didn't need.
|
|
||||||
|
|
||||||
### Config migrations
|
|
||||||
|
|
||||||
When a version renames config keys, `configMigrations` in the version manifest
|
|
||||||
tells the system to rename them automatically in the instance's `config.yaml`
|
|
||||||
before recompiling templates:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
# versions/2/manifest.yaml
|
|
||||||
version: 2.0.0
|
|
||||||
upgrade:
|
|
||||||
configMigrations:
|
|
||||||
dbHost: db.host
|
|
||||||
dbPort: db.port
|
|
||||||
```
|
|
||||||
|
|
||||||
## API path resolution
|
|
||||||
|
|
||||||
The API resolves app paths through `resolveAppDir()`:
|
|
||||||
|
|
||||||
1. Read `app.yaml` from the app root
|
|
||||||
2. Use `latest` (or a specific requested slot) to find `versions/{slot}/`
|
|
||||||
3. Verify `manifest.yaml` exists in that directory
|
|
||||||
4. Return the directory path and parsed `AppMeta`
|
|
||||||
|
|
||||||
For old-style apps (no `app.yaml`, `manifest.yaml` at root), the function
|
|
||||||
falls back to the legacy path. This provides backward compatibility during
|
|
||||||
migration.
|
|
||||||
|
|
||||||
When installing, `applyMeta()` merges identity fields from `app.yaml` onto
|
|
||||||
the version manifest:
|
|
||||||
|
|
||||||
```
|
|
||||||
app.yaml + versions/5/manifest.yaml = installed manifest.yaml
|
|
||||||
(name, is, desc, (version, requires, (complete picture)
|
|
||||||
icon, category) defaultConfig, ...)
|
|
||||||
```
|
|
||||||
|
|
||||||
The installed manifest in the instance data directory always contains all
|
|
||||||
fields because the instance needs the complete picture -- it doesn't have
|
|
||||||
access to the Wild Directory's `app.yaml` at runtime.
|
|
||||||
|
|
||||||
## Drift detection
|
|
||||||
|
|
||||||
The API detects when an installed app's version differs from the Wild
|
|
||||||
Directory's latest. For new-style apps:
|
|
||||||
|
|
||||||
1. Read `app.yaml` to get the `latest` slot name
|
|
||||||
2. Read `versions/{latest}/manifest.yaml` to get the actual version string
|
|
||||||
3. Compare against the installed manifest's version
|
|
||||||
|
|
||||||
If they differ, the app is marked as having source drift with the available
|
|
||||||
version noted. The upgrade planner then computes whether an upgrade path
|
|
||||||
exists and how many steps it requires.
|
|
||||||
|
|
||||||
## Maintainer workflows
|
|
||||||
|
|
||||||
### Simple version bump
|
|
||||||
|
|
||||||
The app upstream releases a new version with no breaking changes. Edit files
|
|
||||||
in-place:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 1. Update files inside the existing slot
|
|
||||||
vi wild-directory/ghost/versions/5/manifest.yaml # bump version: 5.119.0
|
|
||||||
vi wild-directory/ghost/versions/5/deployment.yaml # update image tag
|
|
||||||
|
|
||||||
# 2. app.yaml doesn't change -- latest still points to "5"
|
|
||||||
|
|
||||||
# 3. Test
|
|
||||||
wild app add ghost && wild app deploy ghost
|
|
||||||
```
|
|
||||||
|
|
||||||
No directory created, renamed, or deleted. No `app.yaml` change.
|
|
||||||
|
|
||||||
### Packaging fix
|
|
||||||
|
|
||||||
A Wild Cloud template fix, no upstream change:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 1. Bump packaging revision in manifest
|
|
||||||
vi wild-directory/ghost/versions/5/manifest.yaml # version: 5.118.1-3
|
|
||||||
|
|
||||||
# 2. Fix whatever needs fixing
|
|
||||||
vi wild-directory/ghost/versions/5/deployment.yaml
|
|
||||||
|
|
||||||
# 3. app.yaml doesn't change
|
|
||||||
```
|
|
||||||
|
|
||||||
### Breaking upstream version (new waypoint)
|
|
||||||
|
|
||||||
The app upstream releases a major version with schema changes:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 1. Current slot becomes a waypoint -- leave it in place
|
|
||||||
# versions/2/ stays, containing version 2.8.0
|
|
||||||
|
|
||||||
# 2. Create the new slot
|
|
||||||
mkdir -p wild-directory/myapp/versions/3
|
|
||||||
# ... add manifest.yaml, kustomization.yaml, *.yaml for 3.0.0 ...
|
|
||||||
|
|
||||||
# 3. Update app.yaml
|
|
||||||
```
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
# app.yaml
|
|
||||||
name: myapp
|
|
||||||
latest: "3"
|
|
||||||
upgrade:
|
|
||||||
from:
|
|
||||||
- version: ">=2.5.0" # Post-waypoint, direct
|
|
||||||
- version: ">=2.0.0"
|
|
||||||
via: "2" # Step through waypoint
|
|
||||||
- version: "<2.0.0"
|
|
||||||
blocked: true
|
|
||||||
```
|
|
||||||
|
|
||||||
### Adding a new app
|
|
||||||
|
|
||||||
```bash
|
|
||||||
mkdir -p wild-directory/newapp/versions/1
|
|
||||||
|
|
||||||
# Create app.yaml
|
|
||||||
cat > wild-directory/newapp/app.yaml <<EOF
|
|
||||||
name: newapp
|
|
||||||
is: newapp
|
|
||||||
description: My new application
|
|
||||||
latest: "1"
|
|
||||||
EOF
|
|
||||||
|
|
||||||
# Create version manifest + k8s resources in versions/1/
|
|
||||||
# ... manifest.yaml, kustomization.yaml, deployment.yaml, etc.
|
|
||||||
```
|
|
||||||
|
|
||||||
## Examples
|
|
||||||
|
|
||||||
### Ghost (simple app, one slot)
|
|
||||||
|
|
||||||
```
|
|
||||||
ghost/
|
|
||||||
+-- app.yaml
|
|
||||||
| name: ghost
|
|
||||||
| is: ghost
|
|
||||||
| description: Ghost is a powerful app for...
|
|
||||||
| icon: https://...
|
|
||||||
| latest: "5"
|
|
||||||
+-- versions/
|
|
||||||
+-- 5/
|
|
||||||
+-- manifest.yaml # version: 5.118.1-2
|
|
||||||
+-- kustomization.yaml
|
|
||||||
+-- deployment.yaml
|
|
||||||
+-- service.yaml
|
|
||||||
+-- ingress.yaml
|
|
||||||
+-- namespace.yaml
|
|
||||||
+-- pvc.yaml
|
|
||||||
+-- db-init-job.yaml
|
|
||||||
```
|
|
||||||
|
|
||||||
Upgrading from 5.100.0 to 5.118.1-2: single step, no routing rules needed.
|
|
||||||
|
|
||||||
### e2e-test-app (two slots, waypoint)
|
|
||||||
|
|
||||||
```
|
|
||||||
e2e-test-app/
|
|
||||||
+-- app.yaml
|
|
||||||
| name: e2e-test-app
|
|
||||||
| is: e2e-test-app
|
|
||||||
| description: End-to-end test application...
|
|
||||||
| latest: "2"
|
|
||||||
| upgrade:
|
|
||||||
| from:
|
|
||||||
| - version: ">=1.0.0"
|
|
||||||
| via: "1"
|
|
||||||
| - version: "<1.0.0"
|
|
||||||
| blocked: true
|
|
||||||
| notes: "Versions before 1.0.0 are not supported"
|
|
||||||
| preUpgrade:
|
|
||||||
| backup: recommended
|
|
||||||
+-- versions/
|
|
||||||
+-- 2/
|
|
||||||
| +-- manifest.yaml # version: 2.0.0
|
|
||||||
| +-- kustomization.yaml
|
|
||||||
| +-- ...
|
|
||||||
+-- 1/
|
|
||||||
+-- manifest.yaml # version: 1.0.0-1
|
|
||||||
+-- kustomization.yaml
|
|
||||||
+-- ...
|
|
||||||
```
|
|
||||||
|
|
||||||
Upgrading from 0.5.0: blocked ("Versions before 1.0.0 are not supported").
|
|
||||||
Upgrading from 1.2.0: two steps -- 1.2.0 -> 1.0.0-1 (waypoint) -> 2.0.0.
|
|
||||||
|
|
||||||
### SMTP (infrastructure service, one slot)
|
|
||||||
|
|
||||||
```
|
|
||||||
smtp/
|
|
||||||
+-- app.yaml
|
|
||||||
| name: smtp
|
|
||||||
| is: smtp
|
|
||||||
| description: SMTP relay service...
|
|
||||||
| category: infrastructure
|
|
||||||
| latest: "1"
|
|
||||||
+-- versions/
|
|
||||||
+-- 1/
|
|
||||||
+-- manifest.yaml # version: 1.0.0
|
|
||||||
```
|
|
||||||
|
|
||||||
### Complex app with migrations
|
|
||||||
|
|
||||||
```
|
|
||||||
discourse/
|
|
||||||
+-- app.yaml
|
|
||||||
| name: discourse
|
|
||||||
| is: discourse
|
|
||||||
| description: Discourse forum...
|
|
||||||
| latest: "3"
|
|
||||||
| upgrade:
|
|
||||||
| from:
|
|
||||||
| - version: ">=2.5.0"
|
|
||||||
| - version: ">=2.0.0"
|
|
||||||
| via: "2"
|
|
||||||
| - version: "<2.0.0"
|
|
||||||
| blocked: true
|
|
||||||
| notes: "See upstream migration guide"
|
|
||||||
| preUpgrade:
|
|
||||||
| backup: required
|
|
||||||
+-- versions/
|
|
||||||
+-- 3/
|
|
||||||
| +-- manifest.yaml # version: 3.6.0
|
|
||||||
| +-- migrations/
|
|
||||||
| | +-- pre-deploy.yaml
|
|
||||||
| +-- kustomization.yaml
|
|
||||||
| +-- ...
|
|
||||||
+-- 2/
|
|
||||||
+-- manifest.yaml # version: 2.8.0
|
|
||||||
+-- kustomization.yaml
|
|
||||||
+-- ...
|
|
||||||
```
|
|
||||||
|
|
||||||
## Implementation notes
|
|
||||||
|
|
||||||
### Go types
|
|
||||||
|
|
||||||
```go
|
|
||||||
// app.yaml
|
|
||||||
type AppMeta struct {
|
|
||||||
Name string `yaml:"name"`
|
|
||||||
Is string `yaml:"is,omitempty"`
|
|
||||||
Description string `yaml:"description"`
|
|
||||||
Icon string `yaml:"icon,omitempty"`
|
|
||||||
Category string `yaml:"category,omitempty"`
|
|
||||||
Latest string `yaml:"latest"` // slot name
|
|
||||||
Upgrade *UpgradeConfig `yaml:"upgrade,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// version manifest.yaml (version-specific fields only)
|
|
||||||
type AppManifest struct {
|
|
||||||
Version string `yaml:"version"` // precise version string
|
|
||||||
Requires []AppDependency `yaml:"requires,omitempty"`
|
|
||||||
DefaultConfig map[string]interface{} `yaml:"defaultConfig,omitempty"`
|
|
||||||
DefaultSecrets []SecretDefinition `yaml:"defaultSecrets,omitempty"`
|
|
||||||
RequiredSecrets []string `yaml:"requiredSecrets,omitempty"`
|
|
||||||
Deploy *DeployConfig `yaml:"deploy,omitempty"`
|
|
||||||
Scripts []Script `yaml:"scripts,omitempty"`
|
|
||||||
Upgrade *UpgradeConfig `yaml:"upgrade,omitempty"` // migrations only
|
|
||||||
// Identity fields (Name, Is, Description, etc.) populated by applyMeta()
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Backward compatibility
|
|
||||||
|
|
||||||
The API supports both new-style (`app.yaml` + `versions/`) and old-style
|
|
||||||
(`manifest.yaml` at root, `.versions/` for waypoints). `resolveAppDir()`
|
|
||||||
checks for `app.yaml` first and falls back to the old layout.
|
|
||||||
`ComputeUpgradePlan()` similarly checks for `app.yaml` before falling back
|
|
||||||
to recursive manifest-based routing.
|
|
||||||
|
|
||||||
Old-style support exists for migration purposes. New apps should always use
|
|
||||||
the new-style layout.
|
|
||||||
|
|
||||||
### Source URI in installed manifests
|
|
||||||
|
|
||||||
The `source` field in installed manifests points to the app root
|
|
||||||
(`file:///wild-directory/ghost`), not the version directory. `Fetch()` and
|
|
||||||
`Update()` resolve through `resolveAppDir()` from the root, which reads
|
|
||||||
`app.yaml` to find the current latest slot.
|
|
||||||
|
|
||||||
## Design rationale
|
|
||||||
|
|
||||||
### Why decouple directory name from version string?
|
|
||||||
|
|
||||||
Coupling them creates unnecessary churn. Every packaging fix requires creating
|
|
||||||
a new directory and deleting the old one (`5.118.1/` -> `5.118.1-1/`). Every
|
|
||||||
minor upstream release does the same. This churn has no benefit -- the files
|
|
||||||
inside are what matter, not the directory name.
|
|
||||||
|
|
||||||
Decoupling follows the source package pattern used by every major package
|
|
||||||
system. The directory is a stable container. The version is metadata inside it.
|
|
||||||
|
|
||||||
### Why centralize routing rules in `app.yaml`?
|
|
||||||
|
|
||||||
Scattering `upgrade.from` rules across version manifests means each waypoint
|
|
||||||
must know how to route TO itself. Adding a new waypoint requires editing
|
|
||||||
multiple manifests. Reading the upgrade path requires opening every version
|
|
||||||
manifest in the chain.
|
|
||||||
|
|
||||||
Centralizing in `app.yaml` means one file describes the complete routing
|
|
||||||
topology. The upgrade planner reads one file and iteratively applies rules.
|
|
||||||
Adding a new waypoint means editing one file.
|
|
||||||
|
|
||||||
### Why iterative re-evaluation instead of recursive descent?
|
|
||||||
|
|
||||||
Recursive descent reads the target manifest, finds a `via`, recurses into the
|
|
||||||
waypoint, which has its own `via`, and so on. This scatters routing logic
|
|
||||||
across files and makes the path hard to reason about.
|
|
||||||
|
|
||||||
Iterative re-evaluation reads `app.yaml` once, applies rules, steps to a
|
|
||||||
waypoint, then applies the SAME rules again with the new version. The routing
|
|
||||||
table is evaluated like firewall rules -- same table, different input. This
|
|
||||||
is simpler to implement, debug, and maintain.
|
|
||||||
|
|
||||||
### Why not use a flat file for all versions?
|
|
||||||
|
|
||||||
A single YAML file listing all versions and their configs would centralize
|
|
||||||
everything but would grow unwieldy for apps with many k8s resource files.
|
|
||||||
Each version slot needs its own `kustomization.yaml`, deployment specs,
|
|
||||||
service definitions, etc. Directories are the natural unit.
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
#!/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"
|
|
||||||
@@ -1,573 +0,0 @@
|
|||||||
#!/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())
|
|
||||||
@@ -1,284 +0,0 @@
|
|||||||
# Wild Cloud App Validation Rules
|
|
||||||
#
|
|
||||||
# Loaded by validate-apps.py at runtime.
|
|
||||||
# Run `validate-apps.py --list-rules` for a formatted summary.
|
|
||||||
#
|
|
||||||
# To suppress a rule for an app, add its ID to ignoreRules in app.yaml:
|
|
||||||
# ignoreRules:
|
|
||||||
# - WC-HELM # upstream Helm-rendered manifests
|
|
||||||
#
|
|
||||||
# Fields:
|
|
||||||
# severity: error (must fix before submitting) | warning (strong suggestion)
|
|
||||||
# description: What the rule checks for
|
|
||||||
# remediation: How to fix the issue
|
|
||||||
|
|
||||||
# ── app.yaml ──────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
WC-APP-MISS:
|
|
||||||
severity: error
|
|
||||||
description: app.yaml file is missing
|
|
||||||
remediation: Create app.yaml with name, description, category, latest, and icon fields
|
|
||||||
|
|
||||||
WC-IGN:
|
|
||||||
severity: error
|
|
||||||
description: app.yaml ignoreRules contains an unknown rule ID
|
|
||||||
remediation: Remove the stale entry from ignoreRules, or check for a typo in the rule ID (run --list-rules to see valid IDs)
|
|
||||||
|
|
||||||
WC-APP-PARSE:
|
|
||||||
severity: error
|
|
||||||
description: app.yaml could not be parsed as valid YAML
|
|
||||||
remediation: Fix YAML syntax errors in app.yaml
|
|
||||||
|
|
||||||
WC-APP-EMPTY:
|
|
||||||
severity: error
|
|
||||||
description: app.yaml is empty
|
|
||||||
remediation: Populate app.yaml — it is empty
|
|
||||||
|
|
||||||
WC-REQ:
|
|
||||||
severity: error
|
|
||||||
description: app.yaml missing one or more required fields
|
|
||||||
remediation: "Add the missing fields: name, is, description, category, and latest are all required"
|
|
||||||
|
|
||||||
WC-NAME:
|
|
||||||
severity: error
|
|
||||||
description: app.yaml name does not match the directory name
|
|
||||||
remediation: "Set 'name:' in app.yaml to exactly match the directory name"
|
|
||||||
|
|
||||||
WC-ICON:
|
|
||||||
severity: warning
|
|
||||||
description: app.yaml missing icon field
|
|
||||||
remediation: "Add 'icon: <url>' to app.yaml pointing to the app's logo"
|
|
||||||
|
|
||||||
# ── versions ──────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
WC-VER:
|
|
||||||
severity: error
|
|
||||||
description: versions/ directory is missing
|
|
||||||
remediation: Create a versions/ subdirectory with at least one version slot directory inside it
|
|
||||||
|
|
||||||
WC-LATEST:
|
|
||||||
severity: error
|
|
||||||
description: latest version directory does not exist
|
|
||||||
remediation: "Create the version slot directory named in 'latest:', or update 'latest:' to an existing slot"
|
|
||||||
|
|
||||||
WC-VIA:
|
|
||||||
severity: error
|
|
||||||
description: upgrade.from[].via references a slot directory that does not exist
|
|
||||||
remediation: "Create the waypoint slot directory, or fix the via: value to match an existing slot"
|
|
||||||
|
|
||||||
WC-SLOT:
|
|
||||||
severity: warning
|
|
||||||
description: version slot directory name looks like a full version string or packaging revision
|
|
||||||
remediation: |
|
|
||||||
Rename the slot to the major version only (e.g. '1', 'v1', 'v0').
|
|
||||||
Full versions ('1.2.3') and packaging revisions ('1-2') do not belong in directory names.
|
|
||||||
The actual version string lives in manifest.yaml.
|
|
||||||
|
|
||||||
# ── manifest.yaml ─────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
WC-NSFILE:
|
|
||||||
severity: warning
|
|
||||||
description: no Namespace resource defined — workloads will deploy into an unmanaged namespace
|
|
||||||
remediation: |
|
|
||||||
Add a namespace.yaml listing it in kustomization.yaml resources:
|
|
||||||
apiVersion: v1
|
|
||||||
kind: Namespace
|
|
||||||
metadata:
|
|
||||||
name: {{ .namespace }}
|
|
||||||
|
|
||||||
# ── manifest.yaml ─────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
WC-MAN-MISS:
|
|
||||||
severity: error
|
|
||||||
description: manifest.yaml is missing
|
|
||||||
remediation: "Create manifest.yaml with at minimum: version, defaultConfig.namespace"
|
|
||||||
|
|
||||||
WC-MAN-PARSE:
|
|
||||||
severity: error
|
|
||||||
description: manifest.yaml could not be parsed
|
|
||||||
remediation: Fix YAML syntax errors in manifest.yaml
|
|
||||||
|
|
||||||
WC-MAN-EMPTY:
|
|
||||||
severity: error
|
|
||||||
description: manifest.yaml is empty
|
|
||||||
remediation: Populate manifest.yaml — it is empty
|
|
||||||
|
|
||||||
WC-MAN-REQ:
|
|
||||||
severity: error
|
|
||||||
description: manifest.yaml missing a required field
|
|
||||||
remediation: "Add the missing field to manifest.yaml (at minimum: version)"
|
|
||||||
|
|
||||||
WC-TVAR:
|
|
||||||
severity: warning
|
|
||||||
description: template variable in resource file not found in defaultConfig
|
|
||||||
remediation: |
|
|
||||||
Add the missing variable to defaultConfig in manifest.yaml, or check for a typo in the template.
|
|
||||||
Only top-level variable names are checked — e.g. .db in {{ .db.host }} must appear as a key under defaultConfig.
|
|
||||||
Global namespaces (cloud, cluster, operator, apps, secrets, app) are always available and exempt.
|
|
||||||
|
|
||||||
WC-CFG:
|
|
||||||
severity: warning
|
|
||||||
description: manifest.yaml defaultConfig is empty or missing
|
|
||||||
remediation: "Add a 'defaultConfig:' block to manifest.yaml"
|
|
||||||
|
|
||||||
WC-CFG-NS:
|
|
||||||
severity: warning
|
|
||||||
description: manifest.yaml defaultConfig missing namespace field
|
|
||||||
remediation: "Add 'namespace: <app-name>' under defaultConfig in manifest.yaml"
|
|
||||||
|
|
||||||
WC-SSL:
|
|
||||||
severity: error
|
|
||||||
description: postgres URL missing ?sslmode=disable
|
|
||||||
remediation: |
|
|
||||||
Append ?sslmode=disable to the postgres connection URL:
|
|
||||||
postgresql://user:pass@host:5432/db?sslmode=disable
|
|
||||||
|
|
||||||
WC-DOT:
|
|
||||||
severity: error
|
|
||||||
description: requiredSecret does not use <app-ref>.<key> dot notation
|
|
||||||
remediation: "Use <app-ref>.<key> format in requiredSecrets (e.g. postgres.password, not just 'password')"
|
|
||||||
|
|
||||||
# ── kustomization.yaml ────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
WC-KUS-MISS:
|
|
||||||
severity: error
|
|
||||||
description: kustomization.yaml is missing
|
|
||||||
remediation: Create kustomization.yaml listing all resource files with Wild Cloud labels
|
|
||||||
|
|
||||||
WC-KUS-PARSE:
|
|
||||||
severity: error
|
|
||||||
description: kustomization.yaml could not be parsed
|
|
||||||
remediation: Fix YAML syntax errors in kustomization.yaml
|
|
||||||
|
|
||||||
WC-KUS-EMPTY:
|
|
||||||
severity: error
|
|
||||||
description: kustomization.yaml is empty
|
|
||||||
remediation: "Add a resources: list and labels: block to kustomization.yaml"
|
|
||||||
|
|
||||||
WC-RES:
|
|
||||||
severity: error
|
|
||||||
description: a resource listed in kustomization.yaml does not exist on disk
|
|
||||||
remediation: "Create the missing file, or remove it from resources: in kustomization.yaml"
|
|
||||||
|
|
||||||
WC-RES-PARSE:
|
|
||||||
severity: error
|
|
||||||
description: a resource file listed in kustomization.yaml could not be parsed as valid YAML
|
|
||||||
remediation: Fix YAML syntax errors in the resource file
|
|
||||||
|
|
||||||
WC-NS:
|
|
||||||
severity: warning
|
|
||||||
description: no namespace in kustomization.yaml (has Deployment/StatefulSet)
|
|
||||||
remediation: "Add 'namespace: {{ .namespace }}' to kustomization.yaml"
|
|
||||||
|
|
||||||
WC-SEL:
|
|
||||||
severity: error
|
|
||||||
description: labels.includeSelectors true missing (required for Deployment/StatefulSet)
|
|
||||||
remediation: |
|
|
||||||
Add to kustomization.yaml:
|
|
||||||
labels:
|
|
||||||
- includeSelectors: true
|
|
||||||
pairs:
|
|
||||||
app: <app-name>
|
|
||||||
managedBy: kustomize
|
|
||||||
partOf: wild-cloud
|
|
||||||
|
|
||||||
WC-PAIRS:
|
|
||||||
severity: warning
|
|
||||||
description: a standard label pair is missing or has the wrong value
|
|
||||||
remediation: "Ensure labels.pairs includes managedBy: kustomize and partOf: wild-cloud"
|
|
||||||
|
|
||||||
WC-LBL:
|
|
||||||
severity: warning
|
|
||||||
description: label pair 'app' does not match the app directory name
|
|
||||||
remediation: "Set 'app:' in label pairs to match the app directory name"
|
|
||||||
|
|
||||||
# ── workloads ─────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
WC-SC-POD:
|
|
||||||
severity: warning
|
|
||||||
description: no pod-level securityContext defined
|
|
||||||
remediation: |
|
|
||||||
Add to the pod spec:
|
|
||||||
securityContext:
|
|
||||||
runAsNonRoot: true
|
|
||||||
runAsUser: <uid>
|
|
||||||
seccompProfile:
|
|
||||||
type: RuntimeDefault
|
|
||||||
|
|
||||||
WC-SC-CTR:
|
|
||||||
severity: warning
|
|
||||||
description: no container-level securityContext defined
|
|
||||||
remediation: |
|
|
||||||
Add to each container spec:
|
|
||||||
securityContext:
|
|
||||||
allowPrivilegeEscalation: false
|
|
||||||
capabilities:
|
|
||||||
drop: [ALL]
|
|
||||||
|
|
||||||
WC-RWO:
|
|
||||||
severity: error
|
|
||||||
description: Deployment using a ReadWriteOnce PVC must use strategy Recreate
|
|
||||||
remediation: "Set strategy: type: Recreate on the Deployment — RWO volumes cannot attach to two pods at once"
|
|
||||||
|
|
||||||
WC-HELM:
|
|
||||||
severity: error
|
|
||||||
description: Helm-style app.kubernetes.io/* labels found in pod template
|
|
||||||
remediation: "Remove app.kubernetes.io/* labels from pod template; use simple labels (e.g. component: web) instead"
|
|
||||||
|
|
||||||
WC-IMG:
|
|
||||||
severity: error
|
|
||||||
description: container image uses a mutable or missing version tag
|
|
||||||
remediation: "Pin the image to a specific release tag (e.g. myimage:1.2.3), not latest/main/stable/etc."
|
|
||||||
|
|
||||||
WC-BITNAMI:
|
|
||||||
severity: warning
|
|
||||||
description: container image is from Bitnami — prefer an official image
|
|
||||||
remediation: |
|
|
||||||
Replace the Bitnami image with the official upstream image.
|
|
||||||
Bitnami images require Docker Hub authentication since 2023, use non-standard filesystem layouts,
|
|
||||||
and are often a sign of a Helm chart that wasn't fully adapted. Check Docker Hub or the project's
|
|
||||||
docs for the official image (e.g. postgres:15, redis:alpine, mariadb:11).
|
|
||||||
|
|
||||||
# ── ingress ───────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
WC-ING-ANN:
|
|
||||||
severity: error
|
|
||||||
description: ingress uses deprecated kubernetes.io/ingress.class annotation
|
|
||||||
remediation: "Replace the annotation with spec.ingressClassName: traefik in the Ingress spec"
|
|
||||||
|
|
||||||
WC-ING-CLS:
|
|
||||||
severity: warning
|
|
||||||
description: ingress spec.ingressClassName should be traefik
|
|
||||||
remediation: "Add 'ingressClassName: traefik' under spec: in the Ingress"
|
|
||||||
|
|
||||||
WC-CERT:
|
|
||||||
severity: error
|
|
||||||
description: ingress uses cert-manager annotation (wildcard cert is pre-distributed)
|
|
||||||
remediation: "Remove the cert-manager.io/cluster-issuer annotation — wildcard TLS is pre-distributed to namespaces"
|
|
||||||
|
|
||||||
WC-DNS:
|
|
||||||
severity: warning
|
|
||||||
description: ingress missing external-dns.alpha.kubernetes.io/target annotation
|
|
||||||
remediation: |
|
|
||||||
Add to Ingress metadata.annotations:
|
|
||||||
external-dns.alpha.kubernetes.io/target: '{{ .externalDnsDomain }}'
|
|
||||||
|
|
||||||
# ── db-init ───────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
WC-DKRDNS:
|
|
||||||
severity: error
|
|
||||||
description: Docker DNS resolver (127.0.0.11) used — not available in Kubernetes
|
|
||||||
remediation: |
|
|
||||||
Remove `resolver 127.0.0.11` and avoid nginx variables in proxy_pass.
|
|
||||||
Use `proxy_pass http://backend-name:port;` (no variable) so nginx resolves via CoreDNS at startup.
|
|
||||||
If a runtime-resolved variable is unavoidable, use the cluster DNS resolver instead:
|
|
||||||
resolver kube-dns.kube-system.svc.cluster.local valid=30s;
|
|
||||||
See docs/nginx.md for the full pattern.
|
|
||||||
|
|
||||||
WC-DBIN:
|
|
||||||
severity: error
|
|
||||||
description: MySQL db-init uses CREATE USER without ALTER USER (passwords won't update on redeploy)
|
|
||||||
remediation: |
|
|
||||||
Add ALTER USER after CREATE USER so passwords update on redeploy:
|
|
||||||
CREATE USER IF NOT EXISTS '${USER}'@'%' IDENTIFIED BY '${PASSWORD}';
|
|
||||||
ALTER USER '${USER}'@'%' IDENTIFIED BY '${PASSWORD}';
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
name: akaunting
|
|
||||||
is: akaunting
|
|
||||||
description: Akaunting is a free, open-source, and online accounting software for small businesses and freelancers.
|
|
||||||
category: business
|
|
||||||
icon: https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/akaunting.svg
|
|
||||||
latest: "3"
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
# Akaunting
|
|
||||||
|
|
||||||
Akaunting is an open-source accounting software for small businesses and freelancers.
|
|
||||||
|
|
||||||
## Dependencies
|
|
||||||
|
|
||||||
- **MySQL** - Database for storing accounting data
|
|
||||||
- **SMTP** - For sending invoices and notifications
|
|
||||||
|
|
||||||
## Configuration
|
|
||||||
|
|
||||||
Key settings in `config.yaml`:
|
|
||||||
|
|
||||||
- **domain** - Where Akaunting will be accessible
|
|
||||||
- **storage** - Persistent volume size (default: `2Gi`)
|
|
||||||
- **companyName** - Your company name (default: `My Company`)
|
|
||||||
- **companyEmail** - Company contact email (defaults to your operator email)
|
|
||||||
- **adminEmail** - Admin account email (defaults to your operator email)
|
|
||||||
- **locale** - Language/locale setting (default: `en-US`)
|
|
||||||
- **smtp** - Email settings inherited from your Wild Cloud SMTP service
|
|
||||||
|
|
||||||
## First-Time Setup
|
|
||||||
|
|
||||||
1. Add and deploy the app:
|
|
||||||
```bash
|
|
||||||
wild app add akaunting
|
|
||||||
wild app deploy akaunting
|
|
||||||
```
|
|
||||||
|
|
||||||
2. Log in with:
|
|
||||||
- **Email**: value of `adminEmail` in your config
|
|
||||||
- **Password**: value of `adminPassword` in your `secrets.yaml`
|
|
||||||
|
|
||||||
3. Set up your company details, chart of accounts, and start recording income and expenses.
|
|
||||||
|
|
||||||
## Notes
|
|
||||||
|
|
||||||
- Akaunting supports multiple companies in a single installation — additional companies can be added after login
|
|
||||||
- The `dbPrefix` config (`aka_`) prefixes all database table names to avoid conflicts
|
|
||||||
- Invoices, payments, and reports can be exported as PDF
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
apiVersion: batch/v1
|
|
||||||
kind: Job
|
|
||||||
metadata:
|
|
||||||
name: akaunting-db-init
|
|
||||||
labels:
|
|
||||||
component: db-init
|
|
||||||
spec:
|
|
||||||
template:
|
|
||||||
metadata:
|
|
||||||
labels:
|
|
||||||
component: db-init
|
|
||||||
spec:
|
|
||||||
restartPolicy: OnFailure
|
|
||||||
securityContext:
|
|
||||||
runAsNonRoot: true
|
|
||||||
runAsUser: 999
|
|
||||||
runAsGroup: 999
|
|
||||||
seccompProfile:
|
|
||||||
type: RuntimeDefault
|
|
||||||
containers:
|
|
||||||
- name: mysql-init
|
|
||||||
image: mysql:9.1.0
|
|
||||||
securityContext:
|
|
||||||
allowPrivilegeEscalation: false
|
|
||||||
capabilities:
|
|
||||||
drop:
|
|
||||||
- ALL
|
|
||||||
readOnlyRootFilesystem: false
|
|
||||||
env:
|
|
||||||
- name: MYSQL_ROOT_PASSWORD
|
|
||||||
valueFrom:
|
|
||||||
secretKeyRef:
|
|
||||||
name: akaunting-secrets
|
|
||||||
key: mysql.rootPassword
|
|
||||||
- name: DB_HOSTNAME
|
|
||||||
value: {{ .db.host }}
|
|
||||||
- name: DB_PORT
|
|
||||||
value: "{{ .db.port }}"
|
|
||||||
- name: DB_DATABASE_NAME
|
|
||||||
value: {{ .db.name }}
|
|
||||||
- name: DB_USERNAME
|
|
||||||
value: {{ .db.user }}
|
|
||||||
- name: DB_PASSWORD
|
|
||||||
valueFrom:
|
|
||||||
secretKeyRef:
|
|
||||||
name: akaunting-secrets
|
|
||||||
key: dbPassword
|
|
||||||
command:
|
|
||||||
- /bin/bash
|
|
||||||
- -c
|
|
||||||
- |
|
|
||||||
set -e
|
|
||||||
until mysql -h ${DB_HOSTNAME} -P ${DB_PORT} -u root -p${MYSQL_ROOT_PASSWORD} -e "SELECT 1" 2>/dev/null; do
|
|
||||||
echo "Waiting for MySQL..."
|
|
||||||
sleep 2
|
|
||||||
done
|
|
||||||
mysql -h ${DB_HOSTNAME} -P ${DB_PORT} -u root -p${MYSQL_ROOT_PASSWORD} <<EOF
|
|
||||||
CREATE DATABASE IF NOT EXISTS ${DB_DATABASE_NAME} CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
|
||||||
CREATE USER IF NOT EXISTS '${DB_USERNAME}'@'%' IDENTIFIED BY '${DB_PASSWORD}';
|
|
||||||
ALTER USER '${DB_USERNAME}'@'%' IDENTIFIED BY '${DB_PASSWORD}';
|
|
||||||
GRANT ALL PRIVILEGES ON ${DB_DATABASE_NAME}.* TO '${DB_USERNAME}'@'%';
|
|
||||||
FLUSH PRIVILEGES;
|
|
||||||
EOF
|
|
||||||
echo "Done"
|
|
||||||
@@ -1,114 +0,0 @@
|
|||||||
apiVersion: apps/v1
|
|
||||||
kind: Deployment
|
|
||||||
metadata:
|
|
||||||
name: akaunting
|
|
||||||
namespace: akaunting
|
|
||||||
spec:
|
|
||||||
replicas: 1
|
|
||||||
strategy:
|
|
||||||
type: Recreate
|
|
||||||
selector:
|
|
||||||
matchLabels:
|
|
||||||
component: web
|
|
||||||
template:
|
|
||||||
metadata:
|
|
||||||
labels:
|
|
||||||
component: web
|
|
||||||
spec:
|
|
||||||
securityContext:
|
|
||||||
runAsUser: 0
|
|
||||||
runAsNonRoot: false
|
|
||||||
seccompProfile:
|
|
||||||
type: RuntimeDefault
|
|
||||||
containers:
|
|
||||||
- name: akaunting
|
|
||||||
image: akaunting/akaunting:3.1.21
|
|
||||||
ports:
|
|
||||||
- name: http
|
|
||||||
containerPort: 80
|
|
||||||
protocol: TCP
|
|
||||||
env:
|
|
||||||
- name: AKAUNTING_SETUP
|
|
||||||
value: "true"
|
|
||||||
- name: APP_URL
|
|
||||||
value: https://{{ .domain }}
|
|
||||||
- name: LOCALE
|
|
||||||
value: {{ .locale }}
|
|
||||||
- name: DB_HOST
|
|
||||||
value: {{ .db.host }}
|
|
||||||
- name: DB_PORT
|
|
||||||
value: "{{ .db.port }}"
|
|
||||||
- name: DB_NAME
|
|
||||||
value: {{ .db.name }}
|
|
||||||
- name: DB_USERNAME
|
|
||||||
value: {{ .db.user }}
|
|
||||||
- name: DB_PASSWORD
|
|
||||||
valueFrom:
|
|
||||||
secretKeyRef:
|
|
||||||
name: akaunting-secrets
|
|
||||||
key: dbPassword
|
|
||||||
- name: DB_PREFIX
|
|
||||||
value: {{ .dbPrefix }}
|
|
||||||
- name: COMPANY_NAME
|
|
||||||
value: "{{ .companyName }}"
|
|
||||||
- name: COMPANY_EMAIL
|
|
||||||
value: {{ .companyEmail }}
|
|
||||||
- name: ADMIN_EMAIL
|
|
||||||
value: {{ .adminEmail }}
|
|
||||||
- name: ADMIN_PASSWORD
|
|
||||||
valueFrom:
|
|
||||||
secretKeyRef:
|
|
||||||
name: akaunting-secrets
|
|
||||||
key: adminPassword
|
|
||||||
- name: MAIL_MAILER
|
|
||||||
value: smtp
|
|
||||||
- name: MAIL_HOST
|
|
||||||
value: {{ .smtp.host }}
|
|
||||||
- name: MAIL_PORT
|
|
||||||
value: "{{ .smtp.port }}"
|
|
||||||
- name: MAIL_USERNAME
|
|
||||||
value: {{ .smtp.user }}
|
|
||||||
- name: MAIL_PASSWORD
|
|
||||||
valueFrom:
|
|
||||||
secretKeyRef:
|
|
||||||
name: akaunting-secrets
|
|
||||||
key: smtpPassword
|
|
||||||
- name: MAIL_FROM_ADDRESS
|
|
||||||
value: {{ .smtp.from }}
|
|
||||||
resources:
|
|
||||||
limits:
|
|
||||||
cpu: "1"
|
|
||||||
ephemeral-storage: 1Gi
|
|
||||||
memory: 512Mi
|
|
||||||
requests:
|
|
||||||
cpu: 50m
|
|
||||||
ephemeral-storage: 50Mi
|
|
||||||
memory: 256Mi
|
|
||||||
volumeMounts:
|
|
||||||
- name: akaunting-data
|
|
||||||
mountPath: /var/www/html/storage
|
|
||||||
subPath: storage
|
|
||||||
- name: akaunting-data
|
|
||||||
mountPath: /var/www/html/public/uploads
|
|
||||||
subPath: uploads
|
|
||||||
livenessProbe:
|
|
||||||
tcpSocket:
|
|
||||||
port: 80
|
|
||||||
initialDelaySeconds: 120
|
|
||||||
timeoutSeconds: 10
|
|
||||||
periodSeconds: 30
|
|
||||||
failureThreshold: 6
|
|
||||||
readinessProbe:
|
|
||||||
tcpSocket:
|
|
||||||
port: 80
|
|
||||||
initialDelaySeconds: 60
|
|
||||||
timeoutSeconds: 5
|
|
||||||
periodSeconds: 15
|
|
||||||
failureThreshold: 3
|
|
||||||
securityContext:
|
|
||||||
readOnlyRootFilesystem: false
|
|
||||||
volumes:
|
|
||||||
- name: akaunting-data
|
|
||||||
persistentVolumeClaim:
|
|
||||||
claimName: akaunting-data
|
|
||||||
restartPolicy: Always
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
apiVersion: networking.k8s.io/v1
|
|
||||||
kind: Ingress
|
|
||||||
metadata:
|
|
||||||
name: akaunting
|
|
||||||
namespace: akaunting
|
|
||||||
annotations:
|
|
||||||
external-dns.alpha.kubernetes.io/target: {{ .externalDnsDomain }}
|
|
||||||
external-dns.alpha.kubernetes.io/cloudflare-proxied: "false"
|
|
||||||
external-dns.alpha.kubernetes.io/ttl: "60"
|
|
||||||
spec:
|
|
||||||
ingressClassName: traefik
|
|
||||||
rules:
|
|
||||||
- host: {{ .domain }}
|
|
||||||
http:
|
|
||||||
paths:
|
|
||||||
- path: /
|
|
||||||
pathType: Prefix
|
|
||||||
backend:
|
|
||||||
service:
|
|
||||||
name: akaunting
|
|
||||||
port:
|
|
||||||
number: 80
|
|
||||||
tls:
|
|
||||||
- hosts:
|
|
||||||
- {{ .domain }}
|
|
||||||
secretName: {{ .tlsSecretName }}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
apiVersion: kustomize.config.k8s.io/v1beta1
|
|
||||||
kind: Kustomization
|
|
||||||
namespace: akaunting
|
|
||||||
labels:
|
|
||||||
- includeSelectors: true
|
|
||||||
pairs:
|
|
||||||
app: akaunting
|
|
||||||
managedBy: kustomize
|
|
||||||
partOf: wild-cloud
|
|
||||||
resources:
|
|
||||||
- namespace.yaml
|
|
||||||
- db-init-job.yaml
|
|
||||||
- deployment.yaml
|
|
||||||
- service.yaml
|
|
||||||
- ingress.yaml
|
|
||||||
- pvc.yaml
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
version: 3.1.21-1
|
|
||||||
requires:
|
|
||||||
- name: mysql
|
|
||||||
- name: smtp
|
|
||||||
defaultConfig:
|
|
||||||
namespace: akaunting
|
|
||||||
externalDnsDomain: '{{ .cloud.domain }}'
|
|
||||||
domain: akaunting.{{ .cloud.domain }}
|
|
||||||
tlsSecretName: wildcard-wild-cloud-tls
|
|
||||||
storage: 2Gi
|
|
||||||
locale: en-US
|
|
||||||
dbPrefix: aka_
|
|
||||||
companyName: My Company
|
|
||||||
companyEmail: '{{ .operator.email }}'
|
|
||||||
adminEmail: '{{ .operator.email }}'
|
|
||||||
db:
|
|
||||||
host: '{{ .apps.mysql.host }}'
|
|
||||||
port: "3306"
|
|
||||||
name: akaunting
|
|
||||||
user: akaunting
|
|
||||||
smtp:
|
|
||||||
host: '{{ .apps.smtp.host }}'
|
|
||||||
port: '{{ .apps.smtp.port }}'
|
|
||||||
from: '{{ .apps.smtp.from }}'
|
|
||||||
user: '{{ .apps.smtp.user }}'
|
|
||||||
defaultSecrets:
|
|
||||||
- key: dbPassword
|
|
||||||
- key: adminPassword
|
|
||||||
- key: smtpPassword
|
|
||||||
requiredSecrets:
|
|
||||||
- mysql.rootPassword
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
apiVersion: v1
|
|
||||||
kind: PersistentVolumeClaim
|
|
||||||
metadata:
|
|
||||||
name: akaunting-data
|
|
||||||
namespace: akaunting
|
|
||||||
spec:
|
|
||||||
accessModes:
|
|
||||||
- ReadWriteOnce
|
|
||||||
resources:
|
|
||||||
requests:
|
|
||||||
storage: {{ .storage }}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
apiVersion: v1
|
|
||||||
kind: Service
|
|
||||||
metadata:
|
|
||||||
name: akaunting
|
|
||||||
namespace: akaunting
|
|
||||||
spec:
|
|
||||||
selector:
|
|
||||||
component: web
|
|
||||||
ports:
|
|
||||||
- name: http
|
|
||||||
port: 80
|
|
||||||
targetPort: 80
|
|
||||||
protocol: TCP
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
name: aptly
|
|
||||||
is: aptly
|
|
||||||
description: Aptly is a Debian/Ubuntu APT repository management tool for mirroring, snapshotting, and publishing package repositories.
|
|
||||||
category: developer
|
|
||||||
icon: https://github.com/aptly-dev.png
|
|
||||||
latest: "1"
|
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
# Aptly
|
|
||||||
|
|
||||||
Aptly is a Debian/Ubuntu APT repository management tool. Use it to create and publish private APT repositories that your machines can install from.
|
|
||||||
|
|
||||||
## Configuration
|
|
||||||
|
|
||||||
Key settings in your instance's `config.yaml`:
|
|
||||||
|
|
||||||
- **domain** - Where aptly will be accessible (default: `aptly.{your-cloud-domain}`)
|
|
||||||
- **storage** - Persistent volume size for packages (default: `5Gi`)
|
|
||||||
|
|
||||||
## Access
|
|
||||||
|
|
||||||
After deployment:
|
|
||||||
- `https://aptly.{your-cloud-domain}/` — Published repository root (for APT clients, no auth required)
|
|
||||||
- `https://aptly.{your-cloud-domain}/api` — REST API for managing repos and snapshots (requires credentials)
|
|
||||||
- `https://aptly.{your-cloud-domain}/public.key` — GPG public key for APT clients to import
|
|
||||||
|
|
||||||
## Credentials
|
|
||||||
|
|
||||||
The API is protected by HTTP basic authentication. The username defaults to `aptly` (configurable via `apiUser` in `config.yaml`). The password is auto-generated and stored in your instance's `secrets.yaml` under `apps.aptly.apiPassword`.
|
|
||||||
|
|
||||||
To retrieve it:
|
|
||||||
```bash
|
|
||||||
wild secret get aptly apiPassword
|
|
||||||
```
|
|
||||||
|
|
||||||
To use the API with curl:
|
|
||||||
```bash
|
|
||||||
curl -u aptly:{your-api-password} https://aptly.{your-cloud-domain}/api/version
|
|
||||||
```
|
|
||||||
|
|
||||||
## GPG Signing
|
|
||||||
|
|
||||||
A GPG signing key is generated automatically on first deployment and stored on the persistent volume. The public key is exported to `/public.key` and is accessible without authentication so APT clients can import it:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -fsSL https://aptly.{your-cloud-domain}/public.key | \
|
|
||||||
sudo tee /usr/share/keyrings/my-repo.gpg > /dev/null
|
|
||||||
```
|
|
||||||
|
|
||||||
## Common Workflows
|
|
||||||
|
|
||||||
Aptly is managed through its REST API or the `aptly` CLI (run inside the pod). The REST API docs are at https://www.aptly.info/doc/api/.
|
|
||||||
|
|
||||||
### Running aptly commands inside the pod
|
|
||||||
|
|
||||||
```bash
|
|
||||||
kubectl -n aptly exec -it deploy/aptly -- bash
|
|
||||||
```
|
|
||||||
|
|
||||||
### Publish a local repository
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Create a local repo
|
|
||||||
aptly repo create my-packages
|
|
||||||
|
|
||||||
# Add packages
|
|
||||||
aptly repo add my-packages /path/to/package.deb
|
|
||||||
|
|
||||||
# Publish
|
|
||||||
aptly publish repo my-packages
|
|
||||||
```
|
|
||||||
|
|
||||||
The published repo will be available at `https://aptly.{your-cloud-domain}/`.
|
|
||||||
|
|
||||||
### Configure a machine to use the repository
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Import the signing key
|
|
||||||
curl -fsSL https://aptly.{your-cloud-domain}/public.key | \
|
|
||||||
sudo tee /usr/share/keyrings/my-repo.gpg > /dev/null
|
|
||||||
|
|
||||||
# Add the repo
|
|
||||||
echo "deb [signed-by=/usr/share/keyrings/my-repo.gpg] https://aptly.{your-cloud-domain}/ stable main" | \
|
|
||||||
sudo tee /etc/apt/sources.list.d/my-repo.list
|
|
||||||
|
|
||||||
sudo apt update
|
|
||||||
```
|
|
||||||
|
|
||||||
## Notes
|
|
||||||
|
|
||||||
- All aptly data (GPG keys, database, package pool, published repos) is stored on the persistent volume at `/opt/aptly`.
|
|
||||||
- The REST API (port 8080) and the nginx file server (port 80) both run inside the pod via supervisord. The ingress routes to nginx (port 80).
|
|
||||||
@@ -1,78 +0,0 @@
|
|||||||
apiVersion: apps/v1
|
|
||||||
kind: Deployment
|
|
||||||
metadata:
|
|
||||||
name: aptly
|
|
||||||
spec:
|
|
||||||
replicas: 1
|
|
||||||
strategy:
|
|
||||||
type: Recreate
|
|
||||||
selector:
|
|
||||||
matchLabels:
|
|
||||||
component: server
|
|
||||||
template:
|
|
||||||
metadata:
|
|
||||||
labels:
|
|
||||||
component: server
|
|
||||||
spec:
|
|
||||||
securityContext:
|
|
||||||
seccompProfile:
|
|
||||||
type: RuntimeDefault
|
|
||||||
initContainers:
|
|
||||||
- name: init-gpg
|
|
||||||
image: urpylka/aptly:1.6.2
|
|
||||||
command:
|
|
||||||
- sh
|
|
||||||
- -c
|
|
||||||
- |
|
|
||||||
export GNUPGHOME=/opt/aptly/gpg
|
|
||||||
mkdir -p "$GNUPGHOME" /opt/aptly/public
|
|
||||||
chmod 700 "$GNUPGHOME"
|
|
||||||
if gpg --list-secret-keys 2>/dev/null | grep -q '^sec'; then
|
|
||||||
echo "GPG key already exists, skipping"
|
|
||||||
else
|
|
||||||
echo "Generating GPG signing key..."
|
|
||||||
printf '%%no-protection\nKey-Type: RSA\nKey-Length: 4096\nName-Real: Wild Cloud APT Repository\nName-Email: {{ .operatorEmail }}\nExpire-Date: 0\n%%commit\n' | gpg --batch --gen-key
|
|
||||||
echo "GPG key generated"
|
|
||||||
fi
|
|
||||||
gpg --export --armor > /opt/aptly/public/public.key
|
|
||||||
securityContext:
|
|
||||||
allowPrivilegeEscalation: false
|
|
||||||
volumeMounts:
|
|
||||||
- name: data
|
|
||||||
mountPath: /opt/aptly
|
|
||||||
- name: init-htpasswd
|
|
||||||
image: httpd:alpine
|
|
||||||
command:
|
|
||||||
- sh
|
|
||||||
- -c
|
|
||||||
- htpasswd -bc /opt/aptly/api.htpasswd "$API_USER" "$API_PASSWORD"
|
|
||||||
env:
|
|
||||||
- name: API_USER
|
|
||||||
value: "{{ .apiUser }}"
|
|
||||||
- name: API_PASSWORD
|
|
||||||
valueFrom:
|
|
||||||
secretKeyRef:
|
|
||||||
name: aptly-secrets
|
|
||||||
key: apiPassword
|
|
||||||
securityContext:
|
|
||||||
allowPrivilegeEscalation: false
|
|
||||||
volumeMounts:
|
|
||||||
- name: data
|
|
||||||
mountPath: /opt/aptly
|
|
||||||
containers:
|
|
||||||
- name: aptly
|
|
||||||
image: urpylka/aptly:1.6.2
|
|
||||||
ports:
|
|
||||||
- containerPort: 80
|
|
||||||
name: http
|
|
||||||
- containerPort: 8080
|
|
||||||
name: api
|
|
||||||
securityContext:
|
|
||||||
allowPrivilegeEscalation: false
|
|
||||||
volumeMounts:
|
|
||||||
- name: data
|
|
||||||
mountPath: /opt/aptly
|
|
||||||
volumes:
|
|
||||||
- name: data
|
|
||||||
persistentVolumeClaim:
|
|
||||||
claimName: aptly-pvc
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
apiVersion: networking.k8s.io/v1
|
|
||||||
kind: Ingress
|
|
||||||
metadata:
|
|
||||||
name: aptly
|
|
||||||
annotations:
|
|
||||||
external-dns.alpha.kubernetes.io/target: {{ .externalDnsDomain }}
|
|
||||||
external-dns.alpha.kubernetes.io/cloudflare-proxied: "false"
|
|
||||||
external-dns.alpha.kubernetes.io/ttl: "60"
|
|
||||||
spec:
|
|
||||||
ingressClassName: traefik
|
|
||||||
rules:
|
|
||||||
- host: {{ .domain }}
|
|
||||||
http:
|
|
||||||
paths:
|
|
||||||
- path: /
|
|
||||||
pathType: Prefix
|
|
||||||
backend:
|
|
||||||
service:
|
|
||||||
name: aptly
|
|
||||||
port:
|
|
||||||
number: 80
|
|
||||||
tls:
|
|
||||||
- hosts:
|
|
||||||
- {{ .domain }}
|
|
||||||
secretName: {{ .tlsSecretName }}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
apiVersion: kustomize.config.k8s.io/v1beta1
|
|
||||||
kind: Kustomization
|
|
||||||
namespace: "{{ .namespace }}"
|
|
||||||
labels:
|
|
||||||
- includeSelectors: true
|
|
||||||
pairs:
|
|
||||||
app: aptly
|
|
||||||
managedBy: kustomize
|
|
||||||
partOf: wild-cloud
|
|
||||||
resources:
|
|
||||||
- namespace.yaml
|
|
||||||
- deployment.yaml
|
|
||||||
- service.yaml
|
|
||||||
- ingress.yaml
|
|
||||||
- pvc.yaml
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
version: 1.6.2-3
|
|
||||||
defaultConfig:
|
|
||||||
namespace: aptly
|
|
||||||
externalDnsDomain: "{{ .cloud.domain }}"
|
|
||||||
domain: "aptly.{{ .cloud.domain }}"
|
|
||||||
tlsSecretName: wildcard-wild-cloud-tls
|
|
||||||
storage: 5Gi
|
|
||||||
apiUser: aptly
|
|
||||||
operatorEmail: "{{ .operator.email }}"
|
|
||||||
defaultSecrets:
|
|
||||||
- key: apiPassword
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
apiVersion: v1
|
|
||||||
kind: PersistentVolumeClaim
|
|
||||||
metadata:
|
|
||||||
name: aptly-pvc
|
|
||||||
spec:
|
|
||||||
storageClassName: longhorn
|
|
||||||
accessModes:
|
|
||||||
- ReadWriteOnce
|
|
||||||
volumeMode: Filesystem
|
|
||||||
resources:
|
|
||||||
requests:
|
|
||||||
storage: {{ .storage }}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
apiVersion: v1
|
|
||||||
kind: Service
|
|
||||||
metadata:
|
|
||||||
name: aptly
|
|
||||||
spec:
|
|
||||||
ports:
|
|
||||||
- port: 80
|
|
||||||
targetPort: 80
|
|
||||||
name: http
|
|
||||||
- port: 8080
|
|
||||||
targetPort: 8080
|
|
||||||
name: api
|
|
||||||
selector:
|
|
||||||
component: server
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
name: baserow
|
|
||||||
is: baserow
|
|
||||||
description: Baserow is an open-source no-code database platform and Airtable alternative. Create and manage databases through a spreadsheet-like interface without writing code.
|
|
||||||
category: business
|
|
||||||
icon: https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/baserow.svg
|
|
||||||
latest: "2"
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
# Baserow
|
|
||||||
|
|
||||||
Baserow is an open-source no-code database platform — a self-hosted alternative to Airtable.
|
|
||||||
|
|
||||||
## Dependencies
|
|
||||||
|
|
||||||
- **PostgreSQL** - Database for storing tables and workspace data
|
|
||||||
- **Redis** - Used for caching and background job queuing
|
|
||||||
|
|
||||||
## Configuration
|
|
||||||
|
|
||||||
Key settings in `config.yaml`:
|
|
||||||
|
|
||||||
- **domain** - Where Baserow will be accessible
|
|
||||||
- **storage** - Persistent volume size (default: `2Gi`)
|
|
||||||
- **db.name** - Database name (default: `baserow`)
|
|
||||||
|
|
||||||
## First-Time Setup
|
|
||||||
|
|
||||||
1. Add and deploy the app:
|
|
||||||
```bash
|
|
||||||
wild app add baserow
|
|
||||||
wild app deploy baserow
|
|
||||||
```
|
|
||||||
|
|
||||||
2. Visit the app URL and sign up to create your account.
|
|
||||||
|
|
||||||
3. The first user to sign up can be promoted to staff/admin via the Django admin panel at `/api/admin/`.
|
|
||||||
|
|
||||||
## Notes
|
|
||||||
|
|
||||||
- To make a user a staff member (admin):
|
|
||||||
```bash
|
|
||||||
kubectl exec -n baserow deploy/baserow -- python /baserow/backend/manage.py shell \
|
|
||||||
-c "from django.contrib.auth import get_user_model; u = get_user_model().objects.get(email='your@email.com'); u.is_staff = True; u.save()"
|
|
||||||
```
|
|
||||||
- The `secretKey` is auto-generated in `secrets.yaml`
|
|
||||||
- Baserow supports importing data from CSV, JSON, and XML files
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
apiVersion: batch/v1
|
|
||||||
kind: Job
|
|
||||||
metadata:
|
|
||||||
name: baserow-db-init
|
|
||||||
namespace: {{ .namespace }}
|
|
||||||
labels:
|
|
||||||
component: db-init
|
|
||||||
spec:
|
|
||||||
template:
|
|
||||||
metadata:
|
|
||||||
labels:
|
|
||||||
component: db-init
|
|
||||||
spec:
|
|
||||||
securityContext:
|
|
||||||
runAsNonRoot: true
|
|
||||||
runAsUser: 999
|
|
||||||
runAsGroup: 999
|
|
||||||
seccompProfile:
|
|
||||||
type: RuntimeDefault
|
|
||||||
containers:
|
|
||||||
- name: db-init
|
|
||||||
image: postgres:17
|
|
||||||
command: ["/bin/bash", "-c"]
|
|
||||||
args:
|
|
||||||
- |
|
|
||||||
PGPASSWORD=${POSTGRES_ADMIN_PASSWORD} psql -h ${DB_HOSTNAME} -U postgres <<EOF
|
|
||||||
DO \$\$
|
|
||||||
BEGIN
|
|
||||||
IF NOT EXISTS (SELECT FROM pg_catalog.pg_roles WHERE rolname = '${DB_USERNAME}') THEN
|
|
||||||
CREATE USER ${DB_USERNAME} WITH ENCRYPTED PASSWORD '${DB_PASSWORD}';
|
|
||||||
ELSE
|
|
||||||
ALTER USER ${DB_USERNAME} WITH ENCRYPTED PASSWORD '${DB_PASSWORD}';
|
|
||||||
END IF;
|
|
||||||
END
|
|
||||||
\$\$;
|
|
||||||
|
|
||||||
SELECT 'CREATE DATABASE ${DB_DATABASE_NAME}' WHERE NOT EXISTS (SELECT FROM pg_database WHERE datname = '${DB_DATABASE_NAME}')\gexec
|
|
||||||
ALTER DATABASE ${DB_DATABASE_NAME} OWNER TO ${DB_USERNAME};
|
|
||||||
GRANT ALL PRIVILEGES ON DATABASE ${DB_DATABASE_NAME} TO ${DB_USERNAME};
|
|
||||||
EOF
|
|
||||||
env:
|
|
||||||
- name: POSTGRES_ADMIN_PASSWORD
|
|
||||||
valueFrom:
|
|
||||||
secretKeyRef:
|
|
||||||
name: postgres-secrets
|
|
||||||
key: password
|
|
||||||
- name: DB_HOSTNAME
|
|
||||||
value: "{{ .db.host }}"
|
|
||||||
- name: DB_DATABASE_NAME
|
|
||||||
value: "{{ .db.name }}"
|
|
||||||
- name: DB_USERNAME
|
|
||||||
value: "{{ .db.user }}"
|
|
||||||
- name: DB_PASSWORD
|
|
||||||
valueFrom:
|
|
||||||
secretKeyRef:
|
|
||||||
name: baserow-secrets
|
|
||||||
key: dbPassword
|
|
||||||
securityContext:
|
|
||||||
allowPrivilegeEscalation: false
|
|
||||||
capabilities:
|
|
||||||
drop:
|
|
||||||
- ALL
|
|
||||||
restartPolicy: OnFailure
|
|
||||||
@@ -1,107 +0,0 @@
|
|||||||
apiVersion: apps/v1
|
|
||||||
kind: Deployment
|
|
||||||
metadata:
|
|
||||||
name: baserow
|
|
||||||
namespace: {{ .namespace }}
|
|
||||||
spec:
|
|
||||||
replicas: 1
|
|
||||||
strategy:
|
|
||||||
type: Recreate
|
|
||||||
selector:
|
|
||||||
matchLabels:
|
|
||||||
component: web
|
|
||||||
template:
|
|
||||||
metadata:
|
|
||||||
labels:
|
|
||||||
component: web
|
|
||||||
spec:
|
|
||||||
securityContext:
|
|
||||||
runAsNonRoot: false
|
|
||||||
runAsUser: 0
|
|
||||||
seccompProfile:
|
|
||||||
type: RuntimeDefault
|
|
||||||
containers:
|
|
||||||
- name: baserow
|
|
||||||
image: baserow/baserow:2.2.2
|
|
||||||
ports:
|
|
||||||
- name: http
|
|
||||||
containerPort: 80
|
|
||||||
protocol: TCP
|
|
||||||
env:
|
|
||||||
- name: BASEROW_PUBLIC_URL
|
|
||||||
value: https://{{ .domain }}
|
|
||||||
- name: DATABASE_HOST
|
|
||||||
value: "{{ .db.host }}"
|
|
||||||
- name: DATABASE_PORT
|
|
||||||
value: "{{ .db.port }}"
|
|
||||||
- name: DATABASE_NAME
|
|
||||||
value: "{{ .db.name }}"
|
|
||||||
- name: DATABASE_USER
|
|
||||||
value: "{{ .db.user }}"
|
|
||||||
- name: DATABASE_PASSWORD
|
|
||||||
valueFrom:
|
|
||||||
secretKeyRef:
|
|
||||||
name: baserow-secrets
|
|
||||||
key: dbPassword
|
|
||||||
- name: REDIS_HOST
|
|
||||||
value: "{{ .redis.host }}"
|
|
||||||
- name: REDIS_PORT
|
|
||||||
value: "6379"
|
|
||||||
- name: REDIS_PASSWORD
|
|
||||||
valueFrom:
|
|
||||||
secretKeyRef:
|
|
||||||
name: baserow-secrets
|
|
||||||
key: redis.password
|
|
||||||
- name: SECRET_KEY
|
|
||||||
valueFrom:
|
|
||||||
secretKeyRef:
|
|
||||||
name: baserow-secrets
|
|
||||||
key: secretKey
|
|
||||||
resources:
|
|
||||||
limits:
|
|
||||||
cpu: "2"
|
|
||||||
ephemeral-storage: 1Gi
|
|
||||||
memory: 2Gi
|
|
||||||
requests:
|
|
||||||
cpu: 50m
|
|
||||||
ephemeral-storage: 50Mi
|
|
||||||
memory: 256Mi
|
|
||||||
volumeMounts:
|
|
||||||
- name: baserow-data
|
|
||||||
mountPath: /baserow/data
|
|
||||||
securityContext:
|
|
||||||
runAsNonRoot: false
|
|
||||||
runAsUser: 0
|
|
||||||
allowPrivilegeEscalation: false
|
|
||||||
capabilities:
|
|
||||||
drop: [ALL]
|
|
||||||
readOnlyRootFilesystem: false
|
|
||||||
seccompProfile:
|
|
||||||
type: RuntimeDefault
|
|
||||||
livenessProbe:
|
|
||||||
httpGet:
|
|
||||||
path: /api/_health/
|
|
||||||
port: 80
|
|
||||||
httpHeaders:
|
|
||||||
- name: Host
|
|
||||||
value: "{{ .domain }}"
|
|
||||||
initialDelaySeconds: 300
|
|
||||||
timeoutSeconds: 10
|
|
||||||
periodSeconds: 30
|
|
||||||
failureThreshold: 6
|
|
||||||
readinessProbe:
|
|
||||||
httpGet:
|
|
||||||
path: /api/_health/
|
|
||||||
port: 80
|
|
||||||
httpHeaders:
|
|
||||||
- name: Host
|
|
||||||
value: "{{ .domain }}"
|
|
||||||
initialDelaySeconds: 120
|
|
||||||
timeoutSeconds: 5
|
|
||||||
periodSeconds: 15
|
|
||||||
failureThreshold: 6
|
|
||||||
volumes:
|
|
||||||
- name: baserow-data
|
|
||||||
persistentVolumeClaim:
|
|
||||||
claimName: baserow-data
|
|
||||||
restartPolicy: Always
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
apiVersion: networking.k8s.io/v1
|
|
||||||
kind: Ingress
|
|
||||||
metadata:
|
|
||||||
name: baserow
|
|
||||||
namespace: {{ .namespace }}
|
|
||||||
annotations:
|
|
||||||
external-dns.alpha.kubernetes.io/target: {{ .externalDnsDomain }}
|
|
||||||
external-dns.alpha.kubernetes.io/cloudflare-proxied: "false"
|
|
||||||
external-dns.alpha.kubernetes.io/ttl: "60"
|
|
||||||
spec:
|
|
||||||
ingressClassName: traefik
|
|
||||||
rules:
|
|
||||||
- host: {{ .domain }}
|
|
||||||
http:
|
|
||||||
paths:
|
|
||||||
- path: /
|
|
||||||
pathType: Prefix
|
|
||||||
backend:
|
|
||||||
service:
|
|
||||||
name: baserow
|
|
||||||
port:
|
|
||||||
number: 80
|
|
||||||
tls:
|
|
||||||
- hosts:
|
|
||||||
- {{ .domain }}
|
|
||||||
secretName: {{ .tlsSecretName }}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
apiVersion: kustomize.config.k8s.io/v1beta1
|
|
||||||
kind: Kustomization
|
|
||||||
namespace: {{ .namespace }}
|
|
||||||
labels:
|
|
||||||
- includeSelectors: true
|
|
||||||
pairs:
|
|
||||||
app: baserow
|
|
||||||
managedBy: kustomize
|
|
||||||
partOf: wild-cloud
|
|
||||||
resources:
|
|
||||||
- namespace.yaml
|
|
||||||
- deployment.yaml
|
|
||||||
- service.yaml
|
|
||||||
- ingress.yaml
|
|
||||||
- pvc.yaml
|
|
||||||
- db-init-job.yaml
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
version: 2.2.2-2
|
|
||||||
requires:
|
|
||||||
- name: postgres
|
|
||||||
- name: redis
|
|
||||||
defaultConfig:
|
|
||||||
namespace: baserow
|
|
||||||
externalDnsDomain: "{{ .cloud.domain }}"
|
|
||||||
domain: baserow.{{ .cloud.domain }}
|
|
||||||
tlsSecretName: wildcard-wild-cloud-tls
|
|
||||||
storage: 2Gi
|
|
||||||
db:
|
|
||||||
host: "{{ .apps.postgres.host }}"
|
|
||||||
port: "5432"
|
|
||||||
name: baserow
|
|
||||||
user: baserow
|
|
||||||
redis:
|
|
||||||
host: "{{ .apps.redis.host }}"
|
|
||||||
defaultSecrets:
|
|
||||||
- key: dbPassword
|
|
||||||
- key: secretKey
|
|
||||||
requiredSecrets:
|
|
||||||
- postgres.password
|
|
||||||
- redis.password
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
apiVersion: v1
|
|
||||||
kind: PersistentVolumeClaim
|
|
||||||
metadata:
|
|
||||||
name: baserow-data
|
|
||||||
namespace: {{ .namespace }}
|
|
||||||
spec:
|
|
||||||
accessModes:
|
|
||||||
- ReadWriteOnce
|
|
||||||
resources:
|
|
||||||
requests:
|
|
||||||
storage: {{ .storage }}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
apiVersion: v1
|
|
||||||
kind: Service
|
|
||||||
metadata:
|
|
||||||
name: baserow
|
|
||||||
namespace: {{ .namespace }}
|
|
||||||
spec:
|
|
||||||
selector:
|
|
||||||
component: web
|
|
||||||
ports:
|
|
||||||
- name: http
|
|
||||||
port: 80
|
|
||||||
targetPort: 80
|
|
||||||
protocol: TCP
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
name: bookstack
|
|
||||||
is: bookstack
|
|
||||||
description: BookStack is a simple, self-hosted, easy-to-use platform for organising and storing information.
|
|
||||||
category: productivity
|
|
||||||
icon: https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/bookstack.svg
|
|
||||||
latest: "26"
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
# BookStack
|
|
||||||
|
|
||||||
BookStack is a simple, self-hosted platform for organising and storing information in a hierarchical structure of Books, Chapters, and Pages.
|
|
||||||
|
|
||||||
## Dependencies
|
|
||||||
|
|
||||||
- **MySQL** - Database for storing content and users
|
|
||||||
- **SMTP** - For email notifications and password resets
|
|
||||||
|
|
||||||
## Configuration
|
|
||||||
|
|
||||||
Key settings in `config.yaml`:
|
|
||||||
|
|
||||||
- **domain** - Where BookStack will be accessible
|
|
||||||
- **storage** - Persistent volume size (default: `2Gi`)
|
|
||||||
- **db.name** - Database name (default: `bookstack`)
|
|
||||||
- **smtp** - Email settings inherited from your Wild Cloud SMTP service
|
|
||||||
|
|
||||||
## First-Time Setup
|
|
||||||
|
|
||||||
1. Add and deploy the app:
|
|
||||||
```bash
|
|
||||||
wild app add bookstack
|
|
||||||
wild app deploy bookstack
|
|
||||||
```
|
|
||||||
|
|
||||||
2. Log in with the default admin credentials:
|
|
||||||
- **Email**: `admin@admin.com`
|
|
||||||
- **Password**: `password`
|
|
||||||
|
|
||||||
3. **Immediately change the admin email and password** via **Settings → Your Profile**.
|
|
||||||
|
|
||||||
4. Start creating Books, Chapters, and Pages from the dashboard.
|
|
||||||
|
|
||||||
## Notes
|
|
||||||
|
|
||||||
- BookStack uses the linuxserver.io image which runs as root internally — this is expected and required for the image to function
|
|
||||||
- SMTP settings are configured via the `smtp` section in your config
|
|
||||||
- The `appKey` secret is auto-generated and used for encrypting data — do not change it after content has been created
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
apiVersion: batch/v1
|
|
||||||
kind: Job
|
|
||||||
metadata:
|
|
||||||
name: bookstack-db-init
|
|
||||||
labels:
|
|
||||||
component: db-init
|
|
||||||
spec:
|
|
||||||
template:
|
|
||||||
metadata:
|
|
||||||
labels:
|
|
||||||
component: db-init
|
|
||||||
spec:
|
|
||||||
restartPolicy: OnFailure
|
|
||||||
securityContext:
|
|
||||||
runAsNonRoot: true
|
|
||||||
runAsUser: 999
|
|
||||||
runAsGroup: 999
|
|
||||||
seccompProfile:
|
|
||||||
type: RuntimeDefault
|
|
||||||
containers:
|
|
||||||
- name: mysql-init
|
|
||||||
image: mysql:9.1.0
|
|
||||||
securityContext:
|
|
||||||
allowPrivilegeEscalation: false
|
|
||||||
capabilities:
|
|
||||||
drop:
|
|
||||||
- ALL
|
|
||||||
readOnlyRootFilesystem: false
|
|
||||||
env:
|
|
||||||
- name: MYSQL_ROOT_PASSWORD
|
|
||||||
valueFrom:
|
|
||||||
secretKeyRef:
|
|
||||||
name: bookstack-secrets
|
|
||||||
key: mysql.rootPassword
|
|
||||||
- name: DB_HOSTNAME
|
|
||||||
value: {{ .db.host }}
|
|
||||||
- name: DB_PORT
|
|
||||||
value: "{{ .db.port }}"
|
|
||||||
- name: DB_DATABASE_NAME
|
|
||||||
value: {{ .db.name }}
|
|
||||||
- name: DB_USERNAME
|
|
||||||
value: {{ .db.user }}
|
|
||||||
- name: DB_PASSWORD
|
|
||||||
valueFrom:
|
|
||||||
secretKeyRef:
|
|
||||||
name: bookstack-secrets
|
|
||||||
key: dbPassword
|
|
||||||
command:
|
|
||||||
- /bin/bash
|
|
||||||
- -c
|
|
||||||
- |
|
|
||||||
set -e
|
|
||||||
until mysql -h ${DB_HOSTNAME} -P ${DB_PORT} -u root -p${MYSQL_ROOT_PASSWORD} -e "SELECT 1" 2>/dev/null; do
|
|
||||||
echo "Waiting for MySQL..."
|
|
||||||
sleep 2
|
|
||||||
done
|
|
||||||
mysql -h ${DB_HOSTNAME} -P ${DB_PORT} -u root -p${MYSQL_ROOT_PASSWORD} <<EOF
|
|
||||||
CREATE DATABASE IF NOT EXISTS ${DB_DATABASE_NAME} CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
|
||||||
CREATE USER IF NOT EXISTS '${DB_USERNAME}'@'%' IDENTIFIED BY '${DB_PASSWORD}';
|
|
||||||
ALTER USER '${DB_USERNAME}'@'%' IDENTIFIED BY '${DB_PASSWORD}';
|
|
||||||
GRANT ALL PRIVILEGES ON ${DB_DATABASE_NAME}.* TO '${DB_USERNAME}'@'%';
|
|
||||||
FLUSH PRIVILEGES;
|
|
||||||
EOF
|
|
||||||
echo "Done"
|
|
||||||
@@ -1,106 +0,0 @@
|
|||||||
apiVersion: apps/v1
|
|
||||||
kind: Deployment
|
|
||||||
metadata:
|
|
||||||
name: bookstack
|
|
||||||
namespace: {{ .namespace }}
|
|
||||||
spec:
|
|
||||||
replicas: 1
|
|
||||||
strategy:
|
|
||||||
type: Recreate
|
|
||||||
selector:
|
|
||||||
matchLabels:
|
|
||||||
component: web
|
|
||||||
template:
|
|
||||||
metadata:
|
|
||||||
labels:
|
|
||||||
component: web
|
|
||||||
spec:
|
|
||||||
securityContext:
|
|
||||||
runAsUser: 0
|
|
||||||
runAsNonRoot: false
|
|
||||||
seccompProfile:
|
|
||||||
type: RuntimeDefault
|
|
||||||
containers:
|
|
||||||
- name: bookstack
|
|
||||||
image: lscr.io/linuxserver/bookstack:26.05.1
|
|
||||||
ports:
|
|
||||||
- name: http
|
|
||||||
containerPort: 80
|
|
||||||
protocol: TCP
|
|
||||||
env:
|
|
||||||
- name: PUID
|
|
||||||
value: "1000"
|
|
||||||
- name: PGID
|
|
||||||
value: "1000"
|
|
||||||
- name: APP_URL
|
|
||||||
value: https://{{ .domain }}
|
|
||||||
- name: DB_HOST
|
|
||||||
value: {{ .db.host }}
|
|
||||||
- name: DB_PORT
|
|
||||||
value: "{{ .db.port }}"
|
|
||||||
- name: DB_DATABASE
|
|
||||||
value: {{ .db.name }}
|
|
||||||
- name: DB_USERNAME
|
|
||||||
value: {{ .db.user }}
|
|
||||||
- name: DB_PASSWORD
|
|
||||||
valueFrom:
|
|
||||||
secretKeyRef:
|
|
||||||
name: bookstack-secrets
|
|
||||||
key: dbPassword
|
|
||||||
- name: APP_KEY
|
|
||||||
valueFrom:
|
|
||||||
secretKeyRef:
|
|
||||||
name: bookstack-secrets
|
|
||||||
key: appKey
|
|
||||||
- name: MAIL_DRIVER
|
|
||||||
value: smtp
|
|
||||||
- name: MAIL_HOST
|
|
||||||
value: {{ .smtp.host }}
|
|
||||||
- name: MAIL_PORT
|
|
||||||
value: "{{ .smtp.port }}"
|
|
||||||
- name: MAIL_FROM
|
|
||||||
value: {{ .smtp.from }}
|
|
||||||
- name: MAIL_USERNAME
|
|
||||||
value: {{ .smtp.user }}
|
|
||||||
- name: MAIL_PASSWORD
|
|
||||||
valueFrom:
|
|
||||||
secretKeyRef:
|
|
||||||
name: bookstack-secrets
|
|
||||||
key: smtpPassword
|
|
||||||
- name: MAIL_ENCRYPTION
|
|
||||||
value: tls
|
|
||||||
resources:
|
|
||||||
limits:
|
|
||||||
cpu: "1"
|
|
||||||
ephemeral-storage: 1Gi
|
|
||||||
memory: 512Mi
|
|
||||||
requests:
|
|
||||||
cpu: 50m
|
|
||||||
ephemeral-storage: 50Mi
|
|
||||||
memory: 128Mi
|
|
||||||
volumeMounts:
|
|
||||||
- name: bookstack-data
|
|
||||||
mountPath: /config
|
|
||||||
livenessProbe:
|
|
||||||
httpGet:
|
|
||||||
path: /
|
|
||||||
port: 80
|
|
||||||
initialDelaySeconds: 90
|
|
||||||
timeoutSeconds: 5
|
|
||||||
periodSeconds: 15
|
|
||||||
failureThreshold: 6
|
|
||||||
readinessProbe:
|
|
||||||
httpGet:
|
|
||||||
path: /
|
|
||||||
port: 80
|
|
||||||
initialDelaySeconds: 60
|
|
||||||
timeoutSeconds: 3
|
|
||||||
periodSeconds: 10
|
|
||||||
failureThreshold: 3
|
|
||||||
securityContext:
|
|
||||||
readOnlyRootFilesystem: false
|
|
||||||
volumes:
|
|
||||||
- name: bookstack-data
|
|
||||||
persistentVolumeClaim:
|
|
||||||
claimName: bookstack-data
|
|
||||||
restartPolicy: Always
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
apiVersion: networking.k8s.io/v1
|
|
||||||
kind: Ingress
|
|
||||||
metadata:
|
|
||||||
name: bookstack
|
|
||||||
namespace: {{ .namespace }}
|
|
||||||
annotations:
|
|
||||||
external-dns.alpha.kubernetes.io/target: {{ .externalDnsDomain }}
|
|
||||||
external-dns.alpha.kubernetes.io/cloudflare-proxied: "false"
|
|
||||||
external-dns.alpha.kubernetes.io/ttl: "60"
|
|
||||||
spec:
|
|
||||||
ingressClassName: traefik
|
|
||||||
rules:
|
|
||||||
- host: {{ .domain }}
|
|
||||||
http:
|
|
||||||
paths:
|
|
||||||
- path: /
|
|
||||||
pathType: Prefix
|
|
||||||
backend:
|
|
||||||
service:
|
|
||||||
name: bookstack
|
|
||||||
port:
|
|
||||||
number: 80
|
|
||||||
tls:
|
|
||||||
- hosts:
|
|
||||||
- {{ .domain }}
|
|
||||||
secretName: {{ .tlsSecretName }}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
apiVersion: kustomize.config.k8s.io/v1beta1
|
|
||||||
kind: Kustomization
|
|
||||||
namespace: {{ .namespace }}
|
|
||||||
labels:
|
|
||||||
- includeSelectors: true
|
|
||||||
pairs:
|
|
||||||
app: bookstack
|
|
||||||
managedBy: kustomize
|
|
||||||
partOf: wild-cloud
|
|
||||||
resources:
|
|
||||||
- namespace.yaml
|
|
||||||
- db-init-job.yaml
|
|
||||||
- deployment.yaml
|
|
||||||
- service.yaml
|
|
||||||
- ingress.yaml
|
|
||||||
- pvc.yaml
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
version: 26.05.1-2
|
|
||||||
requires:
|
|
||||||
- name: mysql
|
|
||||||
- name: smtp
|
|
||||||
defaultConfig:
|
|
||||||
namespace: bookstack
|
|
||||||
externalDnsDomain: '{{ .cloud.domain }}'
|
|
||||||
domain: bookstack.{{ .cloud.domain }}
|
|
||||||
tlsSecretName: wildcard-wild-cloud-tls
|
|
||||||
storage: 2Gi
|
|
||||||
db:
|
|
||||||
host: '{{ .apps.mysql.host }}'
|
|
||||||
port: '3306'
|
|
||||||
name: bookstack
|
|
||||||
user: bookstack
|
|
||||||
smtp:
|
|
||||||
host: '{{ .apps.smtp.host }}'
|
|
||||||
port: '{{ .apps.smtp.port }}'
|
|
||||||
from: '{{ .apps.smtp.from }}'
|
|
||||||
user: '{{ .apps.smtp.user }}'
|
|
||||||
defaultSecrets:
|
|
||||||
- key: appKey
|
|
||||||
- key: dbPassword
|
|
||||||
- key: smtpPassword
|
|
||||||
requiredSecrets:
|
|
||||||
- mysql.rootPassword
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
apiVersion: v1
|
|
||||||
kind: PersistentVolumeClaim
|
|
||||||
metadata:
|
|
||||||
name: bookstack-data
|
|
||||||
namespace: {{ .namespace }}
|
|
||||||
spec:
|
|
||||||
accessModes:
|
|
||||||
- ReadWriteOnce
|
|
||||||
resources:
|
|
||||||
requests:
|
|
||||||
storage: {{ .storage }}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
apiVersion: v1
|
|
||||||
kind: Service
|
|
||||||
metadata:
|
|
||||||
name: bookstack
|
|
||||||
namespace: {{ .namespace }}
|
|
||||||
spec:
|
|
||||||
selector:
|
|
||||||
component: web
|
|
||||||
ports:
|
|
||||||
- name: http
|
|
||||||
port: 80
|
|
||||||
targetPort: 80
|
|
||||||
protocol: TCP
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
name: bookwyrm
|
|
||||||
is: bookwyrm
|
|
||||||
description: BookWyrm is a federated social reading platform using ActivityPub. Track your reading, write reviews, and connect with readers across the fediverse.
|
|
||||||
category: social
|
|
||||||
icon: https://raw.githubusercontent.com/bookwyrm-social/bookwyrm/main/bookwyrm/static/images/logo.png
|
|
||||||
latest: "0"
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
# 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).
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
# BookWyrm
|
|
||||||
|
|
||||||
BookWyrm is a federated social reading platform — a self-hosted alternative to Goodreads, connected to the ActivityPub network.
|
|
||||||
|
|
||||||
## Dependencies
|
|
||||||
|
|
||||||
- **PostgreSQL** - Database for storing books, reviews, and user data
|
|
||||||
- **Redis** - Used for caching and background job queuing
|
|
||||||
- **SMTP** - For account verification and notifications
|
|
||||||
|
|
||||||
## Configuration
|
|
||||||
|
|
||||||
Key settings in `config.yaml`:
|
|
||||||
|
|
||||||
- **domain** - Where BookWyrm will be accessible
|
|
||||||
- **storage** - Persistent volume size (default: `2Gi`)
|
|
||||||
- **smtp** - Email settings inherited from your Wild Cloud SMTP service
|
|
||||||
|
|
||||||
## First-Time Setup
|
|
||||||
|
|
||||||
1. Add and deploy the app:
|
|
||||||
```bash
|
|
||||||
wild app add bookwyrm
|
|
||||||
wild app deploy bookwyrm
|
|
||||||
```
|
|
||||||
|
|
||||||
2. Visit the app URL — on first load, a setup wizard will guide you through creating the admin account and configuring the instance.
|
|
||||||
|
|
||||||
3. After setup, log in and configure the instance settings (name, description, registration policy) from the admin panel at `/settings/`.
|
|
||||||
|
|
||||||
## Notes
|
|
||||||
|
|
||||||
- BookWyrm federates with other BookWyrm instances and Mastodon via ActivityPub — users on other instances can follow and interact with your users
|
|
||||||
- Book metadata is imported from OpenLibrary and Inventaire — internet access is required for book search to work
|
|
||||||
- The `secretKey` is auto-generated in `secrets.yaml`
|
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
apiVersion: batch/v1
|
|
||||||
kind: Job
|
|
||||||
metadata:
|
|
||||||
name: bookwyrm-db-init
|
|
||||||
namespace: bookwyrm
|
|
||||||
spec:
|
|
||||||
template:
|
|
||||||
metadata:
|
|
||||||
labels:
|
|
||||||
component: db-init
|
|
||||||
spec:
|
|
||||||
securityContext:
|
|
||||||
runAsNonRoot: true
|
|
||||||
runAsUser: 999
|
|
||||||
runAsGroup: 999
|
|
||||||
seccompProfile:
|
|
||||||
type: RuntimeDefault
|
|
||||||
restartPolicy: OnFailure
|
|
||||||
containers:
|
|
||||||
- name: db-init
|
|
||||||
image: postgres:16-alpine
|
|
||||||
securityContext:
|
|
||||||
allowPrivilegeEscalation: false
|
|
||||||
capabilities:
|
|
||||||
drop:
|
|
||||||
- ALL
|
|
||||||
readOnlyRootFilesystem: false
|
|
||||||
env:
|
|
||||||
- name: PGHOST
|
|
||||||
value: "{{ .db.host }}"
|
|
||||||
- name: PGPORT
|
|
||||||
value: "{{ .db.port }}"
|
|
||||||
- name: PGUSER
|
|
||||||
value: postgres
|
|
||||||
- name: PGPASSWORD
|
|
||||||
valueFrom:
|
|
||||||
secretKeyRef:
|
|
||||||
name: bookwyrm-secrets
|
|
||||||
key: postgres.password
|
|
||||||
- name: BOOKWYRM_DB_USER
|
|
||||||
value: "{{ .db.user }}"
|
|
||||||
- name: BOOKWYRM_DB_NAME
|
|
||||||
value: "{{ .db.name }}"
|
|
||||||
- name: BOOKWYRM_DB_PASSWORD
|
|
||||||
valueFrom:
|
|
||||||
secretKeyRef:
|
|
||||||
name: bookwyrm-secrets
|
|
||||||
key: dbPassword
|
|
||||||
command:
|
|
||||||
- /bin/sh
|
|
||||||
- -c
|
|
||||||
- |
|
|
||||||
echo "Initializing BookWyrm database..."
|
|
||||||
|
|
||||||
# Create user if it doesn't exist
|
|
||||||
psql -c "
|
|
||||||
DO \$\$
|
|
||||||
BEGIN
|
|
||||||
IF NOT EXISTS (SELECT FROM pg_catalog.pg_roles WHERE rolname = '$BOOKWYRM_DB_USER') THEN
|
|
||||||
CREATE USER \"$BOOKWYRM_DB_USER\" WITH PASSWORD '$BOOKWYRM_DB_PASSWORD';
|
|
||||||
ELSE
|
|
||||||
ALTER USER \"$BOOKWYRM_DB_USER\" WITH PASSWORD '$BOOKWYRM_DB_PASSWORD';
|
|
||||||
END IF;
|
|
||||||
END
|
|
||||||
\$\$;
|
|
||||||
"
|
|
||||||
|
|
||||||
# Create database if it doesn't exist
|
|
||||||
if ! psql -lqt | cut -d \| -f 1 | grep -qw "$BOOKWYRM_DB_NAME"; then
|
|
||||||
echo "Creating database $BOOKWYRM_DB_NAME..."
|
|
||||||
createdb -O "$BOOKWYRM_DB_USER" "$BOOKWYRM_DB_NAME"
|
|
||||||
else
|
|
||||||
echo "Database $BOOKWYRM_DB_NAME already exists."
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Grant privileges
|
|
||||||
psql -d "$BOOKWYRM_DB_NAME" -c "
|
|
||||||
GRANT ALL PRIVILEGES ON DATABASE \"$BOOKWYRM_DB_NAME\" TO \"$BOOKWYRM_DB_USER\";
|
|
||||||
GRANT ALL ON SCHEMA public TO \"$BOOKWYRM_DB_USER\";
|
|
||||||
GRANT USAGE ON SCHEMA public TO \"$BOOKWYRM_DB_USER\";
|
|
||||||
"
|
|
||||||
|
|
||||||
# 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."
|
|
||||||
@@ -1,120 +0,0 @@
|
|||||||
apiVersion: apps/v1
|
|
||||||
kind: Deployment
|
|
||||||
metadata:
|
|
||||||
name: bookwyrm-worker
|
|
||||||
namespace: bookwyrm
|
|
||||||
spec:
|
|
||||||
replicas: 1
|
|
||||||
strategy:
|
|
||||||
type: RollingUpdate
|
|
||||||
selector:
|
|
||||||
matchLabels:
|
|
||||||
component: worker
|
|
||||||
template:
|
|
||||||
metadata:
|
|
||||||
labels:
|
|
||||||
component: worker
|
|
||||||
spec:
|
|
||||||
securityContext:
|
|
||||||
runAsNonRoot: false
|
|
||||||
runAsUser: 0
|
|
||||||
seccompProfile:
|
|
||||||
type: RuntimeDefault
|
|
||||||
containers:
|
|
||||||
- name: celery-worker
|
|
||||||
image: ghcr.io/bookwyrm-social/bookwyrm:v0.8.6
|
|
||||||
command:
|
|
||||||
- celery
|
|
||||||
- -A
|
|
||||||
- celerywyrm
|
|
||||||
- worker
|
|
||||||
- --pool=threads
|
|
||||||
- --concurrency=10
|
|
||||||
- -l
|
|
||||||
- info
|
|
||||||
- -Q
|
|
||||||
- high_priority,medium_priority,low_priority,streams,images,suggested_users,email,connectors,lists,inbox,imports,import_triggered,broadcast,misc
|
|
||||||
env:
|
|
||||||
- name: SECRET_KEY
|
|
||||||
valueFrom:
|
|
||||||
secretKeyRef:
|
|
||||||
name: bookwyrm-secrets
|
|
||||||
key: secretKey
|
|
||||||
- name: DEBUG
|
|
||||||
value: "false"
|
|
||||||
- name: DOMAIN
|
|
||||||
value: {{ .domain }}
|
|
||||||
- name: ALLOWED_HOSTS
|
|
||||||
value: {{ .domain }}
|
|
||||||
- name: EMAIL
|
|
||||||
value: {{ .smtp.from }}
|
|
||||||
- name: POSTGRES_HOST
|
|
||||||
value: {{ .db.host }}
|
|
||||||
- name: PGPORT
|
|
||||||
value: "{{ .db.port }}"
|
|
||||||
- name: POSTGRES_DB
|
|
||||||
value: {{ .db.name }}
|
|
||||||
- name: POSTGRES_USER
|
|
||||||
value: {{ .db.user }}
|
|
||||||
- name: POSTGRES_PASSWORD
|
|
||||||
valueFrom:
|
|
||||||
secretKeyRef:
|
|
||||||
name: bookwyrm-secrets
|
|
||||||
key: dbPassword
|
|
||||||
- name: REDIS_ACTIVITY_HOST
|
|
||||||
value: {{ .redis.host }}
|
|
||||||
- name: REDIS_ACTIVITY_PORT
|
|
||||||
value: "6379"
|
|
||||||
- name: REDIS_ACTIVITY_PASSWORD
|
|
||||||
valueFrom:
|
|
||||||
secretKeyRef:
|
|
||||||
name: bookwyrm-secrets
|
|
||||||
key: redis.password
|
|
||||||
- name: REDIS_ACTIVITY_URL
|
|
||||||
value: redis://:$(REDIS_ACTIVITY_PASSWORD)@{{ .redis.host }}:6379/0
|
|
||||||
- name: REDIS_BROKER_HOST
|
|
||||||
value: {{ .redis.host }}
|
|
||||||
- name: REDIS_BROKER_PORT
|
|
||||||
value: "6379"
|
|
||||||
- name: REDIS_BROKER_PASSWORD
|
|
||||||
valueFrom:
|
|
||||||
secretKeyRef:
|
|
||||||
name: bookwyrm-secrets
|
|
||||||
key: redis.password
|
|
||||||
- name: REDIS_BROKER_URL
|
|
||||||
value: redis://:$(REDIS_BROKER_PASSWORD)@{{ .redis.host }}:6379/1
|
|
||||||
- name: EMAIL_HOST
|
|
||||||
value: {{ .smtp.host }}
|
|
||||||
- name: EMAIL_PORT
|
|
||||||
value: "{{ .smtp.port }}"
|
|
||||||
- name: EMAIL_HOST_USER
|
|
||||||
value: {{ .smtp.user }}
|
|
||||||
- name: EMAIL_HOST_PASSWORD
|
|
||||||
valueFrom:
|
|
||||||
secretKeyRef:
|
|
||||||
name: bookwyrm-secrets
|
|
||||||
key: smtpPassword
|
|
||||||
- name: EMAIL_USE_TLS
|
|
||||||
value: "true"
|
|
||||||
- name: EMAIL_USE_SSL
|
|
||||||
value: "false"
|
|
||||||
- name: MEDIA_ROOT
|
|
||||||
value: /app/images
|
|
||||||
- name: STATIC_ROOT
|
|
||||||
value: /app/static
|
|
||||||
resources:
|
|
||||||
limits:
|
|
||||||
cpu: 1000m
|
|
||||||
ephemeral-storage: 1Gi
|
|
||||||
memory: 1Gi
|
|
||||||
requests:
|
|
||||||
cpu: 50m
|
|
||||||
ephemeral-storage: 50Mi
|
|
||||||
memory: 256Mi
|
|
||||||
securityContext:
|
|
||||||
allowPrivilegeEscalation: false
|
|
||||||
capabilities:
|
|
||||||
drop:
|
|
||||||
- ALL
|
|
||||||
readOnlyRootFilesystem: false
|
|
||||||
restartPolicy: Always
|
|
||||||
@@ -1,149 +0,0 @@
|
|||||||
apiVersion: apps/v1
|
|
||||||
kind: Deployment
|
|
||||||
metadata:
|
|
||||||
name: bookwyrm
|
|
||||||
namespace: bookwyrm
|
|
||||||
spec:
|
|
||||||
replicas: 1
|
|
||||||
strategy:
|
|
||||||
type: Recreate
|
|
||||||
selector:
|
|
||||||
matchLabels:
|
|
||||||
component: web
|
|
||||||
template:
|
|
||||||
metadata:
|
|
||||||
labels:
|
|
||||||
component: web
|
|
||||||
spec:
|
|
||||||
securityContext:
|
|
||||||
runAsNonRoot: false
|
|
||||||
runAsUser: 0
|
|
||||||
seccompProfile:
|
|
||||||
type: RuntimeDefault
|
|
||||||
containers:
|
|
||||||
- name: bookwyrm
|
|
||||||
image: ghcr.io/bookwyrm-social/bookwyrm:v0.8.6
|
|
||||||
command: ["/bin/sh", "-c", "python manage.py compile_themes && python manage.py collectstatic --noinput && python manage.py migrate && exec gunicorn bookwyrm.wsgi:application"]
|
|
||||||
ports:
|
|
||||||
- name: http
|
|
||||||
containerPort: 8000
|
|
||||||
protocol: TCP
|
|
||||||
env:
|
|
||||||
- name: SECRET_KEY
|
|
||||||
valueFrom:
|
|
||||||
secretKeyRef:
|
|
||||||
name: bookwyrm-secrets
|
|
||||||
key: secretKey
|
|
||||||
- name: DEBUG
|
|
||||||
value: "false"
|
|
||||||
- name: DOMAIN
|
|
||||||
value: {{ .domain }}
|
|
||||||
- name: ALLOWED_HOSTS
|
|
||||||
value: {{ .domain }}
|
|
||||||
- name: EMAIL
|
|
||||||
value: {{ .smtp.from }}
|
|
||||||
- name: POSTGRES_HOST
|
|
||||||
value: {{ .db.host }}
|
|
||||||
- name: PGPORT
|
|
||||||
value: "{{ .db.port }}"
|
|
||||||
- name: POSTGRES_DB
|
|
||||||
value: {{ .db.name }}
|
|
||||||
- name: POSTGRES_USER
|
|
||||||
value: {{ .db.user }}
|
|
||||||
- name: POSTGRES_PASSWORD
|
|
||||||
valueFrom:
|
|
||||||
secretKeyRef:
|
|
||||||
name: bookwyrm-secrets
|
|
||||||
key: dbPassword
|
|
||||||
- name: REDIS_ACTIVITY_HOST
|
|
||||||
value: {{ .redis.host }}
|
|
||||||
- name: REDIS_ACTIVITY_PORT
|
|
||||||
value: "6379"
|
|
||||||
- name: REDIS_ACTIVITY_PASSWORD
|
|
||||||
valueFrom:
|
|
||||||
secretKeyRef:
|
|
||||||
name: bookwyrm-secrets
|
|
||||||
key: redis.password
|
|
||||||
- name: REDIS_ACTIVITY_URL
|
|
||||||
value: redis://:$(REDIS_ACTIVITY_PASSWORD)@{{ .redis.host }}:6379/0
|
|
||||||
- name: REDIS_BROKER_HOST
|
|
||||||
value: {{ .redis.host }}
|
|
||||||
- name: REDIS_BROKER_PORT
|
|
||||||
value: "6379"
|
|
||||||
- name: REDIS_BROKER_PASSWORD
|
|
||||||
valueFrom:
|
|
||||||
secretKeyRef:
|
|
||||||
name: bookwyrm-secrets
|
|
||||||
key: redis.password
|
|
||||||
- name: REDIS_BROKER_URL
|
|
||||||
value: redis://:$(REDIS_BROKER_PASSWORD)@{{ .redis.host }}:6379/1
|
|
||||||
- name: EMAIL_HOST
|
|
||||||
value: {{ .smtp.host }}
|
|
||||||
- name: EMAIL_PORT
|
|
||||||
value: "{{ .smtp.port }}"
|
|
||||||
- name: EMAIL_HOST_USER
|
|
||||||
value: {{ .smtp.user }}
|
|
||||||
- name: EMAIL_HOST_PASSWORD
|
|
||||||
valueFrom:
|
|
||||||
secretKeyRef:
|
|
||||||
name: bookwyrm-secrets
|
|
||||||
key: smtpPassword
|
|
||||||
- name: EMAIL_USE_TLS
|
|
||||||
value: "true"
|
|
||||||
- name: EMAIL_USE_SSL
|
|
||||||
value: "false"
|
|
||||||
- name: EMAIL_SENDER_NAME
|
|
||||||
value: BookWyrm
|
|
||||||
- name: EMAIL_SENDER_DOMAIN
|
|
||||||
value: {{ .domain }}
|
|
||||||
- name: MEDIA_ROOT
|
|
||||||
value: /app/images
|
|
||||||
- name: STATIC_ROOT
|
|
||||||
value: /app/static
|
|
||||||
- name: ENABLE_THUMBNAIL_GENERATION
|
|
||||||
value: "true"
|
|
||||||
resources:
|
|
||||||
limits:
|
|
||||||
cpu: 1000m
|
|
||||||
ephemeral-storage: 2Gi
|
|
||||||
memory: 1Gi
|
|
||||||
requests:
|
|
||||||
cpu: 50m
|
|
||||||
ephemeral-storage: 50Mi
|
|
||||||
memory: 256Mi
|
|
||||||
volumeMounts:
|
|
||||||
- name: bookwyrm-images
|
|
||||||
mountPath: /app/images
|
|
||||||
- name: bookwyrm-static
|
|
||||||
mountPath: /app/static
|
|
||||||
- name: bookwyrm-exports
|
|
||||||
mountPath: /app/exports
|
|
||||||
livenessProbe:
|
|
||||||
tcpSocket:
|
|
||||||
port: 8000
|
|
||||||
initialDelaySeconds: 120
|
|
||||||
timeoutSeconds: 10
|
|
||||||
periodSeconds: 30
|
|
||||||
failureThreshold: 6
|
|
||||||
readinessProbe:
|
|
||||||
tcpSocket:
|
|
||||||
port: 8000
|
|
||||||
initialDelaySeconds: 60
|
|
||||||
timeoutSeconds: 5
|
|
||||||
periodSeconds: 15
|
|
||||||
failureThreshold: 3
|
|
||||||
securityContext:
|
|
||||||
allowPrivilegeEscalation: false
|
|
||||||
capabilities:
|
|
||||||
drop:
|
|
||||||
- ALL
|
|
||||||
readOnlyRootFilesystem: false
|
|
||||||
volumes:
|
|
||||||
- name: bookwyrm-images
|
|
||||||
persistentVolumeClaim:
|
|
||||||
claimName: bookwyrm-images
|
|
||||||
- name: bookwyrm-static
|
|
||||||
emptyDir: {}
|
|
||||||
- name: bookwyrm-exports
|
|
||||||
emptyDir: {}
|
|
||||||
restartPolicy: Always
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
apiVersion: networking.k8s.io/v1
|
|
||||||
kind: Ingress
|
|
||||||
metadata:
|
|
||||||
name: bookwyrm
|
|
||||||
namespace: bookwyrm
|
|
||||||
annotations:
|
|
||||||
external-dns.alpha.kubernetes.io/target: {{ .externalDnsDomain }}
|
|
||||||
external-dns.alpha.kubernetes.io/cloudflare-proxied: "false"
|
|
||||||
external-dns.alpha.kubernetes.io/ttl: "60"
|
|
||||||
spec:
|
|
||||||
ingressClassName: traefik
|
|
||||||
rules:
|
|
||||||
- host: {{ .domain }}
|
|
||||||
http:
|
|
||||||
paths:
|
|
||||||
- path: /
|
|
||||||
pathType: Prefix
|
|
||||||
backend:
|
|
||||||
service:
|
|
||||||
name: bookwyrm
|
|
||||||
port:
|
|
||||||
number: 80
|
|
||||||
tls:
|
|
||||||
- hosts:
|
|
||||||
- {{ .domain }}
|
|
||||||
secretName: {{ .tlsSecretName }}
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
apiVersion: kustomize.config.k8s.io/v1beta1
|
|
||||||
kind: Kustomization
|
|
||||||
namespace: {{ .namespace }}
|
|
||||||
labels:
|
|
||||||
- includeSelectors: true
|
|
||||||
pairs:
|
|
||||||
app: bookwyrm
|
|
||||||
managedBy: kustomize
|
|
||||||
partOf: wild-cloud
|
|
||||||
resources:
|
|
||||||
- namespace.yaml
|
|
||||||
- deployment.yaml
|
|
||||||
- deployment-worker.yaml
|
|
||||||
- service.yaml
|
|
||||||
- ingress.yaml
|
|
||||||
- pvc.yaml
|
|
||||||
- db-init-job.yaml
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
version: 0.8.7-1
|
|
||||||
requires:
|
|
||||||
- name: postgres
|
|
||||||
- name: redis
|
|
||||||
- name: smtp
|
|
||||||
defaultConfig:
|
|
||||||
namespace: bookwyrm
|
|
||||||
externalDnsDomain: '{{ .cloud.domain }}'
|
|
||||||
domain: bookwyrm.{{ .cloud.domain }}
|
|
||||||
tlsSecretName: wildcard-wild-cloud-tls
|
|
||||||
storage: 2Gi
|
|
||||||
db:
|
|
||||||
host: '{{ .apps.postgres.host }}'
|
|
||||||
port: '{{ .apps.postgres.port }}'
|
|
||||||
name: bookwyrm
|
|
||||||
user: bookwyrm
|
|
||||||
redis:
|
|
||||||
host: '{{ .apps.redis.host }}'
|
|
||||||
smtp:
|
|
||||||
host: '{{ .apps.smtp.host }}'
|
|
||||||
port: '{{ .apps.smtp.port }}'
|
|
||||||
from: '{{ .apps.smtp.from }}'
|
|
||||||
user: '{{ .apps.smtp.user }}'
|
|
||||||
defaultSecrets:
|
|
||||||
- key: secretKey
|
|
||||||
- key: dbPassword
|
|
||||||
- key: smtpPassword
|
|
||||||
requiredSecrets:
|
|
||||||
- postgres.password
|
|
||||||
- redis.password
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
apiVersion: v1
|
|
||||||
kind: PersistentVolumeClaim
|
|
||||||
metadata:
|
|
||||||
name: bookwyrm-images
|
|
||||||
namespace: bookwyrm
|
|
||||||
spec:
|
|
||||||
accessModes:
|
|
||||||
- ReadWriteOnce
|
|
||||||
resources:
|
|
||||||
requests:
|
|
||||||
storage: {{ .storage }}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
apiVersion: v1
|
|
||||||
kind: Service
|
|
||||||
metadata:
|
|
||||||
name: bookwyrm
|
|
||||||
namespace: bookwyrm
|
|
||||||
spec:
|
|
||||||
selector:
|
|
||||||
component: web
|
|
||||||
ports:
|
|
||||||
- name: http
|
|
||||||
port: 80
|
|
||||||
targetPort: 8000
|
|
||||||
protocol: TCP
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
name: cert-manager
|
|
||||||
is: cert-manager
|
|
||||||
description: X.509 certificate management for Kubernetes
|
|
||||||
category: services
|
|
||||||
icon: https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/cert-manager.svg
|
|
||||||
latest: "v1"
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
# cert-manager
|
|
||||||
|
|
||||||
X.509 certificate management for Kubernetes using Let's Encrypt.
|
|
||||||
|
|
||||||
## Upstream
|
|
||||||
|
|
||||||
The `upstream/cert-manager.yaml` file is downloaded from the official cert-manager release:
|
|
||||||
|
|
||||||
- Source: https://github.com/cert-manager/cert-manager/releases/download/v1.17.2/cert-manager.yaml
|
|
||||||
- Version: v1.17.2
|
|
||||||
|
|
||||||
To update, download the new version and replace the file.
|
|
||||||
|
|
||||||
## DNS Configuration
|
|
||||||
|
|
||||||
The upstream cert-manager deployment is patched via kustomize overlay (`upstream/kustomization.yaml`) to use external DNS resolvers (1.1.1.1, 8.8.8.8) instead of cluster DNS. This is required for ACME DNS-01 challenge verification.
|
|
||||||
|
|
||||||
## Maintenance
|
|
||||||
|
|
||||||
The `scripts/repair-certificates.sh` script can fix stuck certificates, orphaned ACME orders, and Cloudflare DNS cleanup errors. Run it manually when certificate issuance has issues.
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
---
|
|
||||||
apiVersion: cert-manager.io/v1
|
|
||||||
kind: Certificate
|
|
||||||
metadata:
|
|
||||||
name: wildcard-internal-wild-cloud
|
|
||||||
namespace: cert-manager
|
|
||||||
spec:
|
|
||||||
secretName: wildcard-internal-wild-cloud-tls
|
|
||||||
dnsNames:
|
|
||||||
- "*.{{ .internalDomain }}"
|
|
||||||
- "{{ .internalDomain }}"
|
|
||||||
issuerRef:
|
|
||||||
name: letsencrypt-prod
|
|
||||||
kind: ClusterIssuer
|
|
||||||
duration: 2160h # 90 days
|
|
||||||
renewBefore: 360h # 15 days
|
|
||||||
privateKey:
|
|
||||||
algorithm: RSA
|
|
||||||
size: 2048
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
apiVersion: kustomize.config.k8s.io/v1beta1
|
|
||||||
kind: Kustomization
|
|
||||||
|
|
||||||
resources:
|
|
||||||
- namespace.yaml
|
|
||||||
- letsencrypt-staging-dns01.yaml
|
|
||||||
- letsencrypt-prod-dns01.yaml
|
|
||||||
- internal-wildcard-certificate.yaml
|
|
||||||
- wildcard-certificate.yaml
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
---
|
|
||||||
apiVersion: cert-manager.io/v1
|
|
||||||
kind: ClusterIssuer
|
|
||||||
metadata:
|
|
||||||
name: letsencrypt-prod
|
|
||||||
spec:
|
|
||||||
acme:
|
|
||||||
email: {{ .email }}
|
|
||||||
privateKeySecretRef:
|
|
||||||
name: letsencrypt-prod
|
|
||||||
server: https://acme-v02.api.letsencrypt.org/directory
|
|
||||||
solvers:
|
|
||||||
# DNS-01 solver for wildcard certificates
|
|
||||||
- dns01:
|
|
||||||
cloudflare:
|
|
||||||
apiTokenSecretRef:
|
|
||||||
name: cloudflare-api-token
|
|
||||||
key: api-token
|
|
||||||
selector:
|
|
||||||
dnsZones:
|
|
||||||
- "{{ .cloudflareDomain }}"
|
|
||||||
# Keep the HTTP-01 solver for non-wildcard certificates
|
|
||||||
- http01:
|
|
||||||
ingress:
|
|
||||||
class: traefik
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
---
|
|
||||||
apiVersion: cert-manager.io/v1
|
|
||||||
kind: ClusterIssuer
|
|
||||||
metadata:
|
|
||||||
name: letsencrypt-staging
|
|
||||||
spec:
|
|
||||||
acme:
|
|
||||||
email: {{ .email }}
|
|
||||||
privateKeySecretRef:
|
|
||||||
name: letsencrypt-staging
|
|
||||||
server: https://acme-staging-v02.api.letsencrypt.org/directory
|
|
||||||
solvers:
|
|
||||||
# DNS-01 solver for wildcard certificates
|
|
||||||
- dns01:
|
|
||||||
cloudflare:
|
|
||||||
apiTokenSecretRef:
|
|
||||||
name: cloudflare-api-token
|
|
||||||
key: api-token
|
|
||||||
selector:
|
|
||||||
dnsZones:
|
|
||||||
- "{{ .cloudflareDomain }}"
|
|
||||||
# Keep the HTTP-01 solver for non-wildcard certificates
|
|
||||||
- http01:
|
|
||||||
ingress:
|
|
||||||
class: traefik
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
version: v1.17.2-1
|
|
||||||
requires:
|
|
||||||
- name: traefik
|
|
||||||
defaultConfig:
|
|
||||||
namespace: cert-manager
|
|
||||||
cloudDomain: "{{ .cloud.domain }}"
|
|
||||||
internalDomain: "{{ .cloud.internalDomain }}"
|
|
||||||
email: "{{ .operator.email }}"
|
|
||||||
cloudflareDomain: "{{ .cloud.baseDomain }}"
|
|
||||||
scripts:
|
|
||||||
- name: repair-certificates
|
|
||||||
path: scripts/repair-certificates.sh
|
|
||||||
description: Fix stuck certificates, orphaned ACME orders, and Cloudflare DNS cleanup errors
|
|
||||||
defaultSecrets:
|
|
||||||
- key: cloudflareToken
|
|
||||||
deploy:
|
|
||||||
phases:
|
|
||||||
- path: upstream
|
|
||||||
waitFor:
|
|
||||||
name: cert-manager-webhook
|
|
||||||
timeout: "120s"
|
|
||||||
- path: .
|
|
||||||
createSecrets:
|
|
||||||
- name: cloudflare-api-token
|
|
||||||
entries:
|
|
||||||
api-token: cloudflareToken
|
|
||||||
@@ -1,89 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
# Repair stuck certificates, orphaned ACME orders, and Cloudflare DNS errors.
|
|
||||||
# This is an operational maintenance script, not part of deployment.
|
|
||||||
# Run manually when cert-manager has issues with certificate issuance.
|
|
||||||
#
|
|
||||||
# Usage: KUBECONFIG=/path/to/kubeconfig ./repair-certificates.sh
|
|
||||||
set -e
|
|
||||||
set -o pipefail
|
|
||||||
|
|
||||||
if [ -z "${KUBECONFIG}" ]; then
|
|
||||||
echo "ERROR: KUBECONFIG is not set"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
needs_restart=false
|
|
||||||
|
|
||||||
echo "=== cert-manager Certificate Repair ==="
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
echo "Checking for certificates with failed issuance attempts..."
|
|
||||||
stuck_certs=$(kubectl get certificates --all-namespaces -o json 2>/dev/null | \
|
|
||||||
jq -r '.items[] | select(.status.conditions[]? | select(.type=="Issuing" and .status=="False" and (.message | contains("404")))) | "\(.metadata.namespace) \(.metadata.name)"')
|
|
||||||
|
|
||||||
if [ -n "$stuck_certs" ]; then
|
|
||||||
echo "WARNING: Found certificates stuck with non-existent orders, recreating them..."
|
|
||||||
echo "$stuck_certs" | while read ns name; do
|
|
||||||
echo "Recreating certificate $ns/$name..."
|
|
||||||
cert_spec=$(kubectl get certificate "$name" -n "$ns" -o json | jq '.spec')
|
|
||||||
kubectl delete certificate "$name" -n "$ns"
|
|
||||||
echo "{\"apiVersion\":\"cert-manager.io/v1\",\"kind\":\"Certificate\",\"metadata\":{\"name\":\"$name\",\"namespace\":\"$ns\"},\"spec\":$cert_spec}" | kubectl apply -f -
|
|
||||||
done
|
|
||||||
needs_restart=true
|
|
||||||
sleep 5
|
|
||||||
else
|
|
||||||
echo "No certificates stuck with failed orders"
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "Checking for orphaned ACME orders..."
|
|
||||||
orphaned_orders=$(kubectl logs -n cert-manager deployment/cert-manager --tail=200 2>/dev/null | \
|
|
||||||
grep -E "failed to retrieve the ACME order.*404" 2>/dev/null | \
|
|
||||||
sed -n 's/.*resource_name="\([^"]*\)".*/\1/p' | \
|
|
||||||
sort -u || true)
|
|
||||||
|
|
||||||
if [ -n "$orphaned_orders" ]; then
|
|
||||||
echo "WARNING: Found orphaned ACME orders from logs"
|
|
||||||
for order in $orphaned_orders; do
|
|
||||||
echo "Deleting orphaned order: $order"
|
|
||||||
orders_found=$(kubectl get orders --all-namespaces 2>/dev/null | grep "$order" 2>/dev/null || true)
|
|
||||||
if [ -n "$orders_found" ]; then
|
|
||||||
echo "$orders_found" | while read ns name rest; do
|
|
||||||
kubectl delete order "$name" -n "$ns" 2>/dev/null || true
|
|
||||||
done
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
needs_restart=true
|
|
||||||
else
|
|
||||||
echo "No orphaned orders found in logs"
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "Checking for Cloudflare DNS cleanup errors..."
|
|
||||||
cloudflare_errors=$(kubectl logs -n cert-manager deployment/cert-manager --tail=200 2>/dev/null | \
|
|
||||||
grep -c "Error: 7003.*Could not route" 2>/dev/null || echo "0")
|
|
||||||
|
|
||||||
if [ "$cloudflare_errors" -gt "0" ]; then
|
|
||||||
echo "WARNING: Found $cloudflare_errors Cloudflare DNS cleanup errors (stale DNS record references)"
|
|
||||||
echo "Deleting stuck challenges and orders to allow fresh start"
|
|
||||||
|
|
||||||
kubectl delete challenges --all -n cert-manager 2>/dev/null || true
|
|
||||||
kubectl delete orders --all -n cert-manager 2>/dev/null || true
|
|
||||||
|
|
||||||
needs_restart=true
|
|
||||||
else
|
|
||||||
echo "No Cloudflare DNS cleanup errors"
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ "$needs_restart" = true ]; then
|
|
||||||
echo "Restarting cert-manager to clear internal state..."
|
|
||||||
kubectl rollout restart deployment cert-manager -n cert-manager
|
|
||||||
kubectl rollout status deployment/cert-manager -n cert-manager --timeout=120s
|
|
||||||
echo "Waiting for cert-manager to recreate fresh challenges..."
|
|
||||||
sleep 15
|
|
||||||
else
|
|
||||||
echo "No restart needed - cert-manager state is clean"
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "Repair complete. Check certificate status with:"
|
|
||||||
echo " kubectl get certificates --all-namespaces"
|
|
||||||
echo " kubectl get clusterissuers"
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,30 +0,0 @@
|
|||||||
apiVersion: kustomize.config.k8s.io/v1beta1
|
|
||||||
kind: Kustomization
|
|
||||||
resources:
|
|
||||||
- cert-manager.yaml
|
|
||||||
patches:
|
|
||||||
- target:
|
|
||||||
kind: Deployment
|
|
||||||
name: cert-manager
|
|
||||||
namespace: cert-manager
|
|
||||||
patch: |-
|
|
||||||
apiVersion: apps/v1
|
|
||||||
kind: Deployment
|
|
||||||
metadata:
|
|
||||||
name: cert-manager
|
|
||||||
namespace: cert-manager
|
|
||||||
spec:
|
|
||||||
template:
|
|
||||||
spec:
|
|
||||||
dnsPolicy: None
|
|
||||||
dnsConfig:
|
|
||||||
nameservers:
|
|
||||||
- "1.1.1.1"
|
|
||||||
- "8.8.8.8"
|
|
||||||
searches:
|
|
||||||
- cert-manager.svc.cluster.local
|
|
||||||
- svc.cluster.local
|
|
||||||
- cluster.local
|
|
||||||
options:
|
|
||||||
- name: ndots
|
|
||||||
value: "5"
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
---
|
|
||||||
apiVersion: cert-manager.io/v1
|
|
||||||
kind: Certificate
|
|
||||||
metadata:
|
|
||||||
name: wildcard-wild-cloud
|
|
||||||
namespace: cert-manager
|
|
||||||
spec:
|
|
||||||
secretName: wildcard-wild-cloud-tls
|
|
||||||
dnsNames:
|
|
||||||
- "*.{{ .cloudDomain }}"
|
|
||||||
- "{{ .cloudDomain }}"
|
|
||||||
issuerRef:
|
|
||||||
name: letsencrypt-prod
|
|
||||||
kind: ClusterIssuer
|
|
||||||
duration: 2160h # 90 days
|
|
||||||
renewBefore: 360h # 15 days
|
|
||||||
privateKey:
|
|
||||||
algorithm: RSA
|
|
||||||
size: 2048
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
name: chamilo
|
|
||||||
is: chamilo
|
|
||||||
description: Chamilo is a free and open-source e-learning platform (LMS) for course management, assessments, and online education.
|
|
||||||
category: education
|
|
||||||
icon: https://raw.githubusercontent.com/chamilo/chamilo-lms/master/public/img/logo.png
|
|
||||||
latest: "1"
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
# Chamilo
|
|
||||||
|
|
||||||
Chamilo is an open-source Learning Management System (LMS) for creating and delivering online courses.
|
|
||||||
|
|
||||||
## Dependencies
|
|
||||||
|
|
||||||
- **MySQL** - Database for storing courses, users, and activity
|
|
||||||
- **SMTP** - For account verification and course notifications
|
|
||||||
|
|
||||||
## Configuration
|
|
||||||
|
|
||||||
Key settings in `config.yaml`:
|
|
||||||
|
|
||||||
- **domain** - Where Chamilo will be accessible
|
|
||||||
- **storage** - Persistent volume size (default: `2Gi`)
|
|
||||||
- **siteName** - Display name for the LMS (default: `Chamilo LMS`)
|
|
||||||
- **adminUser** - Admin account username (default: `admin`)
|
|
||||||
- **adminEmail** - Admin account email (defaults to your operator email)
|
|
||||||
- **smtp** - Email settings inherited from your Wild Cloud SMTP service
|
|
||||||
|
|
||||||
## First-Time Setup
|
|
||||||
|
|
||||||
1. Add and deploy the app:
|
|
||||||
```bash
|
|
||||||
wild app add chamilo
|
|
||||||
wild app deploy chamilo
|
|
||||||
```
|
|
||||||
|
|
||||||
2. Log in with:
|
|
||||||
- **Username**: value of `adminUser` in your config (default: `admin`)
|
|
||||||
- **Password**: value of `adminPassword` in your `secrets.yaml`
|
|
||||||
|
|
||||||
3. From the admin panel, create courses, enroll learners, and configure the LMS settings.
|
|
||||||
|
|
||||||
## Notes
|
|
||||||
|
|
||||||
- The admin panel is accessible via **Administration** in the top navigation after logging in
|
|
||||||
- Courses can be assigned to categories, and users can self-enroll or be enrolled by teachers
|
|
||||||
- SMTP is needed for sending course completion certificates and user notifications
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
apiVersion: batch/v1
|
|
||||||
kind: Job
|
|
||||||
metadata:
|
|
||||||
name: chamilo-db-init
|
|
||||||
labels:
|
|
||||||
component: db-init
|
|
||||||
spec:
|
|
||||||
template:
|
|
||||||
metadata:
|
|
||||||
labels:
|
|
||||||
component: db-init
|
|
||||||
spec:
|
|
||||||
restartPolicy: OnFailure
|
|
||||||
securityContext:
|
|
||||||
runAsNonRoot: true
|
|
||||||
runAsUser: 999
|
|
||||||
runAsGroup: 999
|
|
||||||
seccompProfile:
|
|
||||||
type: RuntimeDefault
|
|
||||||
containers:
|
|
||||||
- name: mysql-init
|
|
||||||
image: mysql:9.1.0
|
|
||||||
securityContext:
|
|
||||||
allowPrivilegeEscalation: false
|
|
||||||
capabilities:
|
|
||||||
drop:
|
|
||||||
- ALL
|
|
||||||
readOnlyRootFilesystem: false
|
|
||||||
env:
|
|
||||||
- name: MYSQL_ROOT_PASSWORD
|
|
||||||
valueFrom:
|
|
||||||
secretKeyRef:
|
|
||||||
name: chamilo-secrets
|
|
||||||
key: mysql.rootPassword
|
|
||||||
- name: DB_HOSTNAME
|
|
||||||
value: {{ .db.host }}
|
|
||||||
- name: DB_PORT
|
|
||||||
value: "{{ .db.port }}"
|
|
||||||
- name: DB_DATABASE_NAME
|
|
||||||
value: {{ .db.name }}
|
|
||||||
- name: DB_USERNAME
|
|
||||||
value: {{ .db.user }}
|
|
||||||
- name: DB_PASSWORD
|
|
||||||
valueFrom:
|
|
||||||
secretKeyRef:
|
|
||||||
name: chamilo-secrets
|
|
||||||
key: dbPassword
|
|
||||||
command:
|
|
||||||
- /bin/bash
|
|
||||||
- -c
|
|
||||||
- |
|
|
||||||
set -e
|
|
||||||
until mysql -h ${DB_HOSTNAME} -P ${DB_PORT} -u root -p${MYSQL_ROOT_PASSWORD} -e "SELECT 1" 2>/dev/null; do
|
|
||||||
echo "Waiting for MySQL..."
|
|
||||||
sleep 2
|
|
||||||
done
|
|
||||||
mysql -h ${DB_HOSTNAME} -P ${DB_PORT} -u root -p${MYSQL_ROOT_PASSWORD} <<EOF
|
|
||||||
CREATE DATABASE IF NOT EXISTS ${DB_DATABASE_NAME} CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
|
||||||
CREATE USER IF NOT EXISTS '${DB_USERNAME}'@'%' IDENTIFIED BY '${DB_PASSWORD}';
|
|
||||||
ALTER USER '${DB_USERNAME}'@'%' IDENTIFIED BY '${DB_PASSWORD}';
|
|
||||||
GRANT ALL PRIVILEGES ON ${DB_DATABASE_NAME}.* TO '${DB_USERNAME}'@'%';
|
|
||||||
FLUSH PRIVILEGES;
|
|
||||||
EOF
|
|
||||||
echo "Done"
|
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
apiVersion: apps/v1
|
|
||||||
kind: Deployment
|
|
||||||
metadata:
|
|
||||||
name: chamilo
|
|
||||||
namespace: {{ .namespace }}
|
|
||||||
spec:
|
|
||||||
replicas: 1
|
|
||||||
strategy:
|
|
||||||
type: Recreate
|
|
||||||
selector:
|
|
||||||
matchLabels:
|
|
||||||
component: web
|
|
||||||
template:
|
|
||||||
metadata:
|
|
||||||
labels:
|
|
||||||
component: web
|
|
||||||
spec:
|
|
||||||
securityContext:
|
|
||||||
runAsNonRoot: false
|
|
||||||
runAsUser: 0
|
|
||||||
seccompProfile:
|
|
||||||
type: RuntimeDefault
|
|
||||||
containers:
|
|
||||||
- name: chamilo
|
|
||||||
image: ipeos/chamilo:1.11.28
|
|
||||||
ports:
|
|
||||||
- name: http
|
|
||||||
containerPort: 80
|
|
||||||
protocol: TCP
|
|
||||||
env:
|
|
||||||
- name: PHP_TIMEZONE
|
|
||||||
value: UTC
|
|
||||||
- name: PHP_MEMORY_LIMIT
|
|
||||||
value: 256M
|
|
||||||
- name: PHP_MAX_EXECUTION_TIME
|
|
||||||
value: "300"
|
|
||||||
- name: PHP_UPLOAD_MAX_FILESIZE
|
|
||||||
value: 100M
|
|
||||||
- name: PHP_POST_MAX_SIZE
|
|
||||||
value: 100M
|
|
||||||
resources:
|
|
||||||
limits:
|
|
||||||
cpu: "1"
|
|
||||||
ephemeral-storage: 1Gi
|
|
||||||
memory: 512Mi
|
|
||||||
requests:
|
|
||||||
cpu: 50m
|
|
||||||
ephemeral-storage: 50Mi
|
|
||||||
memory: 256Mi
|
|
||||||
volumeMounts:
|
|
||||||
- name: chamilo-data
|
|
||||||
mountPath: /var/www/html
|
|
||||||
livenessProbe:
|
|
||||||
httpGet:
|
|
||||||
path: /
|
|
||||||
port: 80
|
|
||||||
initialDelaySeconds: 120
|
|
||||||
timeoutSeconds: 10
|
|
||||||
periodSeconds: 30
|
|
||||||
failureThreshold: 6
|
|
||||||
readinessProbe:
|
|
||||||
httpGet:
|
|
||||||
path: /
|
|
||||||
port: 80
|
|
||||||
initialDelaySeconds: 60
|
|
||||||
timeoutSeconds: 5
|
|
||||||
periodSeconds: 10
|
|
||||||
failureThreshold: 3
|
|
||||||
securityContext:
|
|
||||||
allowPrivilegeEscalation: true
|
|
||||||
readOnlyRootFilesystem: false
|
|
||||||
volumes:
|
|
||||||
- name: chamilo-data
|
|
||||||
persistentVolumeClaim:
|
|
||||||
claimName: chamilo-data
|
|
||||||
restartPolicy: Always
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
apiVersion: networking.k8s.io/v1
|
|
||||||
kind: Ingress
|
|
||||||
metadata:
|
|
||||||
name: chamilo
|
|
||||||
namespace: {{ .namespace }}
|
|
||||||
annotations:
|
|
||||||
external-dns.alpha.kubernetes.io/cloudflare-proxied: "false"
|
|
||||||
external-dns.alpha.kubernetes.io/target: {{ .externalDnsDomain }}
|
|
||||||
external-dns.alpha.kubernetes.io/ttl: "60"
|
|
||||||
spec:
|
|
||||||
ingressClassName: traefik
|
|
||||||
rules:
|
|
||||||
- host: {{ .domain }}
|
|
||||||
http:
|
|
||||||
paths:
|
|
||||||
- path: /
|
|
||||||
pathType: Prefix
|
|
||||||
backend:
|
|
||||||
service:
|
|
||||||
name: chamilo
|
|
||||||
port:
|
|
||||||
number: 80
|
|
||||||
tls:
|
|
||||||
- hosts:
|
|
||||||
- {{ .domain }}
|
|
||||||
secretName: {{ .tlsSecretName }}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
apiVersion: kustomize.config.k8s.io/v1beta1
|
|
||||||
kind: Kustomization
|
|
||||||
namespace: {{ .namespace }}
|
|
||||||
labels:
|
|
||||||
- includeSelectors: true
|
|
||||||
pairs:
|
|
||||||
app: chamilo
|
|
||||||
managedBy: kustomize
|
|
||||||
partOf: wild-cloud
|
|
||||||
resources:
|
|
||||||
- namespace.yaml
|
|
||||||
- db-init-job.yaml
|
|
||||||
- deployment.yaml
|
|
||||||
- service.yaml
|
|
||||||
- ingress.yaml
|
|
||||||
- pvc.yaml
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
version: 1.11.28-1
|
|
||||||
requires:
|
|
||||||
- name: mysql
|
|
||||||
- name: smtp
|
|
||||||
defaultConfig:
|
|
||||||
namespace: chamilo
|
|
||||||
externalDnsDomain: '{{ .cloud.domain }}'
|
|
||||||
domain: chamilo.{{ .cloud.domain }}
|
|
||||||
tlsSecretName: wildcard-wild-cloud-tls
|
|
||||||
storage: 2Gi
|
|
||||||
siteName: Chamilo LMS
|
|
||||||
adminUser: admin
|
|
||||||
adminEmail: '{{ .operator.email }}'
|
|
||||||
db:
|
|
||||||
host: '{{ .apps.mysql.host }}'
|
|
||||||
port: "3306"
|
|
||||||
name: chamilo
|
|
||||||
user: chamilo
|
|
||||||
smtp:
|
|
||||||
host: '{{ .apps.smtp.host }}'
|
|
||||||
port: '{{ .apps.smtp.port }}'
|
|
||||||
from: '{{ .apps.smtp.from }}'
|
|
||||||
user: '{{ .apps.smtp.user }}'
|
|
||||||
defaultSecrets:
|
|
||||||
- key: adminPassword
|
|
||||||
- key: dbPassword
|
|
||||||
- key: smtpPassword
|
|
||||||
requiredSecrets:
|
|
||||||
- mysql.rootPassword
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
apiVersion: v1
|
|
||||||
kind: Service
|
|
||||||
metadata:
|
|
||||||
name: chamilo
|
|
||||||
namespace: {{ .namespace }}
|
|
||||||
spec:
|
|
||||||
selector:
|
|
||||||
component: web
|
|
||||||
ports:
|
|
||||||
- name: http
|
|
||||||
port: 80
|
|
||||||
targetPort: 80
|
|
||||||
protocol: TCP
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
name: community-search
|
|
||||||
is: community-search
|
|
||||||
description: Community Search is a federated, self-hosted search engine built on community-curated indexes rather than global web crawling.
|
|
||||||
category: community
|
|
||||||
latest: "0"
|
|
||||||
ignoreRules:
|
|
||||||
- WC-ICON # no established icon available
|
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
apiVersion: apps/v1
|
|
||||||
kind: Deployment
|
|
||||||
metadata:
|
|
||||||
name: community-search
|
|
||||||
spec:
|
|
||||||
replicas: 1
|
|
||||||
strategy:
|
|
||||||
type: Recreate
|
|
||||||
selector:
|
|
||||||
matchLabels:
|
|
||||||
component: web
|
|
||||||
template:
|
|
||||||
metadata:
|
|
||||||
labels:
|
|
||||||
component: web
|
|
||||||
spec:
|
|
||||||
securityContext:
|
|
||||||
runAsNonRoot: true
|
|
||||||
runAsUser: 999
|
|
||||||
runAsGroup: 999
|
|
||||||
fsGroup: 999
|
|
||||||
seccompProfile:
|
|
||||||
type: RuntimeDefault
|
|
||||||
containers:
|
|
||||||
- name: community-search
|
|
||||||
image: payneio/community-search:0.1.2
|
|
||||||
imagePullPolicy: Always
|
|
||||||
securityContext:
|
|
||||||
allowPrivilegeEscalation: false
|
|
||||||
capabilities:
|
|
||||||
drop:
|
|
||||||
- ALL
|
|
||||||
readOnlyRootFilesystem: false
|
|
||||||
ports:
|
|
||||||
- name: http
|
|
||||||
containerPort: 8080
|
|
||||||
env:
|
|
||||||
- name: COMMUNITY_SEARCH_BIND_ADDR
|
|
||||||
value: "0.0.0.0"
|
|
||||||
- name: COMMUNITY_SEARCH_PORT
|
|
||||||
value: "8080"
|
|
||||||
- name: COMMUNITY_SEARCH_DATA_DIR
|
|
||||||
value: "/data"
|
|
||||||
- name: COMMUNITY_SEARCH_INDEX_PATH
|
|
||||||
value: "/data/index"
|
|
||||||
- name: COMMUNITY_SEARCH_ADMIN_TOKEN
|
|
||||||
valueFrom:
|
|
||||||
secretKeyRef:
|
|
||||||
name: community-search-secrets
|
|
||||||
key: adminToken
|
|
||||||
- name: SELF_URL
|
|
||||||
value: "{{ .selfUrl }}"
|
|
||||||
- name: SELF_NAME
|
|
||||||
value: "{{ .selfName }}"
|
|
||||||
- name: RUST_LOG
|
|
||||||
value: "community_search=info,tower_http=warn"
|
|
||||||
volumeMounts:
|
|
||||||
- name: data
|
|
||||||
mountPath: /data
|
|
||||||
resources:
|
|
||||||
requests:
|
|
||||||
cpu: 50m
|
|
||||||
memory: 256Mi
|
|
||||||
limits:
|
|
||||||
cpu: 1
|
|
||||||
memory: 1Gi
|
|
||||||
readinessProbe:
|
|
||||||
httpGet:
|
|
||||||
path: /api/collections
|
|
||||||
port: http
|
|
||||||
initialDelaySeconds: 10
|
|
||||||
periodSeconds: 10
|
|
||||||
timeoutSeconds: 5
|
|
||||||
livenessProbe:
|
|
||||||
httpGet:
|
|
||||||
path: /api/collections
|
|
||||||
port: http
|
|
||||||
initialDelaySeconds: 30
|
|
||||||
periodSeconds: 30
|
|
||||||
timeoutSeconds: 5
|
|
||||||
volumes:
|
|
||||||
- name: data
|
|
||||||
persistentVolumeClaim:
|
|
||||||
claimName: community-search-data
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
apiVersion: networking.k8s.io/v1
|
|
||||||
kind: Ingress
|
|
||||||
metadata:
|
|
||||||
name: community-search
|
|
||||||
annotations:
|
|
||||||
external-dns.alpha.kubernetes.io/cloudflare-proxied: "false"
|
|
||||||
external-dns.alpha.kubernetes.io/target: {{ .externalDnsDomain }}
|
|
||||||
external-dns.alpha.kubernetes.io/ttl: "60"
|
|
||||||
spec:
|
|
||||||
ingressClassName: traefik
|
|
||||||
rules:
|
|
||||||
- host: {{ .domain }}
|
|
||||||
http:
|
|
||||||
paths:
|
|
||||||
- path: /
|
|
||||||
pathType: Prefix
|
|
||||||
backend:
|
|
||||||
service:
|
|
||||||
name: community-search
|
|
||||||
port:
|
|
||||||
number: 80
|
|
||||||
tls:
|
|
||||||
- hosts:
|
|
||||||
- {{ .domain }}
|
|
||||||
secretName: {{ .tlsSecretName }}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
apiVersion: kustomize.config.k8s.io/v1beta1
|
|
||||||
kind: Kustomization
|
|
||||||
namespace: community-search
|
|
||||||
labels:
|
|
||||||
- includeSelectors: true
|
|
||||||
pairs:
|
|
||||||
app: community-search
|
|
||||||
managedBy: kustomize
|
|
||||||
partOf: wild-cloud
|
|
||||||
resources:
|
|
||||||
- namespace.yaml
|
|
||||||
- deployment.yaml
|
|
||||||
- service.yaml
|
|
||||||
- ingress.yaml
|
|
||||||
- pvc.yaml
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
version: 0.1.2_1
|
|
||||||
requires: []
|
|
||||||
defaultConfig:
|
|
||||||
namespace: community-search
|
|
||||||
externalDnsDomain: '{{ .cloud.domain }}'
|
|
||||||
domain: search.{{ .cloud.domain }}
|
|
||||||
tlsSecretName: wildcard-wild-cloud-tls
|
|
||||||
storage: 2Gi
|
|
||||||
selfUrl: 'https://search.{{ .cloud.domain }}'
|
|
||||||
selfName: 'Community Search'
|
|
||||||
defaultSecrets:
|
|
||||||
- key: adminToken
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
apiVersion: v1
|
|
||||||
kind: PersistentVolumeClaim
|
|
||||||
metadata:
|
|
||||||
name: community-search-data
|
|
||||||
spec:
|
|
||||||
accessModes:
|
|
||||||
- ReadWriteOnce
|
|
||||||
resources:
|
|
||||||
requests:
|
|
||||||
storage: {{ .storage }}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
apiVersion: v1
|
|
||||||
kind: Service
|
|
||||||
metadata:
|
|
||||||
name: community-search
|
|
||||||
spec:
|
|
||||||
type: ClusterIP
|
|
||||||
ports:
|
|
||||||
- name: http
|
|
||||||
port: 80
|
|
||||||
protocol: TCP
|
|
||||||
targetPort: 8080
|
|
||||||
selector:
|
|
||||||
component: web
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
name: coredns
|
|
||||||
is: coredns
|
|
||||||
description: DNS server for internal cluster DNS resolution
|
|
||||||
category: services
|
|
||||||
icon: https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/coredns.svg
|
|
||||||
latest: "v1"
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
# CoreDNS
|
|
||||||
|
|
||||||
- https://kubernetes.io/docs/concepts/services-networking/dns-pod-service/
|
|
||||||
- https://github.com/kubernetes/dns/blob/master/docs/specification.md
|
|
||||||
- https://coredns.io/
|
|
||||||
|
|
||||||
CoreDNS has the `kubernetes` plugin, so it returns all k8s service endpoints in well-known format.
|
|
||||||
|
|
||||||
All services and pods are registered in CoreDNS.
|
|
||||||
|
|
||||||
- <service-name>.<namespace>.svc.cluster.local
|
|
||||||
- <service-name>.<namespace>
|
|
||||||
- <service-name> (if in the same namespace)
|
|
||||||
|
|
||||||
- <pod-ipv4-address>.<namespace>.pod.cluster.local
|
|
||||||
- <pod-ipv4-address>.<service-name>.<namespace>.svc.cluster.local
|
|
||||||
|
|
||||||
Any query for a resource in the `internal.$DOMAIN` domain will be given the IP of the Traefik proxy. We expose the CoreDNS server in the LAN via MetalLB just for this capability.
|
|
||||||
|
|
||||||
## Default CoreDNS Configuration
|
|
||||||
|
|
||||||
This is the default CoreDNS configuration, for reference:
|
|
||||||
|
|
||||||
```txt
|
|
||||||
.:53 {
|
|
||||||
errors
|
|
||||||
health { lameduck 5s }
|
|
||||||
ready
|
|
||||||
log . { class error }
|
|
||||||
prometheus :9153
|
|
||||||
kubernetes cluster.local in-addr.arpa ip6.arpa {
|
|
||||||
pods insecure
|
|
||||||
fallthrough in-addr.arpa ip6.arpa
|
|
||||||
ttl 30
|
|
||||||
}
|
|
||||||
forward . /etc/resolv.conf { max_concurrent 1000 }
|
|
||||||
cache 30 {
|
|
||||||
disable success cluster.local
|
|
||||||
disable denial cluster.local
|
|
||||||
}
|
|
||||||
loop
|
|
||||||
reload
|
|
||||||
loadbalance
|
|
||||||
}
|
|
||||||
```
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
---
|
|
||||||
apiVersion: v1
|
|
||||||
kind: ConfigMap
|
|
||||||
metadata:
|
|
||||||
name: coredns-custom
|
|
||||||
namespace: kube-system
|
|
||||||
data:
|
|
||||||
# Custom server block for internal domains. All internal domains should
|
|
||||||
# resolve to the cluster proxy.
|
|
||||||
internal.server: |
|
|
||||||
{{ .internalDomain }} {
|
|
||||||
errors
|
|
||||||
cache 30
|
|
||||||
reload
|
|
||||||
template IN A {
|
|
||||||
match (.*)\.{{ .internalDomain | strings.ReplaceAll "." "\\." }}\.
|
|
||||||
answer "{{`{{ .Name }}`}} 60 IN A {{ .loadBalancerIp }}"
|
|
||||||
}
|
|
||||||
template IN AAAA {
|
|
||||||
match (.*)\.{{ .internalDomain | strings.ReplaceAll "." "\\." }}\.
|
|
||||||
rcode NXDOMAIN
|
|
||||||
}
|
|
||||||
}
|
|
||||||
# Custom override to set external resolvers.
|
|
||||||
external.override: |
|
|
||||||
forward . {{ .externalResolver }} {
|
|
||||||
max_concurrent 1000
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
apiVersion: kustomize.config.k8s.io/v1beta1
|
|
||||||
kind: Kustomization
|
|
||||||
|
|
||||||
resources:
|
|
||||||
- coredns-custom-config.yaml
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
version: v1.12.0-1
|
|
||||||
requires:
|
|
||||||
- name: metallb
|
|
||||||
defaultConfig:
|
|
||||||
namespace: kube-system
|
|
||||||
internalDomain: "{{ .cloud.internalDomain }}"
|
|
||||||
loadBalancerIp: "{{ .apps.metallb.loadBalancerIp }}"
|
|
||||||
externalResolver: "8.8.8.8"
|
|
||||||
deploy:
|
|
||||||
restartDeployments:
|
|
||||||
- coredns
|
|
||||||
waitForRollout:
|
|
||||||
name: coredns
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
name: crowdsec
|
|
||||||
is: crowdsec
|
|
||||||
description: CrowdSec security engine with Traefik bouncer for threat detection and rate limiting
|
|
||||||
category: services
|
|
||||||
icon: https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/crowdsec.svg
|
|
||||||
latest: "v1"
|
|
||||||
@@ -1,118 +0,0 @@
|
|||||||
# CrowdSec Security Service
|
|
||||||
|
|
||||||
CrowdSec is an open-source security engine that analyzes traffic patterns and blocks malicious actors. This service integrates CrowdSec with Traefik to provide automatic threat detection and rate limiting for all Wild Cloud ingresses.
|
|
||||||
|
|
||||||
## Components
|
|
||||||
|
|
||||||
- **CrowdSec Agent**: Analyzes traffic patterns, maintains decision lists, and connects to the CrowdSec threat intelligence network
|
|
||||||
- **Traefik Bouncer**: Integrates with Traefik via ForwardAuth to enforce CrowdSec decisions
|
|
||||||
- **Security Middlewares**: Traefik middleware for rate limiting and security headers
|
|
||||||
|
|
||||||
## Default Protection
|
|
||||||
|
|
||||||
After installation, **all ingresses are automatically protected** with:
|
|
||||||
- Threat detection (blocks known malicious IPs and attack patterns)
|
|
||||||
- Rate limiting (100 requests per minute per IP)
|
|
||||||
- Security headers (HSTS, XSS protection, content-type sniffing prevention)
|
|
||||||
|
|
||||||
## Configuration
|
|
||||||
|
|
||||||
Configuration is stored in `config.yaml` under `apps.crowdsec`:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
apps:
|
|
||||||
crowdsec:
|
|
||||||
rateLimitAverage: "100"
|
|
||||||
rateLimitBurst: "100"
|
|
||||||
```
|
|
||||||
|
|
||||||
## Secrets
|
|
||||||
|
|
||||||
Secrets are stored in `secrets.yaml` under `apps.crowdsec`:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
apps:
|
|
||||||
crowdsec:
|
|
||||||
agentPassword: <auto-generated>
|
|
||||||
bouncerApiKey: <auto-generated>
|
|
||||||
```
|
|
||||||
|
|
||||||
## Opting Out
|
|
||||||
|
|
||||||
To disable CrowdSec protection for a specific ingress (e.g., webhooks, health checks):
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
apiVersion: networking.k8s.io/v1
|
|
||||||
kind: Ingress
|
|
||||||
metadata:
|
|
||||||
annotations:
|
|
||||||
traefik.ingress.kubernetes.io/router.middlewares: ""
|
|
||||||
```
|
|
||||||
|
|
||||||
## Using Only Rate Limiting
|
|
||||||
|
|
||||||
To use rate limiting without threat detection:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
apiVersion: networking.k8s.io/v1
|
|
||||||
kind: Ingress
|
|
||||||
metadata:
|
|
||||||
annotations:
|
|
||||||
traefik.ingress.kubernetes.io/router.middlewares: crowdsec-rate-limit@kubernetescrd
|
|
||||||
```
|
|
||||||
|
|
||||||
## Monitoring
|
|
||||||
|
|
||||||
View active decisions (blocked IPs):
|
|
||||||
```bash
|
|
||||||
kubectl exec -n crowdsec deploy/crowdsec -- cscli decisions list
|
|
||||||
```
|
|
||||||
|
|
||||||
View registered bouncers:
|
|
||||||
```bash
|
|
||||||
kubectl exec -n crowdsec deploy/crowdsec -- cscli bouncers list
|
|
||||||
```
|
|
||||||
|
|
||||||
View alerts:
|
|
||||||
```bash
|
|
||||||
kubectl exec -n crowdsec deploy/crowdsec -- cscli alerts list
|
|
||||||
```
|
|
||||||
|
|
||||||
View metrics (Prometheus format):
|
|
||||||
```bash
|
|
||||||
kubectl port-forward -n crowdsec svc/crowdsec-lapi 6060:6060
|
|
||||||
curl http://localhost:6060/metrics
|
|
||||||
```
|
|
||||||
|
|
||||||
## Threat Intelligence
|
|
||||||
|
|
||||||
CrowdSec includes these detection collections:
|
|
||||||
- `crowdsecurity/traefik` - Traefik-specific detections
|
|
||||||
- `crowdsecurity/http-cve` - Known HTTP CVE exploits
|
|
||||||
- `crowdsecurity/whitelist-good-actors` - Whitelist for known good actors (search engines, etc.)
|
|
||||||
|
|
||||||
Enabled scenarios:
|
|
||||||
- HTTP probing and path traversal detection
|
|
||||||
- Bad user agent detection
|
|
||||||
- Sensitive file access attempts
|
|
||||||
- HTTP crawling detection
|
|
||||||
- SSH brute force (if exposed)
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
**Bouncer not connecting to agent:**
|
|
||||||
```bash
|
|
||||||
kubectl logs -n crowdsec deploy/traefik-crowdsec-bouncer
|
|
||||||
kubectl exec -n crowdsec deploy/crowdsec -- cscli bouncers list
|
|
||||||
```
|
|
||||||
|
|
||||||
**Check if middleware is applied:**
|
|
||||||
```bash
|
|
||||||
kubectl get middleware -n crowdsec
|
|
||||||
kubectl describe ingressroute -n <app-namespace> <route-name>
|
|
||||||
```
|
|
||||||
|
|
||||||
**View CrowdSec logs:**
|
|
||||||
```bash
|
|
||||||
kubectl logs -n crowdsec deploy/crowdsec
|
|
||||||
```
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
apiVersion: v1
|
|
||||||
kind: ConfigMap
|
|
||||||
metadata:
|
|
||||||
name: crowdsec-config
|
|
||||||
namespace: crowdsec
|
|
||||||
labels:
|
|
||||||
app: crowdsec
|
|
||||||
managedBy: kustomize
|
|
||||||
partOf: wild-cloud
|
|
||||||
data:
|
|
||||||
acquis.yaml: |
|
|
||||||
filenames:
|
|
||||||
- /var/log/containers/traefik-*_traefik_*.log
|
|
||||||
force_inotify: true
|
|
||||||
poll_without_inotify: true
|
|
||||||
labels:
|
|
||||||
type: containerd
|
|
||||||
program: traefik
|
|
||||||
profiles.yaml: |
|
|
||||||
name: default_ip_remediation
|
|
||||||
debug: false
|
|
||||||
filters:
|
|
||||||
- Alert.Remediation == true && Alert.GetScope() == "Ip"
|
|
||||||
decisions:
|
|
||||||
- type: ban
|
|
||||||
duration: 4h
|
|
||||||
on_success: break
|
|
||||||
---
|
|
||||||
name: default_range_remediation
|
|
||||||
debug: false
|
|
||||||
filters:
|
|
||||||
- Alert.Remediation == true && Alert.GetScope() == "Range"
|
|
||||||
decisions:
|
|
||||||
- type: ban
|
|
||||||
duration: 4h
|
|
||||||
scope: Range
|
|
||||||
on_success: break
|
|
||||||
postoverflows.yaml: |
|
|
||||||
# Post-overflow configuration for crowdsec
|
|
||||||
name: "rdns"
|
|
||||||
debug: false
|
|
||||||
filter: "evt.Enriched.IsoCode != ''"
|
|
||||||
# Add reverse DNS enrichment
|
|
||||||
@@ -1,134 +0,0 @@
|
|||||||
apiVersion: apps/v1
|
|
||||||
kind: Deployment
|
|
||||||
metadata:
|
|
||||||
name: crowdsec
|
|
||||||
namespace: crowdsec
|
|
||||||
labels:
|
|
||||||
app: crowdsec
|
|
||||||
managedBy: kustomize
|
|
||||||
partOf: wild-cloud
|
|
||||||
spec:
|
|
||||||
replicas: 1
|
|
||||||
strategy:
|
|
||||||
type: Recreate
|
|
||||||
selector:
|
|
||||||
matchLabels:
|
|
||||||
app: crowdsec
|
|
||||||
managedBy: kustomize
|
|
||||||
partOf: wild-cloud
|
|
||||||
template:
|
|
||||||
metadata:
|
|
||||||
labels:
|
|
||||||
app: crowdsec
|
|
||||||
managedBy: kustomize
|
|
||||||
partOf: wild-cloud
|
|
||||||
spec:
|
|
||||||
serviceAccountName: crowdsec
|
|
||||||
affinity:
|
|
||||||
podAffinity:
|
|
||||||
preferredDuringSchedulingIgnoredDuringExecution:
|
|
||||||
- weight: 100
|
|
||||||
podAffinityTerm:
|
|
||||||
labelSelector:
|
|
||||||
matchLabels:
|
|
||||||
app: traefik
|
|
||||||
topologyKey: kubernetes.io/hostname
|
|
||||||
securityContext:
|
|
||||||
runAsUser: 0
|
|
||||||
runAsNonRoot: false
|
|
||||||
fsGroup: 0
|
|
||||||
seccompProfile:
|
|
||||||
type: RuntimeDefault
|
|
||||||
containers:
|
|
||||||
- name: crowdsec
|
|
||||||
image: crowdsecurity/crowdsec:v1.7.8
|
|
||||||
env:
|
|
||||||
- name: COLLECTIONS
|
|
||||||
value: "crowdsecurity/traefik crowdsecurity/http-cve crowdsecurity/whitelist-good-actors crowdsecurity/iptables crowdsecurity/linux"
|
|
||||||
- name: PARSERS
|
|
||||||
value: "crowdsecurity/traefik-logs crowdsecurity/http-logs crowdsecurity/nginx-logs"
|
|
||||||
- name: SCENARIOS
|
|
||||||
value: "crowdsecurity/http-crawl-non_statics crowdsecurity/http-probing crowdsecurity/http-sensitive-files crowdsecurity/http-bad-user-agent crowdsecurity/http-path-traversal-probing crowdsecurity/ssh-bf crowdsecurity/ssh-slow-bf"
|
|
||||||
- name: POSTOVERFLOWS
|
|
||||||
value: "crowdsecurity/rdns crowdsecurity/cdn-whitelist"
|
|
||||||
- name: GID
|
|
||||||
value: "1000"
|
|
||||||
- name: LEVEL_TRACE
|
|
||||||
value: "false"
|
|
||||||
- name: LEVEL_DEBUG
|
|
||||||
value: "false"
|
|
||||||
- name: LEVEL_INFO
|
|
||||||
value: "true"
|
|
||||||
- name: AGENT_USERNAME
|
|
||||||
value: "kubernetes-cluster"
|
|
||||||
- name: AGENT_PASSWORD
|
|
||||||
valueFrom:
|
|
||||||
secretKeyRef:
|
|
||||||
name: crowdsec-agent-secret
|
|
||||||
key: password
|
|
||||||
- name: BOUNCER_KEY_traefik
|
|
||||||
valueFrom:
|
|
||||||
secretKeyRef:
|
|
||||||
name: crowdsec-secrets
|
|
||||||
key: bouncerApiKey
|
|
||||||
optional: true
|
|
||||||
ports:
|
|
||||||
- name: lapi
|
|
||||||
containerPort: 8080
|
|
||||||
protocol: TCP
|
|
||||||
- name: prometheus
|
|
||||||
containerPort: 6060
|
|
||||||
protocol: TCP
|
|
||||||
livenessProbe:
|
|
||||||
httpGet:
|
|
||||||
path: /health
|
|
||||||
port: 8080
|
|
||||||
initialDelaySeconds: 30
|
|
||||||
periodSeconds: 30
|
|
||||||
readinessProbe:
|
|
||||||
httpGet:
|
|
||||||
path: /health
|
|
||||||
port: 8080
|
|
||||||
initialDelaySeconds: 10
|
|
||||||
periodSeconds: 10
|
|
||||||
resources:
|
|
||||||
requests:
|
|
||||||
cpu: 50m
|
|
||||||
memory: 200Mi
|
|
||||||
limits:
|
|
||||||
cpu: 500m
|
|
||||||
memory: 512Mi
|
|
||||||
securityContext:
|
|
||||||
allowPrivilegeEscalation: false
|
|
||||||
capabilities:
|
|
||||||
drop:
|
|
||||||
- ALL
|
|
||||||
runAsNonRoot: false
|
|
||||||
volumeMounts:
|
|
||||||
- name: crowdsec-config
|
|
||||||
mountPath: /etc/crowdsec/acquis.yaml
|
|
||||||
subPath: acquis.yaml
|
|
||||||
readOnly: true
|
|
||||||
- name: crowdsec-config
|
|
||||||
mountPath: /etc/crowdsec/profiles.yaml
|
|
||||||
subPath: profiles.yaml
|
|
||||||
readOnly: true
|
|
||||||
- name: crowdsec-data
|
|
||||||
mountPath: /var/lib/crowdsec/data
|
|
||||||
- name: crowdsec-config-dir
|
|
||||||
mountPath: /etc/crowdsec/config
|
|
||||||
- name: varlog
|
|
||||||
mountPath: /var/log
|
|
||||||
readOnly: true
|
|
||||||
volumes:
|
|
||||||
- name: crowdsec-config
|
|
||||||
configMap:
|
|
||||||
name: crowdsec-config
|
|
||||||
- name: crowdsec-data
|
|
||||||
persistentVolumeClaim:
|
|
||||||
claimName: crowdsec-data
|
|
||||||
- name: crowdsec-config-dir
|
|
||||||
emptyDir: {}
|
|
||||||
- name: varlog
|
|
||||||
hostPath:
|
|
||||||
path: /var/log
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
apiVersion: v1
|
|
||||||
kind: Service
|
|
||||||
metadata:
|
|
||||||
name: crowdsec-lapi
|
|
||||||
namespace: crowdsec
|
|
||||||
labels:
|
|
||||||
app: crowdsec
|
|
||||||
managedBy: kustomize
|
|
||||||
partOf: wild-cloud
|
|
||||||
spec:
|
|
||||||
type: ClusterIP
|
|
||||||
selector:
|
|
||||||
app: crowdsec
|
|
||||||
managedBy: kustomize
|
|
||||||
partOf: wild-cloud
|
|
||||||
ports:
|
|
||||||
- name: lapi
|
|
||||||
port: 8080
|
|
||||||
targetPort: 8080
|
|
||||||
protocol: TCP
|
|
||||||
- name: prometheus
|
|
||||||
port: 6060
|
|
||||||
targetPort: 6060
|
|
||||||
protocol: TCP
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
apiVersion: kustomize.config.k8s.io/v1beta1
|
|
||||||
kind: Kustomization
|
|
||||||
namespace: "{{ .namespace }}"
|
|
||||||
labels:
|
|
||||||
- includeSelectors: true
|
|
||||||
pairs:
|
|
||||||
app: crowdsec
|
|
||||||
managedBy: kustomize
|
|
||||||
partOf: wild-cloud
|
|
||||||
resources:
|
|
||||||
- namespace.yaml
|
|
||||||
- serviceaccount.yaml
|
|
||||||
- configmap.yaml
|
|
||||||
- pvc.yaml
|
|
||||||
- crowdsec-deployment.yaml
|
|
||||||
- crowdsec-service.yaml
|
|
||||||
- middleware.yaml
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
version: v1.7.8-1
|
|
||||||
requires:
|
|
||||||
- name: longhorn
|
|
||||||
- name: traefik
|
|
||||||
defaultConfig:
|
|
||||||
namespace: crowdsec
|
|
||||||
rateLimitAverage: "100"
|
|
||||||
rateLimitBurst: "100"
|
|
||||||
defaultSecrets:
|
|
||||||
- key: agentPassword
|
|
||||||
- key: bouncerApiKey
|
|
||||||
deploy:
|
|
||||||
createSecrets:
|
|
||||||
- name: crowdsec-agent-secret
|
|
||||||
entries:
|
|
||||||
password: agentPassword
|
|
||||||
- name: crowdsec-bouncer-secret
|
|
||||||
entries:
|
|
||||||
api-key: bouncerApiKey
|
|
||||||
- name: crowdsec-bouncer-secret
|
|
||||||
namespace: traefik
|
|
||||||
entries:
|
|
||||||
api-key: bouncerApiKey
|
|
||||||
waitForRollout:
|
|
||||||
name: crowdsec
|
|
||||||
timeout: "120s"
|
|
||||||
@@ -1,94 +0,0 @@
|
|||||||
---
|
|
||||||
apiVersion: traefik.io/v1alpha1
|
|
||||||
kind: Middleware
|
|
||||||
metadata:
|
|
||||||
name: crowdsec-bouncer
|
|
||||||
namespace: crowdsec
|
|
||||||
labels:
|
|
||||||
app: crowdsec
|
|
||||||
managedBy: kustomize
|
|
||||||
partOf: wild-cloud
|
|
||||||
spec:
|
|
||||||
plugin:
|
|
||||||
bouncer:
|
|
||||||
crowdsecLapiScheme: http
|
|
||||||
crowdsecLapiHost: crowdsec-lapi.crowdsec.svc.cluster.local:8080
|
|
||||||
crowdsecLapiKeyFile: /etc/traefik/crowdsec/api-key
|
|
||||||
crowdsecMode: stream
|
|
||||||
updateIntervalSeconds: 15
|
|
||||||
defaultDecisionSeconds: 60
|
|
||||||
crowdsecAppsecEnabled: false
|
|
||||||
---
|
|
||||||
apiVersion: traefik.io/v1alpha1
|
|
||||||
kind: Middleware
|
|
||||||
metadata:
|
|
||||||
name: rate-limit
|
|
||||||
namespace: crowdsec
|
|
||||||
labels:
|
|
||||||
app: crowdsec
|
|
||||||
managedBy: kustomize
|
|
||||||
partOf: wild-cloud
|
|
||||||
spec:
|
|
||||||
rateLimit:
|
|
||||||
average: {{ .rateLimitAverage }}
|
|
||||||
burst: {{ .rateLimitBurst }}
|
|
||||||
period: 1m
|
|
||||||
---
|
|
||||||
apiVersion: traefik.io/v1alpha1
|
|
||||||
kind: Middleware
|
|
||||||
metadata:
|
|
||||||
name: security-headers
|
|
||||||
namespace: crowdsec
|
|
||||||
labels:
|
|
||||||
app: crowdsec
|
|
||||||
managedBy: kustomize
|
|
||||||
partOf: wild-cloud
|
|
||||||
spec:
|
|
||||||
headers:
|
|
||||||
browserXssFilter: true
|
|
||||||
contentTypeNosniff: true
|
|
||||||
forceSTSHeader: true
|
|
||||||
sslRedirect: true
|
|
||||||
stsIncludeSubdomains: true
|
|
||||||
stsPreload: true
|
|
||||||
stsSeconds: 31536000
|
|
||||||
addVaryHeader: true
|
|
||||||
accessControlAllowMethods:
|
|
||||||
- GET
|
|
||||||
- POST
|
|
||||||
- PUT
|
|
||||||
- DELETE
|
|
||||||
- OPTIONS
|
|
||||||
accessControlAllowOriginList:
|
|
||||||
- "*"
|
|
||||||
accessControlAllowHeaders:
|
|
||||||
- X-Requested-With
|
|
||||||
- Content-Type
|
|
||||||
- Authorization
|
|
||||||
- Date
|
|
||||||
accessControlMaxAge: 100
|
|
||||||
customRequestHeaders:
|
|
||||||
X-Forwarded-Proto: https
|
|
||||||
customResponseHeaders:
|
|
||||||
Server: ""
|
|
||||||
X-Frame-Options: SAMEORIGIN
|
|
||||||
X-Robots-Tag: noindex,nofollow,nosnippet,noarchive,notranslate,noimageindex
|
|
||||||
---
|
|
||||||
apiVersion: traefik.io/v1alpha1
|
|
||||||
kind: Middleware
|
|
||||||
metadata:
|
|
||||||
name: security-chain
|
|
||||||
namespace: crowdsec
|
|
||||||
labels:
|
|
||||||
app: crowdsec
|
|
||||||
managedBy: kustomize
|
|
||||||
partOf: wild-cloud
|
|
||||||
spec:
|
|
||||||
chain:
|
|
||||||
middlewares:
|
|
||||||
- name: security-headers
|
|
||||||
namespace: crowdsec
|
|
||||||
- name: rate-limit
|
|
||||||
namespace: crowdsec
|
|
||||||
- name: crowdsec-bouncer
|
|
||||||
namespace: crowdsec
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
apiVersion: v1
|
|
||||||
kind: Namespace
|
|
||||||
metadata:
|
|
||||||
name: "{{ .namespace }}"
|
|
||||||
labels:
|
|
||||||
app: crowdsec
|
|
||||||
managedBy: kustomize
|
|
||||||
partOf: wild-cloud
|
|
||||||
pod-security.kubernetes.io/enforce: privileged
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
apiVersion: v1
|
|
||||||
kind: PersistentVolumeClaim
|
|
||||||
metadata:
|
|
||||||
name: crowdsec-data
|
|
||||||
spec:
|
|
||||||
storageClassName: longhorn
|
|
||||||
accessModes:
|
|
||||||
- ReadWriteOnce
|
|
||||||
volumeMode: Filesystem
|
|
||||||
resources:
|
|
||||||
requests:
|
|
||||||
storage: 512Mi
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
apiVersion: v1
|
|
||||||
kind: ServiceAccount
|
|
||||||
metadata:
|
|
||||||
name: crowdsec
|
|
||||||
namespace: crowdsec
|
|
||||||
labels:
|
|
||||||
app: crowdsec
|
|
||||||
managedBy: kustomize
|
|
||||||
partOf: wild-cloud
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
name: cryptpad
|
|
||||||
is: cryptpad
|
|
||||||
description: CryptPad is an end-to-end encrypted collaboration suite with documents, spreadsheets, kanban boards, and more.
|
|
||||||
category: productivity
|
|
||||||
icon: https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/cryptpad.svg
|
|
||||||
latest: "2024"
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
# CryptPad
|
|
||||||
|
|
||||||
CryptPad is a privacy-first, end-to-end encrypted collaboration suite. Documents are encrypted in the browser before being stored — the server never sees plaintext content.
|
|
||||||
|
|
||||||
## Configuration
|
|
||||||
|
|
||||||
Key settings in `config.yaml`:
|
|
||||||
|
|
||||||
- **domain** - Main domain for CryptPad
|
|
||||||
- **sandboxDomain** - Sandbox subdomain for secure iframe isolation (default: `cryptpad-sandbox.{your-cloud-domain}`)
|
|
||||||
- **storage** - Persistent volume size (default: `2Gi`)
|
|
||||||
|
|
||||||
## Usage
|
|
||||||
|
|
||||||
No account is required to create documents. Just visit the app URL and start creating spreadsheets, code pads, presentations, or rich-text documents. Share the document URL with collaborators.
|
|
||||||
|
|
||||||
## Admin Setup
|
|
||||||
|
|
||||||
To access the admin panel, you need to register an account and then link it to the `adminKey` secret:
|
|
||||||
|
|
||||||
1. Register an account at the app URL
|
|
||||||
2. Go to your user settings and copy your **Public Signing Key**
|
|
||||||
3. The `adminKey` in `secrets.yaml` should match this key — it is pre-populated with a generated value, but you must replace it with your actual account's public signing key after registering
|
|
||||||
4. Once set, the **Admin** link will appear in the user menu
|
|
||||||
|
|
||||||
## Notes
|
|
||||||
|
|
||||||
- Both the main domain and the sandbox domain must be accessible — CryptPad uses the sandbox domain for secure iframe isolation, and the app will not function correctly if one is missing
|
|
||||||
- All encryption keys are generated client-side — if you lose your account passphrase, documents cannot be recovered
|
|
||||||
- The instance can be configured to require registration before creating documents (via the admin panel)
|
|
||||||
@@ -1,106 +0,0 @@
|
|||||||
apiVersion: apps/v1
|
|
||||||
kind: Deployment
|
|
||||||
metadata:
|
|
||||||
name: cryptpad
|
|
||||||
namespace: cryptpad
|
|
||||||
spec:
|
|
||||||
replicas: 1
|
|
||||||
strategy:
|
|
||||||
type: Recreate
|
|
||||||
selector:
|
|
||||||
matchLabels:
|
|
||||||
component: web
|
|
||||||
template:
|
|
||||||
metadata:
|
|
||||||
labels:
|
|
||||||
component: web
|
|
||||||
spec:
|
|
||||||
securityContext:
|
|
||||||
runAsUser: 0
|
|
||||||
runAsNonRoot: false
|
|
||||||
seccompProfile:
|
|
||||||
type: RuntimeDefault
|
|
||||||
initContainers:
|
|
||||||
- name: seed-config
|
|
||||||
image: cryptpad/cryptpad:version-2026.5.1
|
|
||||||
command:
|
|
||||||
- sh
|
|
||||||
- -c
|
|
||||||
- |
|
|
||||||
if [ ! -f /config-dest/config.example.js ]; then
|
|
||||||
cp /cryptpad/config/config.example.js /config-dest/config.example.js
|
|
||||||
fi
|
|
||||||
volumeMounts:
|
|
||||||
- name: cryptpad-config
|
|
||||||
mountPath: /config-dest
|
|
||||||
securityContext:
|
|
||||||
runAsNonRoot: false
|
|
||||||
runAsUser: 0
|
|
||||||
allowPrivilegeEscalation: false
|
|
||||||
capabilities:
|
|
||||||
drop: [ALL]
|
|
||||||
readOnlyRootFilesystem: false
|
|
||||||
seccompProfile:
|
|
||||||
type: RuntimeDefault
|
|
||||||
containers:
|
|
||||||
- name: cryptpad
|
|
||||||
image: cryptpad/cryptpad:version-2026.5.1
|
|
||||||
ports:
|
|
||||||
- name: http
|
|
||||||
containerPort: 3000
|
|
||||||
protocol: TCP
|
|
||||||
env:
|
|
||||||
- name: CPAD_CONF
|
|
||||||
value: /cryptpad/config/config.js
|
|
||||||
- name: CPAD_MAIN_DOMAIN
|
|
||||||
value: https://{{ .domain }}
|
|
||||||
- name: CPAD_SANDBOX_DOMAIN
|
|
||||||
value: https://{{ .sandboxDomain }}
|
|
||||||
- name: CPAD_TRUSTED_PROXY
|
|
||||||
value: "true"
|
|
||||||
resources:
|
|
||||||
limits:
|
|
||||||
cpu: 1000m
|
|
||||||
ephemeral-storage: 1Gi
|
|
||||||
memory: 1Gi
|
|
||||||
requests:
|
|
||||||
cpu: 50m
|
|
||||||
ephemeral-storage: 50Mi
|
|
||||||
memory: 256Mi
|
|
||||||
volumeMounts:
|
|
||||||
- name: cryptpad-data
|
|
||||||
mountPath: /cryptpad/data
|
|
||||||
- name: cryptpad-config
|
|
||||||
mountPath: /cryptpad/config
|
|
||||||
livenessProbe:
|
|
||||||
httpGet:
|
|
||||||
path: /
|
|
||||||
port: 3000
|
|
||||||
initialDelaySeconds: 90
|
|
||||||
timeoutSeconds: 5
|
|
||||||
periodSeconds: 15
|
|
||||||
failureThreshold: 6
|
|
||||||
readinessProbe:
|
|
||||||
httpGet:
|
|
||||||
path: /
|
|
||||||
port: 3000
|
|
||||||
initialDelaySeconds: 60
|
|
||||||
timeoutSeconds: 3
|
|
||||||
periodSeconds: 10
|
|
||||||
failureThreshold: 3
|
|
||||||
securityContext:
|
|
||||||
runAsNonRoot: false
|
|
||||||
runAsUser: 0
|
|
||||||
allowPrivilegeEscalation: false
|
|
||||||
capabilities:
|
|
||||||
drop: [ALL]
|
|
||||||
readOnlyRootFilesystem: false
|
|
||||||
seccompProfile:
|
|
||||||
type: RuntimeDefault
|
|
||||||
volumes:
|
|
||||||
- name: cryptpad-data
|
|
||||||
persistentVolumeClaim:
|
|
||||||
claimName: cryptpad-data
|
|
||||||
- name: cryptpad-config
|
|
||||||
emptyDir: {}
|
|
||||||
restartPolicy: Always
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
apiVersion: networking.k8s.io/v1
|
|
||||||
kind: Ingress
|
|
||||||
metadata:
|
|
||||||
name: 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:
|
|
||||||
ingressClassName: traefik
|
|
||||||
rules:
|
|
||||||
- host: {{ .domain }}
|
|
||||||
http:
|
|
||||||
paths:
|
|
||||||
- path: /
|
|
||||||
pathType: Prefix
|
|
||||||
backend:
|
|
||||||
service:
|
|
||||||
name: cryptpad
|
|
||||||
port:
|
|
||||||
number: 80
|
|
||||||
- host: {{ .sandboxDomain }}
|
|
||||||
http:
|
|
||||||
paths:
|
|
||||||
- path: /
|
|
||||||
pathType: Prefix
|
|
||||||
backend:
|
|
||||||
service:
|
|
||||||
name: cryptpad
|
|
||||||
port:
|
|
||||||
number: 80
|
|
||||||
tls:
|
|
||||||
- hosts:
|
|
||||||
- {{ .domain }}
|
|
||||||
- {{ .sandboxDomain }}
|
|
||||||
secretName: {{ .tlsSecretName }}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
apiVersion: kustomize.config.k8s.io/v1beta1
|
|
||||||
kind: Kustomization
|
|
||||||
namespace: cryptpad
|
|
||||||
labels:
|
|
||||||
- includeSelectors: true
|
|
||||||
pairs:
|
|
||||||
app: cryptpad
|
|
||||||
managedBy: kustomize
|
|
||||||
partOf: wild-cloud
|
|
||||||
resources:
|
|
||||||
- namespace.yaml
|
|
||||||
- deployment.yaml
|
|
||||||
- service.yaml
|
|
||||||
- ingress.yaml
|
|
||||||
- pvc.yaml
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user