# 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** 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 <=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.