Reorganized for new stable/waypoint versioning design.

This commit is contained in:
2026-05-24 18:28:47 +00:00
parent 945d2225a2
commit bc7a168851
352 changed files with 1264 additions and 294 deletions

View File

@@ -6,26 +6,65 @@ This guide is for contributors and maintainers who want to create or modify Wild
Wild Cloud apps are Kubernetes applications packaged as Kustomize configurations with standardized conventions for configuration management, secrets handling, and deployment.
## Directory Structure
Each app has a two-level structure: an `app.yaml` meta file at the root, and version-specific files inside `versions/`. Version directories are named by **slot** (typically the major version), not by the full version string. The actual version lives in `manifest.yaml` inside the slot.
```
myapp/
├── app.yaml # App identity, latest slot pointer, upgrade routing
└── versions/
├── 2/ # Current latest slot (manifest.yaml has version: 2.3.1)
│ ├── manifest.yaml # Version-specific config (requires, defaultConfig, etc.)
│ ├── kustomization.yaml
│ └── *.yaml # Kubernetes resource templates
└── 1/ # Waypoint slot (only if upgrade routing needs it)
├── manifest.yaml
├── kustomization.yaml
└── *.yaml
```
Most apps have **one** version directory. A second appears only when a waypoint is needed for upgrade routing.
## Required Files
Each app directory must contain:
1. **`manifest.yaml`** - App metadata and configuration schema
2. **`kustomization.yaml`** - Kustomize configuration with Wild Cloud labels
3. **Resource files** - Kubernetes manifests (deployments, services, ingresses, etc.)
1. **`app.yaml`** - App identity, latest slot pointer, and upgrade routing rules
2. **`versions/{slot}/manifest.yaml`** - Version-specific configuration schema
3. **`versions/{slot}/kustomization.yaml`** - Kustomize configuration with Wild Cloud labels
4. **`versions/{slot}/*.yaml`** - Kubernetes resource templates
## App Manifest (`manifest.yaml`)
## App Meta (`app.yaml`)
The manifest defines the app's metadata, dependencies, configuration schema, and secret requirements.
This is the contents of an example `manifest.yaml` file for an app named "immich":
The `app.yaml` file at the app root defines identity, display info, and upgrade routing. These fields are version-independent.
```yaml
name: immich
is: immich
description: Immich is a self-hosted photo and video backup solution that allows you to store, manage, and share your media files securely.
version: 1.0.0
icon: https://immich.app/assets/images/logo.png
latest: "1"
```
### App Meta Fields
| Field | Required | Description |
|-------|----------|-------------|
| `name` | Yes | App identifier (must match directory name) |
| `is` | Yes | Unique id for this app. Used for `requires` mapping |
| `description` | Yes | Brief app description shown in listings |
| `icon` | No | URL to app icon for UI display |
| `category` | No | Category (e.g., `infrastructure`) |
| `latest` | Yes | Slot name -- directory name under `versions/` (not a version string) |
| `upgrade` | No | Upgrade routing rules (see Upgrade Metadata below) |
## Version Manifest (`versions/{slot}/manifest.yaml`)
Each version slot contains a `manifest.yaml` with version-specific installation details: dependencies, configuration schema, and secret requirements.
```yaml
version: 1.135.3-1
requires:
- name: pg
alias: db # Use a different reference name in templates
@@ -46,21 +85,17 @@ defaultConfig:
defaultSecrets:
- key: password # Random value will be generated if empty
- key: dbUrl
default: "postgresql://{{ .app.db.user }}:{{ .secrets.dbPassword }}@{{ .app.db.host }}:{{ .app.db.port }}/{{ .app.db.name }}?pool=30" # Can reference secrets and config as long as they have been defined before this line. Reference config with {{ .app.? }} and secrets with {{ .secrets.? }}
default: "postgresql://{{ .app.db.user }}:{{ .secrets.dbPassword }}@{{ .app.db.host }}:{{ .app.db.port }}/{{ .app.db.name }}?pool=30"
requiredSecrets:
- db.password # References postgres app via 'db' alias
- redis.auth # References redis app via 'redis' name (no alias)
```
### Manifest Fields
### Version Manifest Fields
| Field | Required | Description |
|-------|----------|-------------|
| `name` | Yes | App identifier (must match directory name) |
| `is` | Yes | Unique id for this app. Used for `requires` mapping |
| `description` | Yes | Brief app description shown in listings |
| `version` | Yes | App version (see Versioning Convention below) |
| `icon` | No | URL to app icon for UI display |
| `requires` | No | List of dependency apps with optional aliases |
| `defaultConfig` | Yes | Default configuration values merged into operator's `config.yaml` |
| `defaultSecrets` | No | This app's secrets (no 'default' = auto-generated) |
@@ -85,6 +120,27 @@ Wild Cloud uses a two-part version scheme inspired by Debian packaging: `<upstre
The web UI uses version comparison to detect available updates. If the deployed version differs from the wild-directory version, operators see an update indicator and can apply it from the app detail panel.
### Slot Naming Convention
Version directory names are **slot names**, not version strings. The slot is a stable label; the actual version lives in `manifest.yaml` inside the slot.
**Rules:**
- Use the **major version** as the slot name (e.g., `1`, `2`, `5`, `v3`)
- Preserve the `v` prefix if the upstream project uses it (e.g., `v1` for cert-manager)
- **Never** put packaging revisions (`-1`, `-2`) in directory names
- **Never** put minor/patch versions in directory names unless creating a waypoint that needs to be distinct from another slot at the same major version
**Examples:**
| App | Slot name | Version in manifest |
|-----|-----------|-------------------|
| Ghost 5.118.1-2 | `5` | `5.118.1-2` |
| cert-manager v1.17.2 | `v1` | `v1.17.2` |
| Immich 1.135.3-1 | `1` | `1.135.3-1` |
| Traefik v3.4 | `v3` | `v3.4` |
When bumping versions (upstream or packaging), update files inside the existing slot. Only create a new directory when you need a new waypoint.
### Upgrade Metadata
Most apps can upgrade from any version to any other version directly — no special metadata is needed. The `upgrade` field is **optional** and only required when an app has breaking changes that need controlled upgrade paths.
@@ -93,76 +149,100 @@ Most apps can upgrade from any version to any other version directly — no spec
**When you need `upgrade:`** Apps with breaking database schema changes, incompatible config formats, or upstream requirements for sequential version upgrades (e.g., Discourse requires stepping through major versions).
#### The `upgrade` block
#### The `upgrade` block in `app.yaml`
Upgrade routing rules live in `app.yaml`, centralized for all versions. The system iteratively re-evaluates these rules after each waypoint step.
```yaml
# app.yaml
name: myapp
latest: "3"
upgrade:
from:
- version: ">=3.5.0" # Can upgrade directly from 3.5.x
- version: ">=3.4.0"
via: "3.5.3-1" # Must pass through 3.5.x first
via: "2" # Must pass through slot "2" first (a waypoint)
- version: "<3.4.0"
blocked: true
notes: "Requires sequential major upgrades. See upstream docs."
preUpgrade:
backup: required # "none", "recommended", or "required"
```
Note: `latest` and `via` are **slot names** (directory names), not version strings. The system reads the actual version from the manifest inside each slot.
Version-specific upgrade behavior (migrations, configMigrations) lives in the version's `manifest.yaml`:
```yaml
# versions/3/manifest.yaml
version: 3.6.0
upgrade:
migrations:
pre:
- migrations/pre-deploy.yaml # K8s Job YAML paths relative to app dir
- migrations/pre-deploy.yaml # K8s Job YAML paths relative to version dir
post:
- migrations/post-deploy.yaml
configMigrations:
oldKeyName: newKeyName # Renames config keys automatically
```
**Fields:**
**`app.yaml` upgrade fields:**
| Field | Description |
|-------|-------------|
| `from` | List of version constraint rules, evaluated in order (first match wins) |
| `from[].version` | Version constraint: `>=`, `>`, `<=`, `<`, `=`, or `>0` (matches any) |
| `from[].via` | Waypoint version in `.versions/` — upgrade must pass through this version first |
| `from[].via` | Waypoint slot name in `versions/` — upgrade must pass through this slot first |
| `from[].blocked` | If true, upgrade is blocked with an error message |
| `from[].notes` | Human-readable message shown when blocked or as context |
| `preUpgrade.backup` | Backup requirement: `"required"` blocks upgrade until backup is done, `"recommended"` shows a warning |
| `migrations.pre` | K8s Job YAMLs to run before deploying each version step |
| `migrations.post` | K8s Job YAMLs to run after deploying each version step |
**Version `manifest.yaml` upgrade fields:**
| Field | Description |
|-------|-------------|
| `migrations.pre` | K8s Job YAMLs to run before deploying this version step |
| `migrations.post` | K8s Job YAMLs to run after deploying this version step |
| `configMigrations` | Map of old config key → new config key for automatic renaming |
#### Waypoint versions (`.versions/` directory)
#### Waypoint versions
When an upgrade requires passing through an intermediate version, store that version's files in a `.versions/` subdirectory:
When an upgrade requires passing through an intermediate version, add that version's files as a new slot in the `versions/` directory alongside the latest:
```
myapp/
├── manifest.yaml # Latest version (e.g., 3.6.0)
── kustomization.yaml
├── *.yaml
└── .versions/
└── 3.5.3-1/ # Waypoint version
── manifest.yaml # version: 3.5.3-1 (with its own upgrade rules)
├── app.yaml # Routing rules + latest pointer
── versions/
├── 3/ # Latest slot (version: 3.6.0)
│ ├── manifest.yaml
│ ├── kustomization.yaml
── *.yaml
└── 2/ # Waypoint slot (version: 2.8.0)
├── manifest.yaml
├── kustomization.yaml
└── *.yaml
```
Each waypoint is a complete app package. The system computes a chain automatically — for example, upgrading from 3.4.0 to 3.6.0 might produce: `3.4.0 → 3.5.3-1 → 3.6.0`.
Each waypoint is a complete app package. The system computes a chain automatically — for example, upgrading from 2.3.0 to 3.6.0 might produce: `2.3.0 → 2.8.0 (slot "2") → 3.6.0 (slot "3")`.
**Creating a waypoint:**
**Creating a waypoint:** The current latest slot becomes the waypoint (leave it in place), then create a new slot for the new major version:
```bash
mkdir -p wild-directory/myapp/.versions
rsync -a --exclude='.versions' wild-directory/myapp/ wild-directory/myapp/.versions/3.5.3-1/
# Now update wild-directory/myapp/manifest.yaml to the new version + upgrade rules
# Current slot "2" (with version 2.8.0) stays as a waypoint
# Create the new slot for the next major version
mkdir -p wild-directory/myapp/versions/3
# ... add manifest.yaml, kustomization.yaml, *.yaml for 3.0.0 ...
# Update app.yaml: set latest to "3", add upgrade routing rules with via: "2"
```
#### Migration jobs
Migration jobs are K8s Job manifests that run database migrations or other one-time tasks during an upgrade step. They must be **idempotent** (safe to re-run) since a failed upgrade might be retried.
Place migration job files in the waypoint or app directory and reference them from the `migrations` field:
Place migration job files in the version slot directory and reference them from that version's `manifest.yaml`:
```yaml
# migrations/db-migrate.yaml
# versions/3/migrations/db-migrate.yaml
apiVersion: batch/v1
kind: Job
metadata:
@@ -177,17 +257,18 @@ spec:
command: ["bundle", "exec", "rake", "db:migrate"]
```
Each migration step belongs to the version that introduces the breaking change. If version 3.6.0 requires a schema migration, the migration lives in the 3.6.0 manifest (or its waypoint), not on 3.5.x.
Each migration step belongs to the version that introduces the breaking change. If version 3.6.0 requires a schema migration, the migration lives in the slot `3/` directory.
#### Example: simple app with waypoint
```yaml
# myapp/manifest.yaml (version 2.0.0)
version: 2.0.0
# myapp/app.yaml
name: myapp
latest: "2"
upgrade:
from:
- version: ">=1.0.0"
via: "1.0.0-1"
via: "1"
- version: "<1.0.0"
blocked: true
notes: "Versions before 1.0.0 are not supported"
@@ -195,7 +276,171 @@ upgrade:
backup: recommended
```
This creates a 2-step upgrade path: `1.x → 1.0.0-1 → 2.0.0`. The waypoint at `.versions/1.0.0-1/` has no `upgrade` block, so it accepts any version directly.
This creates a 2-step upgrade path: `1.x → slot "1" (e.g., version 1.0.0-1) slot "2" (e.g., version 2.0.0)`. The waypoint at `versions/1/` is a complete app package used as an intermediate step.
### Adding a New Version
When an upstream app releases a new version, you update the Wild Directory package to track it. The process depends on whether the new version has breaking changes.
#### Simple version bump (no breaking changes)
Most version updates are simple — update the container image tag, adjust any changed config, and update the version in `manifest.yaml`. No directory rename or `app.yaml` change needed.
```bash
# 1. Update files inside the existing slot
# - Bump version in manifest.yaml (e.g., 1.2.0 → 1.3.0)
# - Update container image tags in deployment YAMLs
# - Adjust defaultConfig if the new version adds/changes config
vi wild-directory/myapp/versions/1/manifest.yaml
vi wild-directory/myapp/versions/1/deployment.yaml
# 2. app.yaml doesn't change — latest still points to slot "1"
# 3. Test
wild app add myapp && wild app deploy myapp
```
The directory structure stays the same:
```
myapp/
├── app.yaml # latest: "1" (unchanged)
└── versions/
└── 1/
├── manifest.yaml # version: 1.3.0 (bumped)
└── *.yaml
```
#### Version bump with breaking changes (waypoint required)
When the new version can't safely upgrade from all previous versions — e.g., a database schema change requires stepping through an intermediate version — create a new slot for the new major version, keep the old slot as a waypoint, and add routing rules.
```bash
# 1. The current slot (2/) becomes a waypoint — leave it in place
# 2. Create a new slot for the new major version
mkdir -p wild-directory/myapp/versions/3
# ... add new version files (manifest.yaml, kustomization.yaml, *.yaml) ...
# 3. Update app.yaml: point latest to new slot, add upgrade routing rules
```
```yaml
# app.yaml
name: myapp
latest: "3"
upgrade:
from:
- version: ">=2.5.0" # 2.5.x can upgrade directly
- version: ">=2.0.0"
via: "2" # Older 2.x must pass through slot 2 first
- version: "<2.0.0"
blocked: true
notes: "Upgrade to 2.x first. See upstream migration guide."
preUpgrade:
backup: recommended
```
The resulting directory:
```
myapp/
├── app.yaml # latest: "3", upgrade routing rules
└── versions/
├── 3/ # New latest (manifest.yaml has version: 3.0.0)
│ ├── manifest.yaml
│ └── *.yaml
└── 2/ # Waypoint (manifest.yaml has version: 2.5.0)
├── manifest.yaml
└── *.yaml
```
#### Version bump with database migrations
When the new version requires a schema migration (e.g., `ALTER TABLE`, new indexes, data transformations), add migration job files to the slot directory and reference them from the version's `manifest.yaml`. Since this is a minor/patch update within the same major version, update files in-place in the existing slot.
```bash
# 1. Update files inside the existing slot
# - Bump version in manifest.yaml (e.g., 2.0.0 → 2.1.0)
# - Update container image tags in deployment YAMLs
vi wild-directory/myapp/versions/2/manifest.yaml
vi wild-directory/myapp/versions/2/deployment.yaml
# 2. Add migration job files
mkdir -p wild-directory/myapp/versions/2/migrations
```
Create the migration job:
```yaml
# versions/2/migrations/pre-deploy.yaml
apiVersion: batch/v1
kind: Job
metadata:
name: myapp-migrate-2-1-0
namespace: myapp
spec:
backoffLimit: 3
template:
spec:
restartPolicy: OnFailure
securityContext:
runAsNonRoot: true
runAsUser: 999
seccompProfile:
type: RuntimeDefault
containers:
- name: migrate
image: myapp:2.1.0
command: ["bundle", "exec", "rake", "db:migrate"]
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop: [ALL]
env:
- name: DATABASE_URL
valueFrom:
secretKeyRef:
name: myapp-secrets
key: dbUrl
```
Reference the migration in the version manifest:
```yaml
# versions/2/manifest.yaml
version: 2.1.0
upgrade:
migrations:
pre:
- migrations/pre-deploy.yaml
defaultConfig:
# ...
```
`app.yaml` doesn't change — `latest` still points to slot `"2"`.
Migration jobs must be **idempotent** — safe to re-run if an upgrade is retried after a partial failure. Use `CREATE IF NOT EXISTS`, `ALTER TABLE IF NOT EXISTS`, etc.
**Pre vs post migrations:**
- `pre` — runs before deploying the new version's manifests (schema changes that the new code needs)
- `post` — runs after deploying (data backfills, cleanup that the old code didn't need)
#### Version bump with config key renames
When a version renames config keys (e.g., `dbHost``db.host`), use `configMigrations` to automatically rename them during upgrade:
```yaml
# versions/2/manifest.yaml
version: 2.1.0
upgrade:
configMigrations:
dbHost: db.host
dbPort: db.port
dbName: db.name
defaultConfig:
db:
host: "{{ .apps.pg.host }}"
port: "5432"
name: myapp
```
The system renames the keys in the instance's `config.yaml` before recompiling templates with the new version.
### Dependency Configuration
@@ -666,9 +911,16 @@ labels:
Before submitting a new or modified app, verify:
- [ ] **Manifest**
- [ ] **App Meta (`app.yaml`)**
- [ ] `name` matches directory name
- [ ] All required fields present (`name`, `description`, `version`, `defaultConfig`)
- [ ] `latest` points to a valid version in `versions/`
- [ ] `description` present
- [ ] `upgrade` rules correct (if applicable)
- [ ] **Version Manifest (`versions/{slot}/manifest.yaml`)**
- [ ] `version` field present with full version string (e.g., `1.135.3-1`)
- [ ] Slot directory follows naming convention (major version, e.g., `1`, `v1`)
- [ ] All required fields present (`version`, `defaultConfig`)
- [ ] All template variables defined in `defaultConfig`
- [ ] `defaultSecrets` uses maps with 'key' and 'default' attributes
- [ ] `requiredSecrets` references use `<app-ref>.<key>` format

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

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

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

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

View File

@@ -1,8 +1,4 @@
name: cert-manager
is: cert-manager
description: X.509 certificate management for Kubernetes
version: v1.17.2
category: infrastructure
requires:
- name: traefik
defaultConfig:

5
coredns/app.yaml Normal file
View File

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

View File

@@ -1,8 +1,4 @@
name: coredns
is: coredns
description: DNS server for internal cluster DNS resolution
version: v1.12.0
category: infrastructure
requires:
- name: metallb
defaultConfig:

5
crowdsec/app.yaml Normal file
View File

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

View File

@@ -1,8 +1,4 @@
name: crowdsec
is: crowdsec
description: CrowdSec security engine with Traefik bouncer for threat detection and rate limiting
version: v1.7.8
category: infrastructure
requires:
- name: longhorn
- name: traefik

5
decidim/app.yaml Normal file
View File

@@ -0,0 +1,5 @@
name: decidim
is: decidim
description: Decidim is a participatory democracy framework for cities and organizations. Built in Ruby on Rails, it enables citizen participation through proposals, debates, and voting. Includes Sidekiq for background job processing.
icon: https://raw.githubusercontent.com/decidim/decidim/develop/logo.svg
latest: "0"

View File

@@ -1,8 +1,4 @@
name: decidim
is: decidim
description: Decidim is a participatory democracy framework for cities and organizations. Built in Ruby on Rails, it enables citizen participation through proposals, debates, and voting. Includes Sidekiq for background job processing.
version: 0.31.0-1
icon: https://raw.githubusercontent.com/decidim/decidim/develop/logo.svg
requires:
- name: postgres
installed_as: postgres

5
discourse/app.yaml Normal file
View File

@@ -0,0 +1,5 @@
name: discourse
is: discourse
description: Discourse is a modern, open-source discussion platform designed for online communities and forums.
icon: https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/discourse.svg
latest: "3"

View File

@@ -1,8 +1,4 @@
name: discourse
is: discourse
description: Discourse is a modern, open-source discussion platform designed for online communities and forums.
version: 3.5.3-1
icon: https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/discourse.svg
requires:
- name: postgres
- name: redis
@@ -41,4 +37,4 @@ defaultSecrets:
default: "postgres://{{ .app.db.user }}:{{ .secrets.dbPassword }}@{{ .app.db.host }}:{{ .app.db.port }}/{{ .app.db.name }}?sslmode=disable"
requiredSecrets:
- postgres.password
- redis.password
- redis.password

5
docker-registry/app.yaml Normal file
View File

@@ -0,0 +1,5 @@
name: docker-registry
is: docker-registry
description: Private Docker image registry for cluster
category: infrastructure
latest: "3"

View File

@@ -1,8 +1,4 @@
name: docker-registry
is: docker-registry
description: Private Docker image registry for cluster
version: "3.0.0"
category: infrastructure
requires:
- name: traefik
- name: cert-manager

13
e2e-test-app/app.yaml Normal file
View File

@@ -0,0 +1,13 @@
name: e2e-test-app
is: e2e-test-app
description: End-to-end test application for automated integration testing. Includes PVC and PostgreSQL dependency to exercise all backup strategies.
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 for upgrade"
preUpgrade:
backup: recommended

View File

@@ -1,6 +1,3 @@
name: e2e-test-app
is: e2e-test-app
description: End-to-end test application for automated integration testing. Includes PVC and PostgreSQL dependency to exercise all backup strategies.
version: 1.0.0-1
requires:
- name: postgres

View File

@@ -1,16 +1,4 @@
name: e2e-test-app
is: e2e-test-app
description: End-to-end test application for automated integration testing. Includes PVC and PostgreSQL dependency to exercise all backup strategies.
version: 2.0.0
upgrade:
from:
- version: ">=1.0.0"
via: "1.0.0-1"
- version: "<1.0.0"
blocked: true
notes: "Versions before 1.0.0 are not supported for upgrade"
preUpgrade:
backup: recommended
requires:
- name: postgres
defaultConfig:

4
example-admin/app.yaml Normal file
View File

@@ -0,0 +1,4 @@
name: example-admin
is: example
description: An example application that is deployed with internal-only access.
latest: "1"

View File

@@ -1,6 +1,3 @@
name: example-admin
is: example
description: An example application that is deployed with internal-only access.
version: 1.0.0
defaultConfig:
namespace: example-admin

4
example-app/app.yaml Normal file
View File

@@ -0,0 +1,4 @@
name: example-app
is: example
description: An example application that is deployed with public access.
latest: "1"

View File

@@ -1,6 +1,3 @@
name: example-app
is: example
description: An example application that is deployed with public access.
version: 1.0.0
defaultConfig:
namespace: example-app

5
externaldns/app.yaml Normal file
View File

@@ -0,0 +1,5 @@
name: externaldns
is: externaldns
description: Automatically configures DNS records for services
category: infrastructure
latest: "v0"

View File

@@ -1,9 +1,5 @@
name: externaldns
is: externaldns
description: Automatically configures DNS records for services
version: v0.13.4
deploymentName: external-dns
category: infrastructure
requires:
- name: cert-manager
defaultConfig:

5
ghost/app.yaml Normal file
View File

@@ -0,0 +1,5 @@
name: ghost
is: ghost
description: Ghost is a powerful app for new-media creators to publish, share, and grow a business around their content.
icon: https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png/ghost.png
latest: "5"

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