Compare commits
1 Commits
1b3200a5f1
...
mailu
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9b0c56f720 |
818
ADDING-APPS.md
818
ADDING-APPS.md
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,5 @@
|
||||
- @README.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).
|
||||
- @ADDING-APPS.md
|
||||
|
||||
## 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,18 +0,0 @@
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: akaunting
|
||||
namespace: akaunting
|
||||
spec:
|
||||
ingressClassName: traefik
|
||||
rules:
|
||||
- host: {{ .domain }}
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: akaunting
|
||||
port:
|
||||
number: 80
|
||||
@@ -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,29 +0,0 @@
|
||||
version: 3.1.21-2
|
||||
requires:
|
||||
- name: mysql
|
||||
- name: smtp
|
||||
defaultConfig:
|
||||
namespace: akaunting
|
||||
domain: akaunting.{{ .cloud.domain }}
|
||||
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,17 +0,0 @@
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: aptly
|
||||
spec:
|
||||
ingressClassName: traefik
|
||||
rules:
|
||||
- host: {{ .domain }}
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: aptly
|
||||
port:
|
||||
number: 80
|
||||
@@ -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,9 +0,0 @@
|
||||
version: 1.6.2-4
|
||||
defaultConfig:
|
||||
namespace: aptly
|
||||
domain: aptly.{{ .cloud.domain }}
|
||||
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,18 +0,0 @@
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: baserow
|
||||
namespace: {{ .namespace }}
|
||||
spec:
|
||||
ingressClassName: traefik
|
||||
rules:
|
||||
- host: {{ .domain }}
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: baserow
|
||||
port:
|
||||
number: 80
|
||||
@@ -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,21 +0,0 @@
|
||||
version: 2.2.2-3
|
||||
requires:
|
||||
- name: postgres
|
||||
- name: redis
|
||||
defaultConfig:
|
||||
namespace: baserow
|
||||
domain: baserow.{{ .cloud.domain }}
|
||||
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,18 +0,0 @@
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: bookstack
|
||||
namespace: {{ .namespace }}
|
||||
spec:
|
||||
ingressClassName: traefik
|
||||
rules:
|
||||
- host: {{ .domain }}
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: bookstack
|
||||
port:
|
||||
number: 80
|
||||
@@ -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,24 +0,0 @@
|
||||
version: 26.05.1-3
|
||||
requires:
|
||||
- name: mysql
|
||||
- name: smtp
|
||||
defaultConfig:
|
||||
namespace: bookstack
|
||||
domain: bookstack.{{ .cloud.domain }}
|
||||
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,18 +0,0 @@
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: bookwyrm
|
||||
namespace: bookwyrm
|
||||
spec:
|
||||
ingressClassName: traefik
|
||||
rules:
|
||||
- host: {{ .domain }}
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: bookwyrm
|
||||
port:
|
||||
number: 80
|
||||
@@ -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,28 +0,0 @@
|
||||
version: 0.8.7-2
|
||||
requires:
|
||||
- name: postgres
|
||||
- name: redis
|
||||
- name: smtp
|
||||
defaultConfig:
|
||||
namespace: bookwyrm
|
||||
domain: bookwyrm.{{ .cloud.domain }}
|
||||
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,18 +0,0 @@
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: chamilo
|
||||
namespace: {{ .namespace }}
|
||||
spec:
|
||||
ingressClassName: traefik
|
||||
rules:
|
||||
- host: {{ .domain }}
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: chamilo
|
||||
port:
|
||||
number: 80
|
||||
@@ -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,27 +0,0 @@
|
||||
version: 1.11.28-2
|
||||
requires:
|
||||
- name: mysql
|
||||
- name: smtp
|
||||
defaultConfig:
|
||||
namespace: chamilo
|
||||
domain: chamilo.{{ .cloud.domain }}
|
||||
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,17 +0,0 @@
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: community-search
|
||||
spec:
|
||||
ingressClassName: traefik
|
||||
rules:
|
||||
- host: {{ .domain }}
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: community-search
|
||||
port:
|
||||
number: 80
|
||||
@@ -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,9 +0,0 @@
|
||||
version: 0.1.2_1-1
|
||||
defaultConfig:
|
||||
namespace: community-search
|
||||
domain: search.{{ .cloud.domain }}
|
||||
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,142 +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: "{{ if .agentUsername }}{{ .agentUsername }}{{ else }}kubernetes-cluster{{ end }}"
|
||||
- name: AGENT_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: crowdsec-agent-secret
|
||||
key: password
|
||||
- name: BOUNCER_KEY_traefik
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: crowdsec-secrets
|
||||
key: bouncerApiKey
|
||||
optional: true
|
||||
{{ if .centralLapiUrl }}
|
||||
- name: DISABLE_LOCAL_API
|
||||
value: "true"
|
||||
- name: LOCAL_API_URL
|
||||
value: "{{ .centralLapiUrl }}"
|
||||
{{ end }}
|
||||
ports:
|
||||
- name: lapi
|
||||
containerPort: 8080
|
||||
protocol: TCP
|
||||
- name: prometheus
|
||||
containerPort: 6060
|
||||
protocol: TCP
|
||||
{{ if not .centralLapiUrl }}
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /health
|
||||
port: 8080
|
||||
initialDelaySeconds: 30
|
||||
periodSeconds: 30
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /health
|
||||
port: 8080
|
||||
initialDelaySeconds: 10
|
||||
periodSeconds: 10
|
||||
{{ end }}
|
||||
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,28 +0,0 @@
|
||||
version: v1.7.8-4
|
||||
requires:
|
||||
- name: longhorn
|
||||
- name: traefik
|
||||
defaultConfig:
|
||||
namespace: crowdsec
|
||||
rateLimitAverage: "100"
|
||||
rateLimitBurst: "100"
|
||||
centralLapiUrl: ""
|
||||
agentUsername: ""
|
||||
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: {{ if .centralLapiUrl }}{{ strings.TrimPrefix "http://" .centralLapiUrl }}{{ else }}crowdsec-lapi.crowdsec.svc.cluster.local:8080{{ end }}
|
||||
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,28 +0,0 @@
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: cryptpad
|
||||
namespace: cryptpad
|
||||
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
|
||||
@@ -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