forked from mirror/coolify-cli
Compare commits
91 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1bac524008 | |||
| da3479c65a | |||
| 6e80c95183 | |||
| a896d5f991 | |||
| c6445f9c80 | |||
| d3b6ebffd9 | |||
| 483fa075f7 | |||
| 92a45c6b0d | |||
| dea323aa5e | |||
| c71f5ef491 | |||
| eb854da7c8 | |||
| 286917cd95 | |||
| 93e3e626e3 | |||
| bfb07a5f04 | |||
| c9b6df3171 | |||
| 346320504c | |||
| 8341802c88 | |||
| 298bd28cd1 | |||
| 0380aac05b | |||
| 8d30292fb6 | |||
| 0980f1e363 | |||
| 3a500014b2 | |||
| 6dc6e0770a | |||
| f70d779d0b | |||
| 901097e541 | |||
| ef8e740476 | |||
| 95250d32a0 | |||
| 7c89c3a6c8 | |||
| e5e33b46ae | |||
| 8f358b3115 | |||
| 84fec60a60 | |||
| 0df8f401e1 | |||
| 594e274b6b | |||
| b126ed52c4 | |||
| 4d9b21a662 | |||
| 6f1b38cf84 | |||
| 1e67e5e3f5 | |||
| 67e53195bb | |||
| 76ce28e65f | |||
| ab44a5a107 | |||
| b38f6178b5 | |||
| f4d8049867 | |||
| 1dfbc8cb7b | |||
| ab951a561c | |||
| bc36a44f2c | |||
| d3489a49ce | |||
| e2f0b47579 | |||
| 8e35e61aa0 | |||
| 0197333e41 | |||
| 5e2b3d08db | |||
| b0eb8dbd15 | |||
| c292ba8b42 | |||
| 4ae6065ecf | |||
| 80bc511fd8 | |||
| b2da3013d2 | |||
| 28d54b0df9 | |||
| c6378a8280 | |||
| ce0e8fe9cd | |||
| 528b1359aa | |||
| eabce9a8e1 | |||
| f43cd16f6f | |||
| e49daeea95 | |||
| ccf578e537 | |||
| cad379eefb | |||
| 53ab7b315c | |||
| 8a7d2c20af | |||
| fcd1a01fb7 | |||
| f67411de2c | |||
| 146ce7a7b0 | |||
| b661576fc1 | |||
| 0872e48283 | |||
| 303fad333b | |||
| 8ee7ec4c0d | |||
| 98f40f03dc | |||
| 28521a2ca0 | |||
| dd4b271faf | |||
| cdc5a1e732 | |||
| 7e3639b41a | |||
| 6bd783dc8a | |||
| 2ac1d0f869 | |||
| f4c4c962ff | |||
| 801c2e0b3c | |||
| ea4bec7492 | |||
| 7e59cd76c3 | |||
| 81b9e9cdd0 | |||
| fe01e8f9b8 | |||
| daa2a4cdcb | |||
| 1703fd2e52 | |||
| 333ff3c504 | |||
| 0daae657fb | |||
| ea3236672b |
@@ -28,6 +28,7 @@ jobs:
|
||||
workdir: ./
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
HOMEBREW_TAP_GITHUB_TOKEN: ${{ secrets.HOMEBREW_TAP_GITHUB_TOKEN }}
|
||||
|
||||
update-version:
|
||||
needs: [release-cli]
|
||||
|
||||
@@ -34,6 +34,26 @@ jobs:
|
||||
- name: Run tests
|
||||
run: go test -v -race -cover ./...
|
||||
|
||||
llms-txt:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: 'go.mod'
|
||||
|
||||
- name: Regenerate llms.txt
|
||||
run: go run ./coolify docs llms
|
||||
|
||||
- name: Check uncommitted changes
|
||||
run: git diff --exit-code llms.txt llms-full.txt
|
||||
|
||||
- if: failure()
|
||||
run: echo "::error::llms.txt or llms-full.txt is out of date. Run 'go run ./coolify docs llms' and commit the changes."
|
||||
|
||||
go-mod-tidy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
@@ -52,4 +72,4 @@ jobs:
|
||||
run: git diff --exit-code
|
||||
|
||||
- if: failure()
|
||||
run: echo "::error::Check failed, please run 'go mod tidy' and commit the changes."
|
||||
run: echo "::error::Check failed, please run 'go mod tidy' and commit the changes."
|
||||
|
||||
@@ -42,6 +42,14 @@ linters:
|
||||
exhaustive:
|
||||
default-signifies-exhaustive: true
|
||||
|
||||
revive:
|
||||
rules:
|
||||
- name: var-naming
|
||||
arguments:
|
||||
- []
|
||||
- []
|
||||
- - skipPackageNameChecks: true
|
||||
|
||||
staticcheck:
|
||||
checks: ["all", "-ST1005", "-S1016"]
|
||||
|
||||
|
||||
+16
-1
@@ -36,4 +36,19 @@ archives:
|
||||
|
||||
format_overrides:
|
||||
- goos: windows
|
||||
formats: [zip]
|
||||
formats: [zip]
|
||||
|
||||
brews:
|
||||
- name: coolify-cli
|
||||
repository:
|
||||
owner: coollabsio
|
||||
name: homebrew-coolify-cli
|
||||
token: "{{ .Env.HOMEBREW_TAP_GITHUB_TOKEN }}"
|
||||
directory: Formula
|
||||
homepage: "https://coolify.io"
|
||||
description: "CLI tool for interacting with the Coolify API"
|
||||
license: "MIT"
|
||||
install: |
|
||||
bin.install "coolify"
|
||||
test: |
|
||||
system "#{bin}/coolify", "version"
|
||||
@@ -174,6 +174,332 @@ type Resource struct {
|
||||
- UUIDs are more secure (don't expose database sequencing)
|
||||
- Coolify API uses UUIDs as the primary resource identifier
|
||||
|
||||
## `coolify init` — WireGuard mesh + Podman bootstrap (alpha, v5)
|
||||
|
||||
**This subcommand is an outlier**: it does NOT talk to the Coolify API. It SSHes into remote hosts and installs/configures WireGuard, Podman, the bridge network, and a firewall scaffold. It's the fleet-provisioning command tree consumed by the v5 control plane (coold), split into three intent-scoped subcommands — `bootstrap`, `extend`, `upgrade` — plus a read-only `plan`. Coolify's backend calls `extend` when the operator adds a server and `upgrade` when agent versions move; direct-CLI operators run `bootstrap` for the initial install.
|
||||
|
||||
### What it does
|
||||
|
||||
- Establishes a full-mesh WireGuard overlay across N hosts.
|
||||
- Each host gets a mgmt IP `/32` from `--wg-mgmt-pool` (default `100.64.0.0/16`, RFC 6598 CGNAT) on `wg0`.
|
||||
- For every namespace (see **Namespaces** below; default: just `default`), each host gets a container subnet `/<container-prefix>` carved from the shared `--container-pool` (default `10.210.0.0/16`, default prefix `/24`). Each namespace is owned by its own Podman bridge named `coolify-<namespace>-mesh` (default → `coolify-default-mesh`).
|
||||
- Installs Podman + enables `podman.socket` + creates every namespace bridge + installs `coolify-mesh-fw.service` (always; required for v5 runtime).
|
||||
- Downloads and installs coold + corrosion (v5 control-plane agents; always) from GitHub releases on each remote host. Release tag controlled by `--coold-version` / `--corrosion-version` (default `nightly`). coold receives the full namespace list via `COOLD_NAMESPACES=<ns>:<network>:<gateway-ip>,...` so it can bind DNS and track rules per namespace.
|
||||
- Installs default-deny firewall scaffold by default — host-global `COOLIFY-INTRA` + empty `COOLIFY-ALLOW` chains, with FORWARD jumps for every namespace subnet. Use `--skip-default-deny` to fall back to blanket-allow (mode A) for testing.
|
||||
|
||||
### Architecture (why this layout)
|
||||
|
||||
The mgmt pool and container pool are **separate** so the Podman bridge can own the full container `/24` without conflicting with `wg0`. Pattern adopted from uncloud (psviderski/uncloud).
|
||||
|
||||
WG config per host (e.g. host A with two namespaces `default` + `alpha`):
|
||||
```
|
||||
[Interface]
|
||||
Address = 100.64.0.1/32 # mgmt IP, NOT in container pool
|
||||
ListenPort = 51820
|
||||
PrivateKey = <gen on host>
|
||||
|
||||
[Peer] # one per other host
|
||||
PublicKey = <peer pubkey>
|
||||
AllowedIPs = 100.64.0.2/32, 10.210.1.0/24, 10.220.1.0/24 # mgmt + every namespace subnet
|
||||
Endpoint = <peer SSH ip>:51820
|
||||
```
|
||||
|
||||
Critical: `AllowedIPs` lists the peer's full per-namespace `/24`s so the kernel routes each namespace subnet via `wg0`. Namespace order is deterministic (sorted) so `wg0.conf` is stable across re-runs.
|
||||
|
||||
Every namespace bridge `coolify-<ns>-mesh` is created with `--disable-dns --label io.coolify.managed=true --label io.coolify.namespace=<ns>` — the bridge gateway `:53` is reserved for coold's embedded cluster DNS (see CONTROL_PLANE.md §5). Pre-alpha networks with `dns_enabled=true` are detected on re-run and recreated.
|
||||
|
||||
Firewall service (`coolify-mesh-fw.service`) installed unconditionally and stays host-global:
|
||||
- POSTROUTING `RETURN` rule per namespace subnet prevents Podman MASQUERADE from rewriting container egress source on `wg0`.
|
||||
- Mode A (`--skip-default-deny`): blanket FORWARD ACCEPT for every namespace subnet.
|
||||
- Mode B (default): `COOLIFY-INTRA` chain (ESTABLISHED accept → `COOLIFY-ALLOW` → DROP), FORWARD jumps for `-s/-d <ns-subnet>` per namespace. v5 control plane (coold) fills `COOLIFY-ALLOW`.
|
||||
|
||||
### Cross-host vs intra-host firewall
|
||||
|
||||
- **Cross-host default-deny WORKS** — those packets cross interfaces (wg0 ↔ bridge) and traverse iptables FORWARD. Empirically verified.
|
||||
- **Intra-host (same bridge) is NOT enforced** — Linux + netavark + Ubuntu 24.04 quirk: bridge L2 traffic bypasses iptables FORWARD even with `bridge-nf-call-iptables=1`. v5 control plane handles intra-host isolation via per-app podman networks (`--opt isolate=true`), not iptables.
|
||||
|
||||
### Subcommands
|
||||
|
||||
Three intent-scoped subcommands. Each runs the same probe → plan → filter → apply → verify pipeline; what differs is the filter applied to the action list. The filter lives in `internal/wireguard/intent.go` (`ValidateIntent` + `filterByIntent`). Suppressed actions surface on `plan.Skipped` so the preview shows operators what would have fired and why.
|
||||
|
||||
```bash
|
||||
coolify init plan --servers IP1,IP2,IP3 --ssh-key KEY [--intent bootstrap|extend|upgrade]
|
||||
coolify init bootstrap --servers IP1,IP2,IP3 --ssh-key KEY [--yes]
|
||||
coolify init extend --servers IP1,IP2,IP3,IP4 --new-hosts IP4 --ssh-key KEY [--allow-replace]
|
||||
coolify init upgrade --servers IP1,IP2,IP3 --ssh-key KEY --coold-version v1.7.0 [--allow-nightly]
|
||||
```
|
||||
|
||||
- `plan` is read-only: probes, reconstructs, shows what the selected intent would execute. Default intent is `bootstrap` (broadest preview).
|
||||
- `bootstrap` is the first-time install — every applicable action on every host. Keeps the interactive alpha gate (unless `--yes`, `COOLIFY_NON_INTERACTIVE=1`, or non-TTY). 2-phase parallel: phase 1 = install + keygen + podman + socket + IP forward. Re-probe. Phase 2 = write WG config + enable/reload service + create podman networks + install firewall + install coold/corrosion (+ scheduler on `--central` + builder on `--builder-hosts`).
|
||||
- `extend` adds the hosts listed in `--new-hosts` (required subset of `--servers`) to an existing mesh. Brand-new hosts get the full first-time install. Existing hosts get **only peer-refresh** actions (WG config rewrite picks up the new peer's mgmt `/32` + namespace `/24`s in `AllowedIPs`, corrosion peer list refreshed, firewall unit reinstalled only when the namespace list changed). Agent binaries are not re-downloaded on existing hosts. Destructive-replace actions (podman network recreate because of `dns_enabled=true` drift or a subnet/label mismatch) are **blocked on existing hosts** unless `--allow-replace` is passed. The corrosion-schema wipe-DB branch is never unlocked — resolve schema drift with `upgrade` on a fresh schema.
|
||||
- `upgrade` bumps agent binaries across every host. Only binary-fetch actions (`install-coold`, `install-corrosion`, `install-scheduler`, `install-builder`) and their follow-up service restarts (`install-coold-service`, `install-corrosion-service`, `install-scheduler-service`) run. WG config, podman networks, firewall rules, and the corrosion schema stay untouched. `nightly` tags are rejected by default (they force a re-install every run); pin a version with `--coold-version=v1.7.0` etc. or pass `--allow-nightly`.
|
||||
|
||||
`extend` and `upgrade` skip the interactive alpha gate because they are the paths the Coolify backend calls in production. `bootstrap` keeps the gate for direct-CLI runs.
|
||||
|
||||
### Flags (defined in `cmd/init/flags.go`)
|
||||
|
||||
Persistent (inherited by `plan`, `bootstrap`, `extend`, `upgrade`):
|
||||
|
||||
| Flag | Default | Purpose |
|
||||
|---|---|---|
|
||||
| `--servers` | required | comma-separated SSH IPs (full list of every host in the mesh, including already-converged ones on extend/upgrade) |
|
||||
| `--ssh-key` | required | path to SSH private key |
|
||||
| `--ssh-passphrase-prompt` | false | prompt for key passphrase (also reads `COOLIFY_SSH_PASSPHRASE` env) |
|
||||
| `--ssh-user` | `root` | SSH user |
|
||||
| `--ssh-port` | `22` | SSH port |
|
||||
| `--wg-mgmt-pool` | `100.64.0.0/16` | mgmt IP pool, /32 per host on wg0 |
|
||||
| `--container-pool` | `10.210.0.0/16` | container pool, carved per host |
|
||||
| `--container-prefix` | `24` | per-host container subnet prefix |
|
||||
| `--wg-interface` | `wg0` | WG iface name on remote |
|
||||
| `--wg-listen-port` | `51820` | WG UDP port |
|
||||
| `--namespaces` | `default` | comma-separated list of namespaces. Each creates its own `coolify-<ns>-mesh` bridge with its own per-host `/24` carved from `--container-pool` |
|
||||
| `--skip-default-deny` | false | skip the default-deny firewall scaffold. Default installs COOLIFY-INTRA + empty COOLIFY-ALLOW chains for cross-host deny |
|
||||
| `--coold-version` | `nightly` | release tag to download for coold (e.g. `nightly`, `v1.2.3`). `nightly` always re-downloads on every run; pinned tags skip when the on-host version marker matches. Fetched from `coollabsio/coold` GitHub releases on the remote host. |
|
||||
| `--corrosion-version` | `nightly` | release tag to download for corrosion. Same drift semantics as `--coold-version`. Fetched from `coollabsio/corrosion` GitHub releases. |
|
||||
| `--scheduler-version` | `nightly` | release tag for scheduler (only fetched when `--central` is set). |
|
||||
| `--corrosion-gossip-port` | `8787` | corrosion SWIM gossip port (bound to wg0 mgmt IP) |
|
||||
| `--corrosion-api-port` | `8080` | corrosion HTTP API port (bound to 127.0.0.1) |
|
||||
| `--central` | `""` | SSH address of the central VM (must be in `--servers`). When set, scheduler installs there and per-host JWTs are pushed to every peer. Empty = skip scheduler setup. |
|
||||
| `--enable-builder` | true | cluster-wide shorthand: enable the builder capability on every host (requires `--central`). Ignored when `--builder-hosts` is set. |
|
||||
| `--builder-hosts` | `[]` | explicit subset of `--servers` to enroll with the builder capability. Takes precedence over `--enable-builder`. |
|
||||
| `--builder-capacity` | `2` | concurrent builds per host (`COOLD_BUILDER_CAPACITY`) |
|
||||
| `--builder-cpu-quota` | `200%` | systemd CPUQuota per build subprocess |
|
||||
| `--builder-memory-max` | `2G` | systemd MemoryMax per build subprocess |
|
||||
| `--builder-timeout-secs` | `1800` | wall-clock cap per build |
|
||||
| `--concurrency` | `10` | parallel SSH connections |
|
||||
| `--ssh-timeout` | `30s` | SSH connect timeout |
|
||||
| `--yes`, `-y` | false | skip alpha confirmation prompt (honored by `bootstrap`; `extend` and `upgrade` always skip it) |
|
||||
|
||||
Subcommand-local:
|
||||
|
||||
| Flag | Subcommand | Default | Purpose |
|
||||
|---|---|---|---|
|
||||
| `--intent` | `plan` | `bootstrap` | preview filter: `bootstrap` (all actions), `extend` (treat `--new-hosts` as fresh, existing hosts peer-refresh only), `upgrade` (version bumps only) |
|
||||
| `--new-hosts` | `extend` | required | comma-separated subset of `--servers` that is brand-new this run. Only these hosts receive the full install; all other hosts get peer-refresh only. |
|
||||
| `--allow-replace` | `extend` | false | unlock destructive-replace actions on existing hosts (e.g. recreating a drifted podman bridge). Off by default — drifted existing hosts surface as skipped actions. |
|
||||
| `--allow-nightly` | `upgrade` | false | permit `nightly` as a version tag. Off by default because `nightly` re-installs every run instead of only when the pinned version changes. |
|
||||
|
||||
### Namespaces
|
||||
|
||||
Namespaces are the tenancy unit the mesh carries. A namespace is:
|
||||
|
||||
- **A podman bridge network** on every host, named `coolify-<ns>-mesh` (default → `coolify-default-mesh`), labelled `io.coolify.managed=true` + `io.coolify.namespace=<ns>`.
|
||||
- **A per-host `/<container-prefix>` subnet** carved from the shared `--container-pool`. Allocation is deterministic across `(namespace, host)` pairs so re-runs reproduce the same layout.
|
||||
- **A DNS view** coold serves on that bridge's gateway: records take the shape `<container>.<namespace>.coolify.internal`. Bare `<container>.coolify.internal` is deliberately NXDOMAIN — callers must fully qualify.
|
||||
- **A firewall tenant**: allow-rule cids hash the namespace in, so identical src/dst/proto/port tuples in different namespaces are distinct rules. iptables chains stay host-global (`COOLIFY-INTRA` / `COOLIFY-ALLOW`) for alpha; namespace isolation comes from separate podman bridges + namespace-qualified allow rules.
|
||||
|
||||
Config knobs:
|
||||
|
||||
- `coolify init bootstrap --namespaces default,alpha,beta` provisions every namespace on every host in one pass. Re-running `bootstrap` (or running `extend` with the new namespace in `--namespaces`) installs only the new per-namespace assets (bridge + FORWARD jumps + WG `AllowedIPs` refresh + firewall unit reinstall because of unit-hash drift). Removing a namespace is **not** idempotent today — destroy/rebuild is the documented path for alpha.
|
||||
- `coolify firewall --namespace <ns>` (default `default`) scopes allow/revoke/list/containers to one namespace. `list` and `containers` also accept `--all-namespaces` for cross-namespace observability.
|
||||
- coold receives the full namespace list via `COOLD_NAMESPACES=<ns>:<network>:<gateway-ip>,…` (see `internal/services/coold.go`). DNS binds and rule storage derive from that.
|
||||
|
||||
Deliberately deferred (tracked in the active plan):
|
||||
|
||||
- Per-namespace iptables chains. Host-global keeps kernel state simple; revisit when a user asks for kernel-enforced per-namespace default-deny.
|
||||
- Cross-namespace L2 bridging. Different namespaces = different podman bridges = no intra-host connectivity. Cross-namespace flows require explicit allow rules + dual-attach containers.
|
||||
- Wildcard / DNS search domain. Start strict; loosen once real workloads push back.
|
||||
|
||||
### Code layout
|
||||
|
||||
- `cmd/common/` — flag structs shared between `init` and `firewall`.
|
||||
- `sshmesh.go` — `SSHMeshFlags` + `BindSSHMeshFlags`, `BuildSSHClient`, `ParseSSHTimeout`, `ResolvePassphrase`, `Validate`.
|
||||
- `meshnet.go` — `MeshNetFlags` (namespaces + container pool/prefix) + `BindMeshNetMultiFlags` (init-style: many namespaces) + `BindMeshNetSingleFlags` (firewall-style: one namespace) + `PodmanNetworkFor(ns)` + `ValidateNamespaces` / `ValidateNamespace` (DNS-label check).
|
||||
- `cmd/init/` — Cobra subcommands (`init`, `init plan`, `init bootstrap`, `init extend`, `init upgrade`).
|
||||
- `flags.go` — `InitFlags` struct (embeds `common.SSHMeshFlags` + `common.MeshNetFlags`) + bindings + SSH client builder. Carries subcommand-scoped knobs: `NewHosts`, `AllowReplace`, `AllowNightly`, `Intent`.
|
||||
- `desired.go` — `buildDesired(flags)`: flag → `wireguard.DesiredMesh`. One source of truth so every subcommand produces the same struct modulo `Intent`.
|
||||
- `plan.go` — `runPlan`: validate, `buildDesired`, `ValidateIntent`, build SSH client, probe, `BuildPlan`, render actions + skipped rows. `--intent` flag selects the filter for preview.
|
||||
- `apply.go` — `runApply(ctx, cmd, flags, applyOptions)`: shared pipeline for all three executing subcommands. `applyOptions{SkipAlphaGate, Header}` differentiates them.
|
||||
- `bootstrap.go` — `NewBootstrapCommand`: sets `flags.Intent = "bootstrap"`, keeps alpha gate.
|
||||
- `extend.go` — `NewExtendCommand`: binds `--new-hosts` + `--allow-replace`, validates subset, sets `flags.Intent = "extend"`, skips alpha gate.
|
||||
- `upgrade.go` — `NewUpgradeCommand`: binds `--allow-nightly`, sets `flags.Intent = "upgrade"`, skips alpha gate.
|
||||
- `init.go` — registers the four subcommands; package is `initcmd` (not `init` — Go reserved keyword).
|
||||
- `internal/wireguard/` — pure Go logic (no SSH, no I/O — `apply.go` is the SSH boundary).
|
||||
- `state.go` — `ServerState` (with `Namespaces map[string]*NamespaceServerState`), `MeshState`, `DesiredMesh` (with `Intent`, `NewHosts`, `AllowReplace`, `AllowNightly`). `Intent` enum: `IntentBootstrap` (zero value), `IntentExtend`, `IntentUpgrade`.
|
||||
- `intent.go` — `ValidateIntent` (pre-plan invariants: extend needs `NewHosts ⊆ Hosts`; upgrade rejects nightly unless opted-in), `filterByIntent` (mutates `plan.Actions` + `plan.Skipped`), `categorize` (action → `catSafeAlways` / `catPeerRefresh` / `catDestructiveReplace` / `catVersionBump` / `catWipeDB` / `catCorrosionSchemaFirstWrite`).
|
||||
- `subnet.go` — `Allocate` (per `(namespace, host)` pair: `map[ns]map[host]*net.IPNet`) + `AllocateMgmtIPs` (per-host /32) + conflict detection. Provably stable: adding host D never shifts A/B/C.
|
||||
- `config.go` — `RenderConfig` + `WriteConfigCommand` for `wg0.conf` (Address /32, AllowedIPs = mgmt /32 + every peer namespace subnet, deterministic order).
|
||||
- `reconstruct.go` — `Probe` (per-namespace podman network inspect + label read) + `Reconstruct` (parallel) + `parseConfigFile`.
|
||||
- `plan.go` — `BuildPlan` (pure: desired - actual = actions, then `ValidateIntent` + `filterByIntent`). `Plan.Skipped []SkippedAction` carries intent-filtered entries with reasons. Podman actions carry a `Namespace` field; one create/recreate action per namespace per host.
|
||||
- `apply.go` — `ApplyMesh` (2-phase fanout via `internal/ssh/fanout.go`). Phase 2 loops over namespaces per host; firewall unit takes the union of every namespace subnet.
|
||||
- `firewall.go` — `coolify-mesh-fw.service` unit generator (two-mode: blanket allow vs default-deny, one FORWARD/POSTROUTING pair per namespace subnet).
|
||||
- `internal/ssh/` — generic SSH runner + parallel `ForEachServer[T]`.
|
||||
- `test/fixtures/wg/wg0.conf` — fixture for parser tests.
|
||||
|
||||
### Key invariants
|
||||
|
||||
- **Reconstructed-only state**: no local state file. Every run re-probes via SSH. State lives on the hosts.
|
||||
- **Idempotent**: re-running with no changes produces an empty plan. State drift triggers re-converge (e.g. flipping `--skip-default-deny` reinstalls the firewall service; bumping `--coold-version` re-fetches the binary).
|
||||
- **Intent gates destruction**: `extend` on an existing host never re-downloads agents, never wipes the corrosion DB, and never recreates a drifted podman bridge without `--allow-replace`. Suppressed actions surface on `plan.Skipped` with a reason. `upgrade` never touches WG / podman / firewall / schema.
|
||||
- **Private key never leaves host**: WG private key generated on remote via `wg genkey`; config written using `$PRIVKEY=$(cat /etc/wireguard/privatekey)` shell expansion.
|
||||
- **Atomic config writes**: write to `.conf.tmp`, `mv` to `.conf`.
|
||||
- **Non-disruptive WG reload**: service-restart uses `systemctl restart wg-quick@wg0 || wg syncconf wg0 <(wg-quick strip wg0)` — the fallback updates peers in kernel without tearing the tunnel.
|
||||
- **Stable subnet assignment**: existing valid assignments are preserved across re-runs; adding a host never shifts existing `(namespace, host)` `/24`s. Only invalid (out-of-pool, wrong prefix, duplicate, network/broadcast IP) trigger reassignment with a warning.
|
||||
- **Firewall reinstall is content-hashed**: `coolify-mesh-fw.service` is only rewritten when its expected unit text differs from the on-host sha256, so noisy restarts don't happen on converged re-runs.
|
||||
|
||||
### Future control plane (v5 / coold)
|
||||
|
||||
`coolify init` owns **fleet provisioning**: first-time bootstrap, adding hosts, and bumping agent versions — each via its own intent-scoped subcommand. Day-to-day container/firewall ops are the v5 control plane's job. See `CONTROL_PLANE.md` for the full spec, including:
|
||||
|
||||
- coold per-host agent (REST API on wg0, bind-mounts `/run/podman/podman.sock`, NEVER exposes socket on TCP).
|
||||
- Service discovery via embedded DNS in coold + Corrosion-replicated sqlite (no env injection, no container restart on backend movement).
|
||||
- Allow-rule persistence via coold's own DB + `iptables-restore --noflush` or `nft -f` batch (NOT systemd dropins per rule — doesn't scale).
|
||||
- Cross-host allow rules go on the **destination host** (where DROP would otherwise fire).
|
||||
|
||||
When extending `coolify init`, defer dynamic responsibilities to coold. Bootstrap stays narrow: scaffold the mesh, install runtime, prep firewall chains. `extend` and `upgrade` stay narrower still: add peers and bump binaries, nothing else. coold owns everything that changes at runtime.
|
||||
|
||||
### Testing init
|
||||
|
||||
Tests live in `internal/wireguard/*_test.go` and `cmd/init/*_test.go`:
|
||||
|
||||
```bash
|
||||
go test ./internal/wireguard/... ./cmd/init/... -v
|
||||
```
|
||||
|
||||
Use the SSH `Runner` interface for mocking — never open real SSH connections in unit tests. `internal/ssh/fanout.go` is generic; reuse for any per-server fanout.
|
||||
|
||||
## `coolify firewall` — cross-host allow-rule client (alpha, v5)
|
||||
|
||||
**This subcommand is the second outlier** (alongside `coolify init`): it does NOT talk to the Coolify API. It is a thin REST client of the **coold** per-host agent installed by `coolify init` (coold install is unconditional as of v1.6.3). `allow` / `revoke` / `list` all go through coold's REST API (`/api/v1/firewall/allow`). `containers` stays SSH+podman because coold has no container surface yet. Transport is **SSH-bounce**: the laptop running the CLI is not a mesh peer, so it SSHes into the target host and the shell there runs `curl "http://$(wg0-mgmt-ip):8443/api/v1/firewall/..."` against coold on localhost.
|
||||
|
||||
coold owns all kernel-rule + persistence logic (iptables/nft backend detection, `/etc/coolify/allow.rules` snapshot, `coolify-mesh-allow.service`). The CLI never writes iptables or systemd units directly.
|
||||
|
||||
### What it does
|
||||
|
||||
- Discovers containers on the selected namespace's `coolify-<ns>-mesh` bridge (default `coolify-default-mesh`) across all listed hosts (SSH + `podman ps`). `--all-namespaces` fans out across every managed namespace.
|
||||
- `POST /api/v1/firewall/allow` / `DELETE /api/v1/firewall/allow/{id}` / `GET /api/v1/firewall/allow` against coold on the host that **owns the destination IP** (per `CONTROL_PLANE.md §3`: rules go on dst host).
|
||||
- Per-host bearer tokens fetched on demand from `/etc/coolify/api-token` (see `EnsureCooldAPITokenCommand` in `internal/services/coold.go` — each host generates its own random 32-byte hex token at install time).
|
||||
- Idempotent at the coold level: POST of an identical tuple returns the existing id; DELETE of an unknown id returns 204.
|
||||
|
||||
### Subcommands
|
||||
|
||||
```bash
|
||||
coolify firewall containers [--namespace <ns>] [--all-namespaces] # discover containers on coolify-<ns>-mesh (SSH+podman)
|
||||
coolify firewall list [--namespace <ns>] [--all-namespaces] # GET /allow on every host and merge
|
||||
coolify firewall allow --namespace <ns> --from <ref> --to <ref> [--port N] [--proto tcp|udp] [--bidirectional]
|
||||
coolify firewall revoke --namespace <ns> --from <ref> --to <ref> [--port N] [--proto tcp|udp] [--bidirectional]
|
||||
```
|
||||
|
||||
`<ref>` accepts: container name (unique across mesh), `host:name`, short 12-char podman ID, or raw IP.
|
||||
|
||||
### Flags
|
||||
|
||||
Persistent (inherited from `cmd/common/sshmesh.go` — shared with `coolify init`):
|
||||
|
||||
| Flag | Default | Purpose |
|
||||
|---|---|---|
|
||||
| `--servers` | required | comma-separated SSH IPs |
|
||||
| `--ssh-key` | required | SSH private key path |
|
||||
| `--ssh-passphrase-prompt` | false | prompt for passphrase (also `COOLIFY_SSH_PASSPHRASE` env) |
|
||||
| `--ssh-user` | `root` | SSH user |
|
||||
| `--ssh-port` | `22` | SSH port |
|
||||
| `--concurrency` | `10` | parallel SSH connections |
|
||||
| `--ssh-timeout` | `30s` | SSH connect timeout |
|
||||
|
||||
Firewall-specific persistent:
|
||||
|
||||
| Flag | Default | Purpose |
|
||||
|---|---|---|
|
||||
| `--namespace` | `default` | mesh namespace the command operates on. Derives podman network `coolify-<ns>-mesh` for container discovery and is sent to coold as part of every rule payload / list query |
|
||||
| `--all-namespaces` | false | applies to `list` + `containers` only — fans out across every namespace the mesh carries (`allow` / `revoke` still require a specific `--namespace`) |
|
||||
| `--coold-port` | `8443` | TCP port coold's REST API listens on (wg0 mgmt IP). Must match `COOLD_API_BIND` emitted by `internal/services/coold.go` |
|
||||
| `--coold-token` | `""` | **optional** bearer-token override (also reads `COOLIFY_COOLD_TOKEN` env). When empty (the default), the CLI SSHes each host and reads `/etc/coolify/api-token` — tokens are per-host, not centrally shared |
|
||||
|
||||
Allow/revoke local:
|
||||
|
||||
| Flag | Default | Purpose |
|
||||
|---|---|---|
|
||||
| `--from` | required | source container ref or raw IP |
|
||||
| `--to` | required | destination container ref or raw IP |
|
||||
| `--port` | `0` | dst port (0 = any) |
|
||||
| `--proto` | `tcp` | `tcp`, `udp`, or `""` (any — requires `--port=0`) |
|
||||
| `--bidirectional` | false | also install reverse rule on src host (needed for server-initiated flows; conntrack ESTABLISHED handles client-initiated replies) |
|
||||
|
||||
### Rule identity
|
||||
|
||||
`cid = sha256(namespace|src|dst|proto|port)[:12]`. Namespace defaults to `"default"` on the wire when empty so legacy coold peers keep working. coold computes the cid server-side on POST and returns it in the body; the CLI surfaces it as the user-facing rule ID in `firewall list` output and uses it for DELETE. Stable across calls: `revoke --namespace … --from … --to …` rebuilds the same cid and matches. Identical src/dst/proto/port tuples in different namespaces produce different cids and are managed independently.
|
||||
|
||||
### SSH-bounce transport
|
||||
|
||||
Every coold call is wrapped in a single SSH command that first discovers the host's own wg0 mgmt IP and then curls coold on localhost:
|
||||
|
||||
```sh
|
||||
# emitted for POST / DELETE (hard-fails if wg0 missing — no coold means nothing to apply to)
|
||||
MGMT=$(ip -4 -o addr show wg0 2>/dev/null | awk '{print $4}' | cut -d/ -f1)
|
||||
test -n "$MGMT" || { echo "coold mgmt IP (wg0) not found on $(hostname)" >&2; exit 1; }
|
||||
curl -fsS --max-time 10 \
|
||||
-H 'Authorization: Bearer <token>' \
|
||||
-H 'Content-Type: application/json' \
|
||||
-X POST -d '{"src":"...","dst":"...","proto":"tcp","port":80}' \
|
||||
"http://$MGMT:8443/api/v1/firewall/allow"
|
||||
```
|
||||
|
||||
`list` uses the **soft** variant: missing wg0 emits `[]` and exits 0 so a partially-deployed mesh doesn't abort the whole fanout.
|
||||
|
||||
### Per-host token resolution
|
||||
|
||||
`cmd/firewall/helpers.go::tokenResolver` hands out tokens per host with a sync.Mutex-guarded cache:
|
||||
|
||||
- `--coold-token` (or `COOLIFY_COOLD_TOKEN` env) set → closure returns the override for every host; no SSH fetch.
|
||||
- Otherwise → first access per host SSHes `cat /etc/coolify/api-token`, caches the result for the rest of the run. Token-fetch failures surface as a `ServerResult.Err` on the owning host (won't poison others).
|
||||
|
||||
The cache is scoped to one CLI invocation — no on-disk caching.
|
||||
|
||||
### Persistence across reboots
|
||||
|
||||
**coold owns this now.** On every API mutate, coold regenerates `/etc/coolify/allow.rules` (flat `iptables-save` fragment) and the companion `coolify-mesh-allow.service` restores it on boot via `iptables-restore --noflush`. Pre-coold persistence scaffolding was removed from the CLI when it migrated to REST — same file format, different writer.
|
||||
|
||||
### Code layout
|
||||
|
||||
- `cmd/common/sshmesh.go` — shared SSH/mesh flag struct `SSHMeshFlags` (+ `BindSSHMeshFlags`, `BuildSSHClient`, `ParseSSHTimeout`, `ResolvePassphrase`, `Validate`).
|
||||
- `cmd/common/meshnet.go` — shared namespace plumbing: `MeshNetFlags` (namespaces + container pool/prefix), `BindMeshNetMultiFlags` (init: many), `BindMeshNetSingleFlags` (firewall: one), `PodmanNetworkFor(ns)`, `ValidateNamespaces` / `ValidateNamespace`.
|
||||
- `cmd/firewall/` — Cobra layer.
|
||||
- `firewall.go` — `NewFirewallCommand()` parent + subcommand registration.
|
||||
- `flags.go` — `FirewallFlags` embeds `common.SSHMeshFlags` + `Namespace` + `AllNamespaces` + `CooldToken` + `CooldPort` + `WGInterface`. `PodmanNetworkName()` derives the bridge name from `Namespace`. `ResolveCooldToken()` returns the override or `""` (meaning "fetch per host").
|
||||
- `allow.go` — `allowRevokeFlags`, `emitAllowRevoke` (discover → resolve → build rule with namespace → coold POST/DELETE per rule, resolving token per host).
|
||||
- `list.go` — `emitList` fans out `CooldList` via `CooldListAll`, forwarding the namespace query param (or omitting it under `--all-namespaces`).
|
||||
- `containers.go` — `containers` subcommand (still SSH+podman). Without `--all-namespaces`: single bridge. With `--all-namespaces`: SSH per host for `podman network ls --filter label=io.coolify.managed=true`, then per-namespace fanout.
|
||||
- `resolve.go` — `resolveEndpoint(ref, []Container)` (name / host:name / short-id / raw IP).
|
||||
- `helpers.go` — `discoverAllViaPkg`, `discoverAcrossNamespaces`, `discoverNamespacesOnHosts`, `tokenResolver` (per-host cached bearer-token closure).
|
||||
- `internal/firewall/` — REST client + discovery.
|
||||
- `coold_client.go` — `FetchCooldToken`, `CooldApply`, `CooldRevoke`, `CooldList(… , namespace)`, `CooldListAll(… , namespace)`. `buildCurlAllow/Revoke/List`, `shellSingleQuote`, `mgmtIPScript` / `mgmtIPScriptSoft`. `cooldRulePayload` carries `namespace` (required on wire; empty normalized to `"default"`).
|
||||
- `discover.go` — `Container` (with `Namespace`), `discoverScript`, `DiscoverContainers(… , namespace, network)`, `DiscoverAll`, `DiscoverAllNamespaces` (fan-out over a `networkFor(ns)` mapper).
|
||||
- `rule.go` — `AllowRule` (with `Namespace`), `ComputeID(namespace, src, dst, proto, port)`.
|
||||
- `internal/models/firewall.go` — table/JSON row types (`ContainerRow`, `AllowRuleRow`) both now carry a `Namespace` column.
|
||||
- `internal/services/coold.go` — `EnsureCooldAPITokenCommand` (installer writes `/etc/coolify/api-token`, mode 0600), `CooldServiceUnit` emits `COOLD_API_BIND=<mgmt-ip>:8443` + `COOLD_API_TOKEN_FILE=/etc/coolify/api-token` + `COOLD_NAMESPACES=<ns>:<network>:<gateway-ip>,…`.
|
||||
|
||||
### Key invariants
|
||||
|
||||
- **Destination-host ownership**: every rule lives on exactly one host — the one whose `/24` contains the destination IP. `--bidirectional` adds the reverse rule on the src host.
|
||||
- **coold is the only kernel writer**: the CLI never runs `iptables` or touches `/etc/coolify/allow.rules` directly. Everything flows through coold's REST API.
|
||||
- **Per-host tokens by default**: each coold generates its own random token at install. `--coold-token` is an escape hatch for homogeneous test / CI environments, not the common path.
|
||||
- **Bidirectional is opt-in**: conntrack ESTABLISHED accept (installed by `coolify-mesh-fw.service`) handles reply packets for client-initiated flows. Only set `--bidirectional` for protocols that actually open new connections in both directions.
|
||||
- **Rule identity is hash, not UUID**: coold computes it server-side so CLI and any future writer agree on the same id for the same tuple.
|
||||
- **Namespace is part of identity**: `cid = sha256(namespace|src|dst|proto|port)[:12]`. Same tuple in two namespaces = two distinct rules. Empty-string namespace normalizes to `"default"` on the wire so legacy coold peers keep working.
|
||||
- **Transient token exposure on remote `/proc`**: `curl -H "Authorization: Bearer $TOKEN"` is visible in `/proc/<curl-pid>/cmdline` for the ~ms lifetime of the call, root-only. Acceptable for alpha; TLS + stdin-fed tokens are a follow-up.
|
||||
|
||||
### Testing firewall
|
||||
|
||||
```bash
|
||||
go test ./internal/firewall/... ./cmd/firewall/... ./cmd/common/... -v
|
||||
```
|
||||
|
||||
Uses `fakeCooldRunner` / `cmdFakeRunner` pattern (substring → canned stdout map) — same as `cmd/init/plan_test.go`. All SSH calls mocked at the `ssh.Runner` boundary; no real SSH in unit tests. Token-fetch, mgmt-IP script, curl shape, JSON payload, and error propagation are all covered.
|
||||
|
||||
### End-to-end flow (verified on real hosts)
|
||||
|
||||
After `coolify init bootstrap --servers A,B --namespaces default,alpha ...` ran (coold must be up):
|
||||
|
||||
1. Baseline cross-host traffic DROPped by `COOLIFY-INTRA` in every namespace.
|
||||
2. `coolify firewall containers --servers A,B --ssh-key KEY --all-namespaces` → discovery table columned by namespace.
|
||||
3. `coolify firewall allow --servers A,B --ssh-key KEY --namespace default --from client --to web --port 80` → CLI SSH-fetches each host's token, POSTs to coold (body includes `"namespace":"default"`), traffic flows in the `default` namespace only.
|
||||
4. Same tuple with `--namespace alpha` → separate cid, separate rule; doesn't affect `default`.
|
||||
5. `coolify firewall list --servers A,B --ssh-key KEY --all-namespaces` → merged rules across every namespace on every host with their coold-assigned `cid:…` IDs.
|
||||
6. `coolify firewall revoke --namespace <ns> …` → coold DELETE, rule gone, traffic DROPped again.
|
||||
7. Reboot → `coolify-mesh-allow.service` (installed by coold) restores from `/etc/coolify/allow.rules`.
|
||||
|
||||
Add `--coold-token <hex>` only when every host was bootstrapped with the same token (CI fixtures, homogeneous test clusters).
|
||||
|
||||
## Testing Requirements
|
||||
|
||||
**CRITICAL: All code changes MUST include tests. This is non-negotiable.**
|
||||
|
||||
@@ -0,0 +1,759 @@
|
||||
# Coolify v5 Control Plane — Server Management Spec
|
||||
|
||||
This document lists everything the Coolify v5 control plane must implement on top of the host provisioning performed by the `coolify init` subcommand tree (`bootstrap` for first install, `extend` for adding hosts, `upgrade` for bumping agent versions) to fully manage a fleet of mesh-connected hosts.
|
||||
|
||||
## Architecture overview
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
│ Coolify central UI / API │
|
||||
│ - Multi-tenant (cloud) or 1-tenant │
|
||||
│ (self-hosted); same binary │
|
||||
│ - WSS / gRPC bidi stream listener │
|
||||
│ on :443 (public) │
|
||||
│ - Routes commands by host_id │
|
||||
└────────────────────▲────────────────┘
|
||||
│ outbound TLS :443 (WSS / gRPC bidi)
|
||||
│ long-lived, resumable, jittered reconnect
|
||||
│ per-host JWT (issued at enroll)
|
||||
│
|
||||
┌─────────────────┴──────────────────┐
|
||||
│ (per-customer gateway, │
|
||||
│ OPTIONAL — one mesh host │
|
||||
│ proxies N coolds → 1 stream) │
|
||||
└─────────────────▲──────────────────┘
|
||||
│ same stream protocol, over wg0
|
||||
│
|
||||
┌────────────────────┴────────────────┐ ┌─────────────────────────┐
|
||||
│ coold (per-host agent) │ │ /run/podman/podman.sock│
|
||||
│ - Dials central (or gateway) out │──┤ bind-mount, host-only │
|
||||
│ - Local REST on wg0 :8443 │ │ (NEVER on network) │
|
||||
│ (intra-mesh callers: CLI, peers) │ └─────────────┬───────────┘
|
||||
│ - Bearer-token authn (both paths) │ │
|
||||
│ - Talks ONLY to local podman sock │ ▼
|
||||
└─────────────────────────────────────┘ ┌─────────────────────────────┐
|
||||
│ podmand (containers, nets) │
|
||||
└─────────────────────────────┘
|
||||
```
|
||||
|
||||
**Key principles**:
|
||||
|
||||
1. **`/run/podman/podman.sock` is never exposed on TCP.** coold bind-mounts it and proxies a curated API. Central Coolify never touches the raw podman socket directly.
|
||||
2. **coold always dials outbound — never accepts inbound from central or public internet.** One topology for self-hosted and cloud SaaS. Works through any NAT/corp firewall, scales to thousands of hosts per central region (10k+ idle streams are cheap). No "add central to every customer's wg0" — central never joins any mesh.
|
||||
3. **coold still exposes a local REST API on wg0 mgmt IP** for intra-mesh callers only (the `coolify firewall` CLI via SSH-bounce, other coolds in the same mesh, a per-customer gateway if deployed). Never reachable from public internet; wg0 is the only L3 boundary that can hit it.
|
||||
4. **Per-customer gateway (optional)**: for large customers, one host in the mesh runs a stream aggregator that dials central once and proxies commands to the other coolds over wg0. Reduces stream fan-out at central from N-per-customer to 1-per-customer; adds one hop of latency. Transparent to both ends — same protocol each side.
|
||||
|
||||
## What `coolify init bootstrap` already provides
|
||||
|
||||
| Layer | Component | State |
|
||||
|---|---|---|
|
||||
| L3 mesh | WireGuard `wg0` per host with mgmt `/32` from `--wg-mgmt-pool` (default `100.64.0.0/16`) | Installed, configured, active |
|
||||
| L3 mesh | Peer `AllowedIPs = <peer-mgmt>/32, <peer-container>/24` | Configured |
|
||||
| Container runtime | Podman (distro apt) | Installed |
|
||||
| Container runtime | `podman.socket` (rootful, `/run/podman/podman.sock`) | Enabled, active |
|
||||
| Container network | `coolify-mesh` bridge per host with `/24` from `--container-pool` (default `10.210.0.0/16`), gateway `.1` | Created |
|
||||
| Routing | `net.ipv4.ip_forward=1` (persisted via `/etc/sysctl.d/99-coolify-mesh.conf`) | Enabled |
|
||||
| Firewall (mode A — `--podman` only) | `coolify-mesh-fw.service` with FORWARD ACCEPT for container subnet + POSTROUTING RETURN to skip podman MASQUERADE on wg0 | Active |
|
||||
| Firewall (mode B — `--default-deny`) | `COOLIFY-INTRA` chain (ESTABLISHED/RELATED accept → COOLIFY-ALLOW → DROP), FORWARD jumps for `-s/-d <container-subnet>`, blanket ACCEPT removed | Active when set |
|
||||
| Allow chain | `COOLIFY-ALLOW` (empty filter chain) | Created, ready for runtime rules |
|
||||
|
||||
Each host has a stable `(mgmt-ip, container-subnet)` pair. The bootstrap is idempotent — re-running `apply` only changes what drifted.
|
||||
|
||||
---
|
||||
|
||||
## What v5 control plane MUST implement
|
||||
|
||||
### 1. Inventory & state sync
|
||||
|
||||
- **Discovery**: query each host's `podman.socket` (over wg0 mgmt IP) for: containers, networks, volumes, images, system stats.
|
||||
- **Drift detection**: periodically reconcile desired state (Coolify DB) against actual (podman API). Re-converge or alert.
|
||||
- **Mesh join/leave**: when a host is added or removed from the cluster:
|
||||
- Add → invoke `coolify init extend --servers <full list> --new-hosts <new host>` (installs the new host end-to-end, regenerates wg0 config on every existing peer with the new mgmt IP + namespace `/24`s, leaves agent binaries on existing hosts untouched).
|
||||
- Remove → not supported by a first-class subcommand today. Documented workaround for alpha: tear the host out-of-band (stop services, drop it from DNS) and re-run `coolify init bootstrap` with the reduced `--servers` list on a maintenance window; a dedicated `remove-host` flow is a follow-up.
|
||||
|
||||
### 2. Container lifecycle
|
||||
|
||||
Every container op is a command sent over coold's outbound stream (central → coold) or a local REST call on coold's wg0 listener (intra-mesh → coold). coold executes the command against the local `/run/podman/podman.sock` Unix socket and streams results back.
|
||||
|
||||
- Create container with `--network coolify-mesh` and explicit `--ip` from the host's `/24`.
|
||||
- Reserve container IPs in the control plane DB. Allocator skips `.1` (bridge gateway), reserves `.2` for coold itself, `.3-.254` for app containers.
|
||||
- Start, stop, restart, remove.
|
||||
- Stream logs via `/containers/{id}/logs?follow=true` (coold relays podman API frames over the open control stream).
|
||||
- Health checks via `/containers/{id}/healthcheck/run`.
|
||||
- Resource limits, env vars, mounts, volumes, secrets — all standard podman API surfaced through coold.
|
||||
|
||||
#### coold is a primitive proxy, not an app brain
|
||||
|
||||
coold follows the **kubelet analogue**: it knows containers, images, volumes, networks, iptables, and Corrosion writes. It does **not** know apps, compose, Dockerfiles, buildpacks, or Nixpacks. Central Coolify is the apiserver+controllers: it parses app-level config and compiles it into a sequence of primitive ops streamed to coold.
|
||||
|
||||
Test for "should this live in coold?": could a second orchestrator (a Nomad-style competitor) reuse this coold with a different app model? If yes → coold. If no → central.
|
||||
|
||||
#### Wire surface (enumerable)
|
||||
|
||||
Same endpoint set on both transports (outbound stream from central, local REST on wg0 for intra-mesh callers). New verbs require a coold release — there is no `/podman/raw` passthrough.
|
||||
|
||||
```
|
||||
# Images
|
||||
POST /api/v1/images/pull {ref, auth?} -> {digest}
|
||||
GET /api/v1/images -> [{ref, digest, size}]
|
||||
DELETE /api/v1/images/{ref}
|
||||
|
||||
# Containers (filtered podman surface)
|
||||
POST /api/v1/containers <create spec> -> {id}
|
||||
POST /api/v1/containers/{id}/start
|
||||
POST /api/v1/containers/{id}/stop {timeout?}
|
||||
POST /api/v1/containers/{id}/restart
|
||||
DELETE /api/v1/containers/{id} {force?}
|
||||
GET /api/v1/containers/{id} (inspect)
|
||||
GET /api/v1/containers/{id}/logs?follow=true (streamed)
|
||||
POST /api/v1/containers/{id}/exec {cmd, tty?} (streamed)
|
||||
POST /api/v1/containers/{id}/healthcheck/run
|
||||
|
||||
# Volumes
|
||||
POST /api/v1/volumes {name, driver, labels}
|
||||
DELETE /api/v1/volumes/{name}
|
||||
GET /api/v1/volumes/{name}
|
||||
|
||||
# Networks (bootstrap creates coolify-mesh; extra per-app nets created here)
|
||||
POST /api/v1/networks {name, driver, options, labels}
|
||||
DELETE /api/v1/networks/{name}
|
||||
GET /api/v1/networks
|
||||
|
||||
# Firewall (coold = sole writer)
|
||||
POST /api/v1/firewall/allow {src, dst, proto?, port?} -> {id}
|
||||
DELETE /api/v1/firewall/allow/{id}
|
||||
GET /api/v1/firewall/allow
|
||||
|
||||
# Service endpoints (Corrosion writer; used by central to register deploys)
|
||||
POST /api/v1/services/register
|
||||
DELETE /api/v1/services/{id}/endpoints/{container_id}
|
||||
GET /api/v1/services/{id}/endpoints
|
||||
|
||||
# DNS (diagnostics)
|
||||
GET /api/v1/dns/lookup/{name}
|
||||
GET /api/v1/dns/stats
|
||||
|
||||
# Host facts (read-only; central scrapes these for observability + scheduling)
|
||||
GET /api/v1/host/info (podman info, kernel, wg state, load)
|
||||
GET /api/v1/host/containers (podman ps -a)
|
||||
GET /api/v1/host/stats (podman stats snapshot)
|
||||
```
|
||||
|
||||
**Deny filter on `POST /containers`** (defense-in-depth even though central is trusted):
|
||||
- Block `--privileged`, `--cap-add=SYS_ADMIN/NET_ADMIN` unless host is marked `allow_privileged=true`.
|
||||
- Block host-path bind mounts outside a configurable allowlist (default: none).
|
||||
- Block host netns (`--net=host`) unless the container is coold itself.
|
||||
|
||||
Anything not above is not coold's job. No `/apps`, `/deployments`, `/compose`, `/build`, `/podman/raw`. coold does not parse compose, Dockerfiles, buildpacks, or any app-level config — central compiles these into sequences of the primitive ops above and streams them down.
|
||||
|
||||
#### Networks
|
||||
|
||||
Default = shared `coolify-mesh` bridge. Containers get `.coolify.internal` DNS + flat L3 across the mesh. Users may define extra podman networks per app (docker-compose `networks:` style) via `POST /networks` + container attach on create. Central compiles compose into network-create + container-attach primitives.
|
||||
|
||||
#### coold deployment
|
||||
|
||||
coold runs as a privileged container on each host (or as a host systemd service). `coolify init bootstrap` puts it in place at install time (and `coolify init upgrade` bumps its version later): binary, systemd unit with `COOLD_API_BIND=<wg0-mgmt-ip>:8443`, random per-host bearer token at `/etc/coolify/api-token` (mode 0600), outbound stream config written atomically to `/etc/coolify/coold.env`.
|
||||
|
||||
Reference container spec (equivalent to systemd-service deployment):
|
||||
```bash
|
||||
podman run -d --name coold --restart=always \
|
||||
--network coolify-mesh --ip 10.210.X.2 \
|
||||
-v /run/podman/podman.sock:/run/podman/podman.sock \
|
||||
-v /etc/coolify/coold:/etc/coolify/coold:ro \
|
||||
--security-opt label=disable \
|
||||
-p 100.64.0.X:8443:8443 \
|
||||
ghcr.io/coollabs/coold:latest
|
||||
```
|
||||
|
||||
- **Outbound stream**: coold dials `wss://<central-host>/v1/agent` (or gRPC bidi) on start, presenting its per-host JWT. Central routes commands to it by host id over the open stream. Stream is the primary control channel for both self-hosted and cloud SaaS — same code path, same binary.
|
||||
- **Local REST on wg0 mgmt IP (`100.64.0.X:8443`)**: accepts intra-mesh callers only (the `coolify firewall` CLI via SSH-bounce, other coolds in the same mesh, a per-customer gateway). Not reachable from public internet — wg0 is the L3 boundary. Bearer-token auth on every request.
|
||||
- **No inbound from central**: central never dials coold. All mutations arrive over the coold-initiated stream; no `COOLIFY-ALLOW` rule for "central → host:8443" needed. Works through NAT/corp firewalls.
|
||||
|
||||
#### Control channel transport (stream)
|
||||
|
||||
Two candidates; spec-time decision, not per-host:
|
||||
|
||||
| Option | Pros | Cons |
|
||||
|---|---|---|
|
||||
| **gRPC bidi stream over HTTP/2** *(chosen)* | typed Protobuf schemas, native server-streaming for logs/exec, versionable wire | stricter proxy requirements (some corp proxies still mangle HTTP/2); larger runtime |
|
||||
| WebSocket (WSS over :443) *(fallback)* | traverses every proxy, tiny overhead, libs everywhere | framing is custom-on-top; manual request/response correlation |
|
||||
|
||||
**Decision: gRPC bidi + Protobuf.** Typed schemas + native server-streaming for logs and exec outweigh the proxy risk; WSS remains the documented fallback if gRPC-through-proxy issues show up in the field. Both run on :443, so customer-side egress rules stay unchanged either way.
|
||||
|
||||
#### Enrollment
|
||||
|
||||
coold registers once at install using a one-time token from central:
|
||||
|
||||
```bash
|
||||
coolify init bootstrap \
|
||||
--central-url https://cloud.coolify.io \
|
||||
--enroll-token <one-time-hex>
|
||||
```
|
||||
|
||||
1. coold POSTs `(host_id, wg0_mgmt_ip, container_subnet, enroll_token)` to `https://<central>/v1/enroll`.
|
||||
2. Central validates the enroll token (scoped to a tenant, single-use, short TTL) and issues a long-lived per-host JWT + TLS-pinned central cert. Response stored in `/etc/coolify/coold.env` (mode 0600).
|
||||
3. coold burns the enroll token and switches to JWT for the persistent stream.
|
||||
4. Central revokes by invalidating the JWT in its own DB; next stream reconnect fails auth and the host is quarantined until re-enrolled.
|
||||
|
||||
#### Reconnect + fleet-restart storms
|
||||
|
||||
Single-central-restart would otherwise trigger simultaneous reconnects from every host. Mitigations:
|
||||
|
||||
- **Jittered backoff**: exponential from 1s up to 60s with full jitter. 10k hosts reconnecting spread across ~minutes, not seconds.
|
||||
- **Resumable streams**: stream carries a monotonic `last_seq` per host so central can replay missed commands after reconnect without central-side queueing beyond an in-memory ring buffer.
|
||||
- **Region sharding**: DNS round-robin or geo-steering across multiple central stream gateways; each gateway holds O(10k) streams. Stateful routing via consistent-hashing on host_id so a host lands on the same gateway across reconnects (cache affinity).
|
||||
|
||||
#### Per-customer gateway (optional)
|
||||
|
||||
For customers with 50+ hosts, one designated mesh host runs a **gateway mode coold** (same binary, different role):
|
||||
|
||||
- Dials central like any other coold.
|
||||
- Accepts incoming streams from its peer coolds over wg0 (they dial `wss://<gateway-mgmt-ip>:8443/v1/agent-peer` instead of central).
|
||||
- Relays commands down, responses up. Maintains O(hosts-in-mesh) inbound streams + 1 outbound to central.
|
||||
|
||||
Saves N-1 WAN streams at central per customer; costs one hop of latency + one more thing to keep alive. Opt-in via `coolify init bootstrap --gateway-for-mesh` on the chosen host; peers get `--via-gateway <gateway-mgmt-ip>` at install.
|
||||
|
||||
### 3. Network policy (firewall)
|
||||
|
||||
When host has `--default-deny` enabled, **all cross-host container traffic is dropped by default**. The control plane decides who talks to whom.
|
||||
|
||||
#### Division of labour: bootstrap vs coold vs central
|
||||
|
||||
| Layer | Owner | Responsibility |
|
||||
|---|---|---|
|
||||
| Chain scaffold (COOLIFY-INTRA, COOLIFY-ALLOW, FORWARD jumps, conntrack early-accept, POSTROUTING RETURN) | `coolify init bootstrap` (also reconverges on `extend`) | Install + idempotently re-converge on flag change. Never touches individual allow rules. |
|
||||
| Rule metadata (who/when/why, audit log, RBAC, tenant scoping, app→rule mapping) | **Coolify central DB** | Authoritative store. All rich queries, audit trails, and access control live here. |
|
||||
| Raw rule tuples `(src, dst, proto, port)` on the host | **coold** (single writer) | Apply to kernel + snapshot to `/etc/coolify/allow.rules` for reboot. Stateless-ish — just a cache of what the caller (central Coolify or `coolify firewall` CLI) told it to apply. No metadata, no DB. |
|
||||
|
||||
**Key split**: central Coolify owns rich state (metadata, audit, RBAC). Per-host coold owns only the raw rules needed to program the kernel + survive reboot. This keeps coold small and lets a single central DB be the source of truth for all cross-cutting concerns.
|
||||
|
||||
**App-topology compilation happens in central.** coold applies the rule tuples it is told to apply; it does not generate rules from app intent (e.g. "allow service `web` → `db`"). Central compiles that from the app model and sends individual `POST /firewall/allow` frames.
|
||||
|
||||
**`coolify init` is intentionally not the rule store.** Bootstrap creates the empty allow chain. coold is the sole writer into it. Callers reach coold via two paths: (a) central Coolify over the coold-initiated outbound stream, (b) intra-mesh callers (`coolify firewall` CLI via SSH-bounce, other coolds, optional per-customer gateway) via coold's local REST API on wg0 mgmt IP.
|
||||
|
||||
#### Reboot persistence
|
||||
|
||||
Works the same pre- and post-coold because both use the same file format:
|
||||
|
||||
- `/etc/coolify/allow.rules` — filter-table fragment, `:COOLIFY-ALLOW` + `-A COOLIFY-ALLOW` lines only. Written atomically (`.tmp` + `mv`) on every rule change.
|
||||
- `/etc/systemd/system/coolify-mesh-allow.service` — `Type=oneshot`, `After=coolify-mesh-fw.service`, `Wants=coolify-mesh-fw.service`. `ExecStart=iptables-restore --noflush /etc/coolify/allow.rules`. `--noflush` means only `COOLIFY-ALLOW` is populated; nothing else is disturbed.
|
||||
|
||||
coold owns the file: it rewrites `/etc/coolify/allow.rules` on every successful API mutate, keeping it in sync with the live kernel. The `coolify firewall` CLI never touches the file — it POSTs/DELETEs through coold and coold handles persistence + systemd unit install. One writer, one format.
|
||||
|
||||
#### Allow-rule lifecycle
|
||||
|
||||
For an allow `(srcIP, dstIP)`:
|
||||
- Add ACCEPT to `COOLIFY-ALLOW` on the host that **owns dstIP** (where DROP would otherwise fire).
|
||||
- For bidirectional traffic (e.g. TCP, ICMP echo+reply), add the reverse `(dstIP, srcIP)` on the host that owns srcIP. (Reply packets traverse THAT host's FORWARD chain when arriving back, and dst-side check fires there.)
|
||||
- **One unidirectional allow = one rule on one host. One bidirectional allow = two rules on two hosts.**
|
||||
- Conntrack ESTABLISHED early-accept (installed by bootstrap) handles in-flow follow-up packets — no need to add per-packet rules.
|
||||
|
||||
#### Persistence + scale model
|
||||
|
||||
Per-rule systemd dropins do NOT scale (1000 rules × `daemon-reload` + restart = minutes, fs clutter, audit nightmare). Instead, coold is a thin rule-applier backed by central:
|
||||
|
||||
```
|
||||
coold service (per host)
|
||||
├─ Snapshot file: /etc/coolify/allow.rules (flat iptables-save fragment)
|
||||
├─ Boot: systemd unit runs iptables-restore --noflush from file
|
||||
├─ API mutate: apply iptables -A/-D → regen snapshot via iptables-save
|
||||
└─ Reconcile: central periodically diffs its DB vs coold's live
|
||||
`iptables -S COOLIFY-ALLOW`; pushes deltas to re-converge
|
||||
```
|
||||
|
||||
Source of truth for **the set of rules that should exist** = central Coolify DB. Source of truth for **what's programmed in the kernel right now** = kernel itself, mirrored to `/etc/coolify/allow.rules` for reboot. coold does not keep its own DB.
|
||||
|
||||
#### Write ordering (crash/reboot safety)
|
||||
|
||||
Every mutating call from central → coold follows this sequence:
|
||||
|
||||
1. **Central writes to its own DB first** (with its own audit/tenant metadata). Durable with the rest of Coolify's state.
|
||||
2. **Central sends command over the open stream** to coold with just `(src, dst, proto, port)`. No inbound connection to coold — the stream was already established by coold at boot.
|
||||
3. **coold applies `iptables -A/-D`** to kernel.
|
||||
4. **coold regenerates `/etc/coolify/allow.rules`** via `iptables-save` (atomic `.tmp` + `mv`).
|
||||
5. **coold returns success to central** over the same stream (response carries the request id).
|
||||
6. **On any failure in 3–5**, central marks the row "pending" in its DB and retries / surfaces to operator. Nothing is lost because step 1 is already durable.
|
||||
|
||||
Consequences:
|
||||
- **Crash between steps 3 and 4** → kernel has the rule, file doesn't. Reboot loses the rule. Central's reconcile loop detects divergence (its DB has the rule, live kernel doesn't after boot) and re-pushes. Safe, with a small drift window bounded by reconcile cadence.
|
||||
- **Crash between steps 4 and 5** → kernel + file both updated, but central didn't get the ack. Central retries; `iptables -C` guard makes the retry a no-op. Safe.
|
||||
- **coold down when central wants to mutate** → central queues the change and retries on reconnect. No state loss on either side.
|
||||
- **Central DB is authoritative** — a reboot can only *shrink* the live rule set compared to central's view, never grow it.
|
||||
|
||||
Bulk ops (`/bulk`) ship the whole batch in one REST call. coold applies via `iptables-restore --noflush` / `nft -f` (atomic transaction), then regens snapshot once.
|
||||
|
||||
Apply paths:
|
||||
|
||||
| Backend | Bulk apply (1000 rules) | Atomicity |
|
||||
|---|---|---|
|
||||
| `iptables -A` per rule | ~5s | per-rule |
|
||||
| `iptables-restore --noflush` (preferred for iptables-legacy) | ~50ms | per-batch |
|
||||
| `nft -f /tmp/rules.nft` (preferred when host uses nftables backend) | ~10ms | atomic transaction |
|
||||
|
||||
coold detects backend (`iptables --version` or presence of nftables socket) and picks. Bootstrap doesn't care.
|
||||
|
||||
For **systemctl restart coolify-mesh-fw.service** (e.g. a `coolify init bootstrap` re-run after a flag flip, or `coolify init extend` reinstalling the unit because the namespace list changed): the unit flushes COOLIFY-INTRA but **never flushes COOLIFY-ALLOW** — existing rules survive. If somehow lost (manual `iptables -F COOLIFY-ALLOW`, crash mid-write), central's reconcile loop compares its own DB against `iptables -S COOLIFY-ALLOW` from each host and re-pushes any missing tuples within the reconcile interval.
|
||||
|
||||
#### Allow API surface
|
||||
|
||||
Same method/path set is served on both transports — stream (central → coold) and local REST (intra-mesh → coold). Stream = JSON-RPC frames carrying the same `(method, path, body)` tuple; REST = plain HTTP on wg0 mgmt IP :8443.
|
||||
|
||||
```
|
||||
POST /api/v1/firewall/allow {src, dst, proto?, port?, comment?} → returns id
|
||||
DELETE /api/v1/firewall/allow/{id}
|
||||
GET /api/v1/firewall/allow list
|
||||
GET /api/v1/firewall/allow/{id} show + match counters
|
||||
POST /api/v1/firewall/allow/bulk {add: [...], remove: [...]} atomic batch
|
||||
POST /api/v1/firewall/reconcile force full reload
|
||||
```
|
||||
|
||||
coold translates each row into the right iptables/nft fragment. Per-port: `-p tcp --dport <N>`. Source/dest IP, CIDR, or set reference (for grouping like "all-frontend-ips").
|
||||
|
||||
For very large rule sets: use **nftables sets** so a rule references a set name, and the set membership changes are O(1):
|
||||
|
||||
```
|
||||
nft add element ip filter coolify_allowed_pairs { 10.210.0.10 . 10.210.1.10 }
|
||||
```
|
||||
|
||||
One static rule like `ct state new ip saddr . ip daddr @coolify_allowed_pairs accept` evaluates in O(log n) regardless of set size. coold maintains the set rather than thousands of rules. Optional optimization for v5+.
|
||||
|
||||
#### Intra-host isolation (NOT enforced by `--default-deny`)
|
||||
|
||||
Linux + netavark + Ubuntu 24.04: bridge L2 traffic bypasses iptables FORWARD even with `bridge-nf-call-iptables=1`. **Containers on the same host's `coolify-mesh` bridge can always reach each other.**
|
||||
|
||||
Two paths for v5 to enforce intra-host isolation:
|
||||
|
||||
- **(Recommended) Per-app podman networks**: each Coolify service = own podman network with `--opt isolate=true`. Different networks can't talk by default; use `podman network connect` for cross-app.
|
||||
- Trade-off: each network needs its own `/24` from container pool → wastes pool. Or carve `/27`s (allocator extension needed).
|
||||
- **(Alternative) ebtables L2 filter**: `ebtables --logical-in podman1 --logical-out podman1 --ip-src X --ip-dst Y -j ACCEPT/DROP`. Independent toolchain, separate persistence. Bridge name discovery needed.
|
||||
|
||||
v1 ships without intra-host enforcement. v5 picks one path.
|
||||
|
||||
### 4. Container IP allocation per host
|
||||
|
||||
The bootstrap gives each host a `/24` (e.g. `10.210.0.0/24`). The control plane:
|
||||
- Reserves `.1` (bridge gateway, skip).
|
||||
- Allocates `.2-.254` for containers, deduplicated against running `podman ps` IPs.
|
||||
- Pins IP via `podman run --ip <IP>` so DNS/firewall rules stay stable.
|
||||
- Detects exhaustion early; alerts user to grow `--container-pool` or `--container-prefix`.
|
||||
|
||||
For `/24` per host: 253 containers max. For higher density: re-bootstrap with `--container-prefix 23` or larger pool.
|
||||
|
||||
### 5. Service discovery
|
||||
|
||||
**Pattern**: embedded DNS server in coold, backed by [Corrosion](https://github.com/superfly/corrosion) (CRDT sqlite gossiped via SWIM across the mesh). No env injection. No container restarts on backend movement.
|
||||
|
||||
#### Why DNS-via-coold over alternatives
|
||||
|
||||
| Approach | Stable target? | Backend move = restart? | Complexity |
|
||||
|---|---|---|---|
|
||||
| Env injection (`DB_HOST=10.210.5.42`) | no — IP changes | yes (rolling redeploy on every change) | medium (template engine + dep graph) |
|
||||
| **Embedded DNS in coold** | **yes (hostname)** | **no** | **low (~200 LoC)** |
|
||||
| VIP per service | yes (IP) | no | high (keepalived/BGP/IPVS) |
|
||||
| Per-host HTTP/TCP proxy | yes (port) | no | medium (proxy config) |
|
||||
|
||||
DNS chosen: smallest moving parts, works for any protocol, standard `getaddrinfo()` path, ubiquitous client support.
|
||||
|
||||
#### Corrosion schema (replicated sqlite)
|
||||
|
||||
```sql
|
||||
CREATE TABLE services (
|
||||
id TEXT PRIMARY KEY, -- "myapp.db"
|
||||
coolify_app_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL, -- "db"
|
||||
namespace TEXT NOT NULL, -- "myapp"
|
||||
port INTEGER, -- canonical port (informational)
|
||||
updated_at INTEGER NOT NULL -- ms epoch (CRDT clock)
|
||||
);
|
||||
|
||||
CREATE TABLE service_endpoints (
|
||||
service_id TEXT NOT NULL,
|
||||
container_id TEXT NOT NULL,
|
||||
host_mgmt_ip TEXT NOT NULL, -- 100.64.0.X (host running the container)
|
||||
container_ip TEXT NOT NULL, -- 10.210.X.Y
|
||||
healthy INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (service_id, container_id)
|
||||
);
|
||||
```
|
||||
|
||||
Each coold writes its own host's container facts. Reads are local sqlite (sub-ms). Gossip handles distribution; convergence ~1s in small clusters.
|
||||
|
||||
#### Embedded DNS server
|
||||
|
||||
```go
|
||||
// pseudocode — ~200 LoC total
|
||||
func (c *Coold) serveDNS() {
|
||||
pc, _ := net.ListenPacket("udp", "10.210.X.1:53") // bridge gateway IP
|
||||
for {
|
||||
buf := make([]byte, 512)
|
||||
n, addr, _ := pc.ReadFrom(buf)
|
||||
go c.handle(buf[:n], addr, pc)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Coold) handle(query []byte, src net.Addr, pc net.PacketConn) {
|
||||
msg := dns.Unpack(query)
|
||||
name := msg.Questions[0].Name // "myapp.db.coolify.internal."
|
||||
|
||||
if !strings.HasSuffix(name, ".coolify.internal.") {
|
||||
// Forward to upstream (configurable; default 1.1.1.1).
|
||||
pc.WriteTo(c.upstream.Query(msg), src)
|
||||
return
|
||||
}
|
||||
|
||||
serviceID := strings.TrimSuffix(name, ".coolify.internal.")
|
||||
var ips []string
|
||||
c.corrosion.Query(`
|
||||
SELECT container_ip FROM service_endpoints
|
||||
WHERE service_id = ? AND healthy = 1
|
||||
`, serviceID).Scan(&ips)
|
||||
|
||||
if len(ips) == 0 {
|
||||
pc.WriteTo(dns.NXDOMAIN(msg), src); return
|
||||
}
|
||||
pc.WriteTo(dns.AnswerA(msg, ips, ttl=5), src)
|
||||
}
|
||||
```
|
||||
|
||||
Listens on **bridge gateway IP** (`10.210.X.1:53`) of the host's `coolify-mesh` bridge — reachable from every container in the host's `/24` via standard kernel routing.
|
||||
|
||||
#### Container creation hook
|
||||
|
||||
Every container coold creates gets:
|
||||
```
|
||||
podman run --dns 10.210.X.1 --dns-search coolify.internal ...
|
||||
```
|
||||
|
||||
App code uses short names: `getaddrinfo("myapp.db", ...)` → libc appends search suffix → `myapp.db.coolify.internal` → coold answers from local Corrosion.
|
||||
|
||||
#### Resolution flow
|
||||
|
||||
```
|
||||
1. App in container A on host-1 (10.210.0.10) calls getaddrinfo("myapp.db")
|
||||
2. libc reads /etc/resolv.conf:
|
||||
nameserver 10.210.0.1
|
||||
search coolify.internal
|
||||
3. UDP query "myapp.db.coolify.internal" → 10.210.0.1:53
|
||||
4. coold@host-1 reads local Corrosion → 10.210.5.42 (running on host-3)
|
||||
5. Reply: A 10.210.5.42, TTL=5
|
||||
6. App opens TCP to 10.210.5.42:5432
|
||||
7. Routed via wg0 (peer host-3's AllowedIPs covers 10.210.5.0/24)
|
||||
→ bridge → container
|
||||
8. (If --default-deny is on, COOLIFY-ALLOW on host-3 must permit
|
||||
10.210.0.10 → 10.210.5.42.)
|
||||
```
|
||||
|
||||
#### Backend movement (zero restart on dependents)
|
||||
|
||||
```
|
||||
T+0: myapp.db @ 10.210.5.42 on host-3. Endpoint row gossiped.
|
||||
T+10s: User redeploys myapp.db on host-3.
|
||||
coold@host-3:
|
||||
- new container at 10.210.5.43
|
||||
- INSERT new endpoint row (10.210.5.43)
|
||||
- DELETE old endpoint row (10.210.5.42)
|
||||
- kill old container
|
||||
Corrosion gossips delta.
|
||||
T+11s: All hosts have updated state.
|
||||
T+15s: App on host-1 has stale TCP to 10.210.5.42 — broken when old container died.
|
||||
App's reconnect logic re-resolves myapp.db → 10.210.5.43 → reconnects.
|
||||
App container NEVER restarted, env NEVER changed.
|
||||
```
|
||||
|
||||
App must have reconnect logic (every reasonable DB/cache client does). DNS provides the new IP transparently.
|
||||
|
||||
#### TTL
|
||||
|
||||
5s. Trade-off:
|
||||
- Lower = faster failover, more queries.
|
||||
- Higher = quieter DNS, slower failover.
|
||||
|
||||
Apps with infinite-cache resolvers (Java's `networkaddress.cache.ttl=-1`) won't see updates. Document for users; not coold's problem.
|
||||
|
||||
#### Multi-replica services
|
||||
|
||||
Resolver returns ALL healthy A records. Apps with proper conn pools (postgres, redis clients) handle multi-target naturally. No client-side LB protocol needed.
|
||||
|
||||
#### Health & staleness
|
||||
|
||||
- coold marks `healthy=0` on healthcheck fail. DNS stops returning that IP within next query.
|
||||
- Stale-row TTL: rows older than 60s without heartbeat are pruned (owning coold heartbeats every 15s).
|
||||
|
||||
#### TLD
|
||||
|
||||
`.coolify.internal` — `.internal` is RFC 6761 reserved for private use. Won't collide with public TLDs. Configurable per-cluster.
|
||||
|
||||
#### Failure modes
|
||||
|
||||
| Failure | Behaviour |
|
||||
|---|---|
|
||||
| coold dies | Cluster DNS resolution stops. systemd restarts coold (~3s). Existing connections survive. Same profile as k8s losing CoreDNS. |
|
||||
| Corrosion split-brain | Each partition serves local view; CRDT merges cleanly when partition heals. May serve stale IPs during partition. |
|
||||
| Backend healthy in DB but unreachable | DNS returns IP → app connection fails → app retries. If multi-replica, may pick different one on retry. |
|
||||
| Container has no `--dns` (created outside coold) | No cluster resolution. Document: only coold-managed containers get discovery. |
|
||||
| Cross-region high latency | Slower convergence; stale DNS for 10–30s. Acceptable v1. |
|
||||
|
||||
#### API surface
|
||||
|
||||
Same dual-transport model as the firewall API — stream from central, REST from intra-mesh callers.
|
||||
|
||||
```
|
||||
POST /api/v1/services/register {service_id, app_id, name, namespace, port, container_id, container_ip, host_mgmt_ip}
|
||||
DELETE /api/v1/services/{service_id}/endpoints/{container_id}
|
||||
GET /api/v1/services/{service_id}/endpoints
|
||||
GET /api/v1/services?namespace=myapp
|
||||
GET /api/v1/dns/lookup/{name} (debug — what coold would answer)
|
||||
GET /api/v1/dns/stats (qps, hit/miss/forward counts)
|
||||
```
|
||||
|
||||
Most ops are automatic side effects of deploy/scale/health-check. Central rarely calls `/services/register` directly — coold registers on container create, deregisters on remove.
|
||||
|
||||
coold writes Corrosion rows on behalf of central (explicit `POST /services/register` frames); it does not infer service identity from container labels. Central supplies `service_id` explicitly so naming policy stays in one place.
|
||||
|
||||
#### Bootstrap impact
|
||||
|
||||
Minimal. `coolify init bootstrap` creates every `coolify-<ns>-mesh` Podman network with `--disable-dns` so netavark never starts aardvark-dns on the bridge gateway `:53`. coold owns that socket. Bridge gateway IP was always reserved by `MachineIP()`.
|
||||
|
||||
Pre-alpha deployments that created the network without `--disable-dns` are detected at plan-time (probe reads `podman network inspect .DNSEnabled`). A `recreate-podman-network` action drops and recreates the network — same subnet, same gateway, but with DNS disabled. Any attached containers are disconnected via `podman network rm -f`.
|
||||
|
||||
#### Port 53 conflict handling
|
||||
|
||||
Three layers protect coold's `10.210.X.1:53` socket:
|
||||
|
||||
| Layer | Mechanism | Covers |
|
||||
|---|---|---|
|
||||
| 1. Bootstrap | `podman network create --disable-dns` (+ drift recreate) | aardvark-dns squat |
|
||||
| 2. Bind target | coold binds **bridge gateway IP only**, not `0.0.0.0` and not wg0 mgmt IP | host wildcard DNS daemons (dnsmasq/pihole on `0.0.0.0:53`) and wg0 bloat |
|
||||
| 3. Preflight | `net.Listen("tcp", gateway+":53")` probe before `ListenPacket` | clear actionable error + systemd `Restart=on-failure` retry |
|
||||
|
||||
systemd-resolved on Ubuntu binds `127.0.0.53:53` — no conflict with bridge gateway.
|
||||
|
||||
Bind rule: coold DNS is container-facing only (listen on bridge gateway IP). coold REST API is operator-facing (listen on wg0 mgmt IP, port 8443). Separate concerns, separate sockets.
|
||||
|
||||
### 6. Ingress (public traffic → containers)
|
||||
|
||||
`coolify init` doesn't manage public ingress. v5 deploys a reverse proxy (Traefik/Caddy) per host or HA pair:
|
||||
- Listens on host public IP `:80/:443`.
|
||||
- Routes `Host: app.example.com` → container IP (over container bridge or wg0 if cross-host).
|
||||
- Cert management via ACME.
|
||||
- Coolify generates proxy config from app routing rules.
|
||||
|
||||
Important: ingress proxy needs its own podman network OR can share `coolify-mesh`. Sharing means proxy can reach all containers — fine since it's the entrypoint.
|
||||
|
||||
### 7. Deployment workflows
|
||||
|
||||
Deploy is a **central-side state machine** that compiles app intent (compose / Dockerfile / buildpack / Nixpacks / raw image) into a sequence of coold primitives (see §2 wire surface). coold does not participate in planning — it executes one primitive per frame.
|
||||
|
||||
#### Build pipeline (not in coold)
|
||||
|
||||
```
|
||||
git push
|
||||
│
|
||||
▼
|
||||
Central receives webhook
|
||||
│
|
||||
▼
|
||||
Builder (BuildKit / Buildpacks / Nixpacks) ← coold NOT involved
|
||||
- Self-hosted: first mesh host by default;
|
||||
central may pin via target_host_id per build.
|
||||
- Cloud: central-run.
|
||||
│
|
||||
▼
|
||||
Push to registry (registry.coolify.io or customer's) ← coold NOT involved
|
||||
│
|
||||
▼
|
||||
Central deploy controller → primitive op stream → coold on target host
|
||||
```
|
||||
|
||||
coold's only role in the build path: `POST /images/pull` once the tag exists in the registry.
|
||||
|
||||
#### Deploy flow (T0–T10 — every frame = one §2 primitive)
|
||||
|
||||
```
|
||||
T0 Central builder clones source, invokes BuildKit / buildpack / nixpacks.
|
||||
Output: OCI image @ registry.coolify.io/tenant/web:v2.
|
||||
|
||||
T1 Central deploy controller picks target host H (scheduler = least-loaded / pin).
|
||||
|
||||
T2 Frame: POST /images/pull {ref: "registry.coolify.io/tenant/web:v2"}
|
||||
coold@H calls podman.sock /images/create, streams progress back.
|
||||
|
||||
T3 Frame: POST /volumes {name: "web-data", driver: "local"}
|
||||
coold@H idempotent; no-op if exists.
|
||||
|
||||
T4 Frame: POST /containers (central templates from compose + resolved secrets)
|
||||
body:
|
||||
{
|
||||
"image": "registry.coolify.io/tenant/web:v2",
|
||||
"name": "web-v2-a3f91",
|
||||
"network": "coolify-mesh",
|
||||
"ip": "10.210.H.42",
|
||||
"dns": ["10.210.H.1"],
|
||||
"dns_search": ["coolify.internal"],
|
||||
"env": {"DATABASE_URL": "postgres://…"},
|
||||
"mounts": [{"volume": "web-data", "target": "/data"}],
|
||||
"healthcheck": {"test": ["CMD","curl","-f","http://localhost/"], "interval": "5s"},
|
||||
"labels": {"coolify.app": "web", "coolify.version": "v2"}
|
||||
}
|
||||
coold checks deny filter → calls podman.sock /containers/create → returns id.
|
||||
|
||||
T5 Frame: POST /containers/{id}/start
|
||||
coold starts container.
|
||||
|
||||
T6 Central polls GET /containers/{id} or subscribes to events.
|
||||
Wait for healthy; abort + rollback on timeout.
|
||||
|
||||
T7 Frame: POST /services/register
|
||||
coold writes Corrosion row. Gossip distributes; DNS now answers new IP.
|
||||
|
||||
T8 Frame: POST /firewall/allow (on dst host — coold = sole kernel writer)
|
||||
{src: proxy-ip, dst: 10.210.H.42, proto: "tcp", port: 80}
|
||||
|
||||
T9 Central ingress controller regenerates proxy config (Caddy/Traefik/nginx)
|
||||
→ upstreams point to new container IP.
|
||||
Frame: POST /containers/{proxy-id}/exec (reload) or proxy-specific reload.
|
||||
|
||||
T10 Cutover complete. Central retires the old container:
|
||||
POST /containers/{old-id}/stop {timeout: 10}
|
||||
DELETE /containers/{old-id}
|
||||
DELETE /services/web/endpoints/{old-container-id}
|
||||
DELETE /firewall/allow/{old-rule-id}
|
||||
```
|
||||
|
||||
Every T-frame is one of the narrow primitives in §2. coold never runs compose, never builds, never picks hosts, never reads app config. If a future verb is needed, it gets added to §2 and the coold release, not smuggled through a passthrough.
|
||||
|
||||
**coold non-goals for deploy**: no compose parser, no buildpacks, no Dockerfile handler, no Nixpacks, no scheduler, no ingress templating, no rollback orchestration, no secrets store.
|
||||
|
||||
### 8. Storage & volumes
|
||||
|
||||
- Local podman volumes per host (`/var/lib/containers/storage/volumes`).
|
||||
- Cross-host: distributed FS (out of scope) OR pin stateful services to a host (anti-affinity rules in scheduler).
|
||||
- Backup: `podman volume export` + scp to backup target. Coolify orchestrates schedule.
|
||||
- **v5 alpha decision**: stateful services **pin to host**. Cross-host volume movement / distributed FS is post-alpha.
|
||||
|
||||
### 9. Scheduling
|
||||
|
||||
**Placement lives in central.** coold provides facts (`GET /host/info`, `/host/stats`, `/host/containers`); central consumes them, picks the target host, and sends the resulting primitives. coold has no placement logic.
|
||||
|
||||
When user creates an app, central decides which host runs it:
|
||||
- Round-robin / least-loaded / explicit pin.
|
||||
- Pinned services (DB, persistent volumes) tracked in central DB.
|
||||
- Re-schedule on host failure (wg0 down, last-handshake stale).
|
||||
|
||||
Failure detection: central polls `wg show wg0 latest-handshakes` via `GET /host/info` on every host, parses seconds-since-handshake; alerts if > N seconds.
|
||||
|
||||
### 10. Observability
|
||||
|
||||
coold exposes read-only `/host/*` endpoints surfacing the facts below. Central (or a central-side scraper) pulls from each host and feeds Prometheus / VictoriaMetrics. coold does **not** push metrics.
|
||||
|
||||
Per host metrics (over wg0 via coold endpoints):
|
||||
- `GET /host/info` → podman info (version, storage driver, free space), kernel, wg state, load.
|
||||
- `GET /host/containers` → `podman ps -a --format json` state.
|
||||
- `GET /host/stats` → `podman stats --no-stream --format json` CPU/mem per container.
|
||||
- Wg handshake + transfer bytes via `GET /host/info` (`wg show wg0 dump` internally).
|
||||
- `iptables -nvL COOLIFY-ALLOW` match counters (for audit) exposed through `GET /firewall/allow` with counters.
|
||||
|
||||
Stream into central time-series store (Prometheus / VictoriaMetrics).
|
||||
|
||||
### 11. Updates
|
||||
|
||||
- Coolify runtime image self-updates (container restart with new image).
|
||||
- WireGuard / Podman package updates: `coolify init bootstrap` re-runs idempotently and picks up newer packages from apt. Agent (coold/corrosion/scheduler/builder) bumps go through `coolify init upgrade --coold-version vX.Y.Z` etc. Schedule periodic re-apply (weekly?).
|
||||
- Mesh config changes (new host, removed host) trigger re-apply on all hosts; control plane orchestrates.
|
||||
|
||||
### 12. Security posture
|
||||
|
||||
- **Private keys never leave hosts**: WG private key generated on remote, never transits SSH (already done by bootstrap).
|
||||
- **Podman socket access**: `/run/podman/podman.sock` stays as a rootful Unix socket on each host — **NEVER exposed on TCP**. Only **coold** (per-host agent, see §2) has access via bind-mount. coold surfaces a curated REST API over wg0 with TLS + bearer auth. This means:
|
||||
- Compromise of a non-coold container does NOT grant podman API access.
|
||||
- coold enforces bearer-token authn and can deny dangerous flags (e.g. `--privileged`) at the API surface. RBAC, per-user/tenant scoping, and business audit live **only** in central Coolify (see §3 split).
|
||||
- No `podman system service tcp://...` listener; no need for socket-level TLS.
|
||||
- Central Coolify only knows the coold endpoint, not the underlying socket.
|
||||
- **SSH access**: bootstrap uses key-based SSH. Control plane should rotate SSH keys per agent install, store in encrypted DB. After bootstrap, day-to-day ops go via coold REST — SSH is for re-bootstrap only.
|
||||
- **Host firewall (iptables INPUT chain)**: bootstrap doesn't lock down INPUT. v5 should drop public access to ports other than `:51820/udp` (WG), `:22/tcp` (SSH), `:80/:443` (ingress). coold's `:8443` binds to the wg0 IP only, so it's already not on the public interface.
|
||||
- **coold port reachability**: central never dials in — coold's outbound stream is the control path — so no `COOLIFY-ALLOW` rule for central is needed. coold's local REST on wg0 mgmt IP (`:8443`) is reachable only from inside the mesh, and is used by (a) the `coolify firewall` CLI via SSH-bounce, (b) other coolds in the same mesh, (c) an optional per-customer gateway. Nothing on the public internet reaches coold. Outbound TLS :443 to central must be permitted by the customer's egress firewall — standard for any SaaS agent.
|
||||
- **Audit**: central Coolify is the sole authoritative audit log — who-when-why metadata for every COOLIFY-ALLOW change. coold writes only an ops/debug request log (request id, endpoint, status, duration) for troubleshooting; it never sees the identity of the human caller, only the bearer token used to reach it.
|
||||
|
||||
### 13. Failure modes & recovery
|
||||
|
||||
| Failure | Detection | Recovery |
|
||||
|---|---|---|
|
||||
| Host SSH unreachable | bootstrap apply error | Manual investigation; node marked unhealthy in DB |
|
||||
| WG peer offline (`latest_handshake > 180s`) | `wg show` poll | Mark unhealthy; re-schedule containers if pinning permits |
|
||||
| Podman socket unreachable | API call timeout | Restart `podman.socket`; if persistent, re-bootstrap |
|
||||
| Firewall service failed | `systemctl is-active != active` | Re-run `coolify init bootstrap`; service is idempotent |
|
||||
| Container OOM/crash | `podman events` watcher | Restart per restart policy; alert after N crashes |
|
||||
| Container subnet exhausted | allocator returns error | Alert; offer apply with bigger `--container-prefix` |
|
||||
| Mgmt IP exhausted | allocator returns error | Alert; rare for /16 |
|
||||
| `coolify-mesh` bridge missing | probe `podman network exists` returns no | Re-run apply |
|
||||
| User manually deletes COOLIFY-ALLOW chain | runtime check | Re-run apply (recreates chain via service restart) |
|
||||
|
||||
### 14. Multi-tenancy (deferred)
|
||||
|
||||
If Coolify ever supports tenant isolation:
|
||||
- Tenant = own podman network namespace per host.
|
||||
- Allows always scoped within tenant; cross-tenant requires explicit allow.
|
||||
- Pool subdivided per tenant. Allocator extension.
|
||||
|
||||
Not in v1 or v5 initial.
|
||||
|
||||
---
|
||||
|
||||
## Out of scope (now and likely v5)
|
||||
|
||||
- Rootless containers (would need user namespace mapping, separate sockets per user).
|
||||
- IPv6 mesh (`fdcc::` style, ip6tables mirror).
|
||||
- Hardware-level isolation (SELinux profiles, AppArmor).
|
||||
- Live migration (qemu/criu).
|
||||
- Distributed storage (Ceph/Longhorn).
|
||||
- macvlan / SR-IOV networking.
|
||||
- Autoscaling.
|
||||
- BGP / external network announcements.
|
||||
|
||||
---
|
||||
|
||||
## Quick reference — operations the agent CLI should expose
|
||||
|
||||
(Future `coolify-cli` subcommands beyond `init`)
|
||||
|
||||
```
|
||||
coolify deploy <app> # build + push + run
|
||||
coolify scale <app> --replicas N
|
||||
coolify firewall containers --servers A,B ... # discover mesh containers (SSH+podman)
|
||||
coolify firewall list --servers A,B ... # list allow rules across hosts (coold GET /allow, SSH-bounced)
|
||||
coolify firewall allow --from <ref> --to <ref> --port N # add allow rule (coold POST /allow, SSH-bounced)
|
||||
coolify firewall revoke --from <ref> --to <ref> --port N # remove allow rule (coold DELETE /allow/{id})
|
||||
coolify host list # show mesh state, last-handshake, container count
|
||||
coolify host add <ip> --ssh-key K
|
||||
coolify host remove <ip>
|
||||
coolify logs <container>
|
||||
coolify exec <container> -- sh
|
||||
```
|
||||
|
||||
`coolify firewall` is implemented today as a thin SSH-bounced REST client of coold (§3 above). The laptop running the CLI isn't a mesh peer, so every call SSHes into the target host and runs `curl "http://<wg0-mgmt-ip>:8443/api/v1/firewall/..."` against coold locally. Per-host bearer tokens are fetched from `/etc/coolify/api-token` on demand (with `--coold-token` as an override for homogeneous test clusters).
|
||||
|
||||
Everything else on the roadmap (`coolify deploy`, `coolify scale`, `coolify logs`, `coolify exec`) targets the **central** API (SaaS or self-hosted central), not coold directly. Central compiles the request into the primitive-op sequence in §7 and streams it to coold. Only `coolify firewall` currently bypasses central and hits coold directly — legacy + test harness until central wires up `/firewall/*` itself.
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
`coolify init bootstrap` does the **first-time host install**: WG mesh, podman runtime, bridge network, default-deny scaffold, coold/corrosion/scheduler/builder agents. `coolify init extend` adds hosts to an existing mesh without disturbing converged ones; `coolify init upgrade` bumps agent versions across the fleet. After that, **everything dynamic is the v5 control plane's job**: container lifecycle, allow rules in COOLIFY-ALLOW (via systemd dropins for persistence), scheduling, observability, ingress, updates.
|
||||
|
||||
The pieces communicate via:
|
||||
1. **SSH** for host provisioning + re-converge (idempotent `coolify init bootstrap` / `extend` / `upgrade` re-runs). SSH is the installer channel only, not a steady-state control path.
|
||||
2. **coold → central outbound stream** (WSS / gRPC bidi on :443) for day-to-day runtime ops from central. One topology for self-hosted and cloud SaaS; central never dials coold, never joins any mesh. Per-customer gateway (optional) collapses N streams into 1 per mesh.
|
||||
3. **coold local REST API** on wg0 mgmt IP (`http://100.64.0.X:8443`) for intra-mesh callers: the `coolify firewall` CLI via SSH-bounce, other coolds, the per-customer gateway. Never reachable from the public internet.
|
||||
|
||||
coold is the *only* process with access to the local podman socket AND the sole writer of allow rules in COOLIFY-ALLOW. Both transports hit the same API surface.
|
||||
|
||||
Persistence model:
|
||||
- Bootstrap state (chains, jumps, conntrack accept) → idempotent `coolify init bootstrap` re-runs (and `extend` when a namespace is added).
|
||||
- Rule metadata (who/when/why, audit, RBAC, tenant scoping) → central Coolify DB only. coold does not duplicate this.
|
||||
- Kernel rules → programmed by coold on every API call (from either central Coolify or the `coolify firewall` CLI); mirrored to `/etc/coolify/allow.rules` for reboot via `coolify-mesh-allow.service` (oneshot `iptables-restore --noflush`).
|
||||
- Today the `coolify firewall` CLI is the primary caller of coold (SSH-bounced REST client with per-host `/etc/coolify/api-token` resolution). Central Coolify will call the same API once wired.
|
||||
|
||||
The podman socket is host-local. There is no TCP podman API. coold is the **authn + privilege boundary** between any caller (central Coolify over the outbound stream, or the `coolify firewall` CLI via SSH-bounced local REST) and the host, AND the kernel-rule applier. Central Coolify owns RBAC, tenant scoping, and the business audit log (who/when/why). coold only verifies a bearer token (per-host static for local REST; per-host JWT for the stream), applies the rule, and keeps an ops/debug request log. `coolify firewall` exercises the local REST surface today; central will exercise the stream surface — same code path end-to-end, different transport.
|
||||
|
||||
**coold stays small.** All app-aware logic (compose, Dockerfile, buildpacks, Nixpacks, scheduling, rollback, ingress templating, RBAC, audit) lives in central. coold's wire surface is enumerable (§2); new verbs require a coold release, not a `/podman/raw` passthrough. If coold ever grows a `/apps` or `/compose` endpoint, that is the wrong layer.
|
||||
+24
-23
@@ -44,30 +44,27 @@ Once you publish the release:
|
||||
- **Linux**: amd64, arm64
|
||||
- **macOS (Darwin)**: amd64, arm64
|
||||
- **Windows**: amd64, arm64
|
||||
3. Goreleaser injects the version from the tag into the binaries
|
||||
3. Goreleaser injects the version from the tag into the binaries via ldflags (into `internal/version.version`)
|
||||
4. Binaries are automatically uploaded to the release
|
||||
5. The release becomes available at:
|
||||
5. A follow-up `update-version` job then:
|
||||
- Updates the `version` constant in `internal/version/checker.go` to the new tag
|
||||
- Commits the bump to `v4.x` as `chore: bump version to vX.Y.Z`
|
||||
- Force-moves the release tag to point at that new commit
|
||||
6. GoReleaser also publishes a Homebrew formula to the tap at [`coollabsio/homebrew-coolify-cli`](https://github.com/coollabsio/homebrew-coolify-cli) (under `Formula/coolify-cli.rb`), using the `HOMEBREW_TAP_GITHUB_TOKEN` secret
|
||||
7. The release becomes available at:
|
||||
- GitHub: `https://github.com/coollabsio/coolify-cli/releases/tag/v1.x.x`
|
||||
- Install script: `curl -fsSL https://cdn.coollabs.io/coolify/install.sh | bash`
|
||||
- Homebrew: `brew install coollabsio/coolify-cli/coolify-cli`
|
||||
- `go install`: `go install github.com/coollabsio/coolify-cli/coolify@v1.x.x`
|
||||
|
||||
### 3. Verify the Release
|
||||
|
||||
After the workflow completes (usually 2-5 minutes):
|
||||
After the workflow completes (usually 2-5 minutes), verify without touching your local install:
|
||||
|
||||
1. Check the release page has all platform binaries
|
||||
2. Test the install script:
|
||||
```bash
|
||||
curl -fsSL https://cdn.coollabs.io/coolify/install.sh | bash
|
||||
coolify version
|
||||
```
|
||||
3. Test the auto-update functionality:
|
||||
```bash
|
||||
# If you have an older version installed
|
||||
coolify update
|
||||
coolify version # Should show the new version
|
||||
```
|
||||
4. Verify the version matches your release
|
||||
1. Check the release page has all platform binaries (Linux/macOS/Windows × amd64/arm64)
|
||||
2. Confirm the `update-version` job committed the bump on `v4.x` (look for `chore: bump version to vX.Y.Z`) and that the tag now points at that commit
|
||||
3. Confirm `internal/version/checker.go` on `v4.x` has the new version
|
||||
4. Confirm the Homebrew tap has a new `Formula/coolify-cli.rb` commit for this version at https://github.com/coollabsio/homebrew-coolify-cli
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
@@ -79,9 +76,10 @@ After the workflow completes (usually 2-5 minutes):
|
||||
- GoReleaser configuration issues
|
||||
|
||||
### Version Not Updating
|
||||
- Ensure you committed the version change in `cmd/root.go`
|
||||
- The version is injected at build time via ldflags into `internal/version.version` — you do **not** need to edit it manually before releasing. The post-release `update-version` job also rewrites `internal/version/checker.go` on `v4.x`.
|
||||
- If the hardcoded fallback in `internal/version/checker.go` is stale, check that the `update-version` job ran successfully after the release.
|
||||
- The tag must start with `v` (e.g., `v1.2.3`, not `1.2.3`)
|
||||
- Check that the workflow has write permissions
|
||||
- Check that the workflow has write permissions (`contents: write` in `release-cli.yml`)
|
||||
|
||||
### Install Script Not Finding New Version
|
||||
- Wait a few minutes for GitHub's CDN to update
|
||||
@@ -94,30 +92,33 @@ Before creating a release:
|
||||
|
||||
- [ ] All tests pass: `go test ./internal/...`
|
||||
- [ ] Code is formatted: `go fmt ./...`
|
||||
- [ ] Version updated in `cmd/root.go`
|
||||
- [ ] Changes merged to `v4.x` branch
|
||||
- [ ] Release notes prepared
|
||||
|
||||
> Note: You do **not** need to bump the version manually. GoReleaser injects the tag version via ldflags, and the `update-version` CI job commits the bump to `internal/version/checker.go` after the release.
|
||||
|
||||
After creating a release:
|
||||
|
||||
- [ ] GitHub Actions workflow completed successfully
|
||||
- [ ] GitHub Actions workflow completed successfully (both `release-cli` and `update-version` jobs)
|
||||
- [ ] All platform binaries are present on the release page
|
||||
- [ ] Install script downloads the new version
|
||||
- [ ] `coolify version` returns the correct version
|
||||
- [ ] `internal/version/checker.go` on `v4.x` shows the new version
|
||||
- [ ] Homebrew tap has a fresh `Formula/coolify-cli.rb` commit
|
||||
|
||||
## Configuration Files
|
||||
|
||||
The release process uses these configuration files:
|
||||
|
||||
- `.goreleaser.yml` - GoReleaser configuration (build matrix, archives, etc.) - points to `/coolify` as entry point
|
||||
- `.goreleaser.yml` - GoReleaser configuration (build matrix, archives, Homebrew tap) - entry point is `./coolify/main.go`
|
||||
- `.github/workflows/release-cli.yml` - GitHub Actions workflow
|
||||
- `scripts/install.sh` - User-facing install script
|
||||
- `internal/version/checker.go` - Contains `GetVersion()` function that returns the current version
|
||||
- `coolify/main.go` - Binary entry point for `go install` support
|
||||
- [`coollabsio/homebrew-coolify-cli`](https://github.com/coollabsio/homebrew-coolify-cli) - External Homebrew tap updated automatically on each release
|
||||
|
||||
## Notes
|
||||
|
||||
- The CLI has auto-update checking built-in (checks every 10 minutes)
|
||||
- Users can manually update with `coolify update`
|
||||
- Install script supports version pinning: `bash install.sh v1.2.3`
|
||||
- Homebrew users can install via `brew install coollabsio/coolify-cli/coolify-cli` (the tap at https://github.com/coollabsio/homebrew-coolify-cli is auto-updated by GoReleaser)
|
||||
- Releases are immutable - if you need to fix something, create a new patch version
|
||||
|
||||
@@ -12,6 +12,12 @@ curl -fsSL https://raw.githubusercontent.com/coollabsio/coolify-cli/main/scripts
|
||||
|
||||
It will install the CLI in `/usr/local/bin/coolify` and the configuration file in `~/.config/coolify/config.json`
|
||||
|
||||
### Homebrew (macOS/Linux)
|
||||
|
||||
```bash
|
||||
brew install coollabsio/coolify-cli/coolify-cli
|
||||
```
|
||||
|
||||
#### Windows (PowerShell)
|
||||
|
||||
```powershell
|
||||
@@ -58,6 +64,16 @@ This will install the `coolify` binary in your `$GOPATH/bin` directory (usually
|
||||
|
||||
Now you can use the CLI with the token you just added.
|
||||
|
||||
## For LLMs / AI agents
|
||||
|
||||
- Quick instructions: [`llms.txt`](./llms.txt)
|
||||
- Full command catalog: [`llms-full.txt`](./llms-full.txt)
|
||||
- Regenerate both files:
|
||||
|
||||
```bash
|
||||
go run ./coolify docs llms
|
||||
```
|
||||
|
||||
## Change default context
|
||||
You can change the default context with `coolify context use <context_name>` or `coolify context set-default <context_name>`
|
||||
## Currently Supported Commands
|
||||
@@ -149,9 +165,9 @@ Commands can use `server` or `servers` interchangeably.
|
||||
- `--build-time` - Available at build time
|
||||
- `--is-literal` - Treat value as literal (don't interpolate variables)
|
||||
- `--is-multiline` - Value is multiline
|
||||
- `coolify app env update <app_uuid>` - Update an environment variable
|
||||
- `--key <key>` - Variable key (required)
|
||||
- `coolify app env update <app_uuid> <env_uuid_or_key>` - Update an environment variable
|
||||
- `--value <value>` - Variable value (required)
|
||||
- `--key <key>` - New variable key (optional, for renaming)
|
||||
- `--preview` - Available in preview deployments
|
||||
- `--build-time` - Available at build time
|
||||
- `--is-literal` - Treat value as literal (don't interpolate variables)
|
||||
@@ -239,9 +255,9 @@ Commands can use `server` or `servers` interchangeably.
|
||||
- `coolify service env get <service_uuid> <env_uuid_or_key>` - Get a specific environment variable
|
||||
- `coolify service env create <service_uuid>` - Create a new environment variable
|
||||
- Same flags as application environment variables
|
||||
- `coolify service env update <service_uuid>` - Update an environment variable
|
||||
- `--key <key>` - Variable key (required)
|
||||
- `coolify service env update <service_uuid> <env_uuid_or_key>` - Update an environment variable
|
||||
- `--value <value>` - Variable value (required)
|
||||
- `--key <key>` - New variable key (optional, for renaming)
|
||||
- `--build-time` - Available at build time
|
||||
- `--is-literal` - Treat value as literal (don't interpolate variables)
|
||||
- `--is-multiline` - Value is multiline
|
||||
@@ -257,10 +273,16 @@ Commands can use `server` or `servers` interchangeably.
|
||||
### Deployments
|
||||
- `coolify deploy uuid <uuid>` - Deploy a resource by UUID
|
||||
- `-f, --force` - Force deployment
|
||||
- `--pull-request-id <id>` - Pull request ID for preview deployments
|
||||
- `--docker-tag <tag>` - Docker image tag override for the deployment (requires Coolify `4.0.0-beta.471+`)
|
||||
- `coolify deploy name <name>` - Deploy a resource by name
|
||||
- `-f, --force` - Force deployment
|
||||
- `--pull-request-id <id>` - Pull request ID for preview deployments
|
||||
- `--docker-tag <tag>` - Docker image tag override for the deployment (requires Coolify `4.0.0-beta.471+`)
|
||||
- `coolify deploy batch <name1,name2,...>` - Deploy multiple resources at once
|
||||
- `-f, --force` - Force all deployments
|
||||
- `--pull-request-id <id>` - Pull request ID for preview deployments
|
||||
- `--docker-tag <tag>` - Docker image tag override for the deployment (requires Coolify `4.0.0-beta.471+`)
|
||||
- `coolify deploy list` - List all deployments
|
||||
- `coolify deploy get <uuid>` - Get deployment details
|
||||
- `coolify deploy cancel <uuid>` - Cancel a deployment
|
||||
@@ -421,6 +443,9 @@ coolify deploy batch api,worker,frontend
|
||||
# Force deploy with specific context
|
||||
coolify --context=prod deploy batch api,worker --force
|
||||
|
||||
# Deploy a preview with an explicit docker tag
|
||||
coolify deploy uuid u5ualfp30j27qtfpgcen8p03 --pull-request-id 2345 --docker-tag 1.28.3
|
||||
|
||||
# Traditional UUID deployment still works
|
||||
coolify deploy uuid abc123-def456-...
|
||||
|
||||
|
||||
@@ -5,6 +5,8 @@ import (
|
||||
|
||||
"github.com/coollabsio/coolify-cli/cmd/application/create"
|
||||
"github.com/coollabsio/coolify-cli/cmd/application/env"
|
||||
"github.com/coollabsio/coolify-cli/cmd/application/previews"
|
||||
"github.com/coollabsio/coolify-cli/cmd/application/storage"
|
||||
)
|
||||
|
||||
// NewAppCommand creates the app parent command
|
||||
@@ -43,5 +45,28 @@ func NewAppCommand() *cobra.Command {
|
||||
envCmd.AddCommand(env.NewSyncEnvCommand())
|
||||
cmd.AddCommand(envCmd)
|
||||
|
||||
// Add storage subcommand with its children
|
||||
storageCmd := &cobra.Command{
|
||||
Use: "storage",
|
||||
Aliases: []string{"storages"},
|
||||
Short: "Manage application storages",
|
||||
Long: `List and manage persistent volumes and file storages for applications.`,
|
||||
}
|
||||
storageCmd.AddCommand(storage.NewListCommand())
|
||||
storageCmd.AddCommand(storage.NewCreateCommand())
|
||||
storageCmd.AddCommand(storage.NewUpdateCommand())
|
||||
storageCmd.AddCommand(storage.NewDeleteCommand())
|
||||
cmd.AddCommand(storageCmd)
|
||||
|
||||
// Add previews subcommand with its children
|
||||
previewsCmd := &cobra.Command{
|
||||
Use: "previews",
|
||||
Aliases: []string{"preview"},
|
||||
Short: "Manage application preview deployments",
|
||||
Long: `Manage preview deployments created from pull requests. Requires the application UUID.`,
|
||||
}
|
||||
previewsCmd.AddCommand(previews.NewDeletePreviewCommand())
|
||||
cmd.AddCommand(previewsCmd)
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
@@ -93,6 +93,7 @@ Examples:
|
||||
setOptionalBoolFlag(cmd, "instant-deploy", &req.InstantDeploy)
|
||||
setOptionalBoolFlag(cmd, "health-check-enabled", &req.HealthCheckEnabled)
|
||||
setOptionalStringFlag(cmd, "health-check-path", &req.HealthCheckPath)
|
||||
setOptionalStringFlag(cmd, "dockerfile-target-build", &req.DockerfileTargetBuild)
|
||||
|
||||
client, err := cli.GetAPIClient(cmd)
|
||||
if err != nil {
|
||||
@@ -147,6 +148,7 @@ Examples:
|
||||
cmd.Flags().String("limits-memory", "", "Memory limit")
|
||||
cmd.Flags().Bool("health-check-enabled", false, "Enable health checks")
|
||||
cmd.Flags().String("health-check-path", "", "Health check path")
|
||||
cmd.Flags().String("dockerfile-target-build", "", "Dockerfile target build stage")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
@@ -70,6 +70,7 @@ Examples:
|
||||
setOptionalBoolFlag(cmd, "instant-deploy", &req.InstantDeploy)
|
||||
setOptionalBoolFlag(cmd, "health-check-enabled", &req.HealthCheckEnabled)
|
||||
setOptionalStringFlag(cmd, "health-check-path", &req.HealthCheckPath)
|
||||
setOptionalStringFlag(cmd, "dockerfile-target-build", &req.DockerfileTargetBuild)
|
||||
|
||||
client, err := cli.GetAPIClient(cmd)
|
||||
if err != nil {
|
||||
@@ -115,6 +116,7 @@ Examples:
|
||||
cmd.Flags().String("limits-memory", "", "Memory limit")
|
||||
cmd.Flags().Bool("health-check-enabled", false, "Enable health checks")
|
||||
cmd.Flags().String("health-check-path", "", "Health check path")
|
||||
cmd.Flags().String("dockerfile-target-build", "", "Dockerfile target build stage")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
@@ -76,6 +76,7 @@ Examples:
|
||||
setOptionalBoolFlag(cmd, "instant-deploy", &req.InstantDeploy)
|
||||
setOptionalBoolFlag(cmd, "health-check-enabled", &req.HealthCheckEnabled)
|
||||
setOptionalStringFlag(cmd, "health-check-path", &req.HealthCheckPath)
|
||||
setOptionalStringFlag(cmd, "dockerfile-target-build", &req.DockerfileTargetBuild)
|
||||
|
||||
client, err := cli.GetAPIClient(cmd)
|
||||
if err != nil {
|
||||
@@ -122,6 +123,7 @@ Examples:
|
||||
cmd.Flags().String("limits-memory", "", "Memory limit")
|
||||
cmd.Flags().Bool("health-check-enabled", false, "Enable health checks")
|
||||
cmd.Flags().String("health-check-path", "", "Health check path")
|
||||
cmd.Flags().String("dockerfile-target-build", "", "Dockerfile target build stage")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
@@ -94,6 +94,7 @@ Examples:
|
||||
setOptionalBoolFlag(cmd, "instant-deploy", &req.InstantDeploy)
|
||||
setOptionalBoolFlag(cmd, "health-check-enabled", &req.HealthCheckEnabled)
|
||||
setOptionalStringFlag(cmd, "health-check-path", &req.HealthCheckPath)
|
||||
setOptionalStringFlag(cmd, "dockerfile-target-build", &req.DockerfileTargetBuild)
|
||||
|
||||
client, err := cli.GetAPIClient(cmd)
|
||||
if err != nil {
|
||||
@@ -148,6 +149,7 @@ Examples:
|
||||
cmd.Flags().String("limits-memory", "", "Memory limit")
|
||||
cmd.Flags().Bool("health-check-enabled", false, "Enable health checks")
|
||||
cmd.Flags().String("health-check-path", "", "Health check path")
|
||||
cmd.Flags().String("dockerfile-target-build", "", "Dockerfile target build stage")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
@@ -85,6 +85,7 @@ Examples:
|
||||
setOptionalBoolFlag(cmd, "instant-deploy", &req.InstantDeploy)
|
||||
setOptionalBoolFlag(cmd, "health-check-enabled", &req.HealthCheckEnabled)
|
||||
setOptionalStringFlag(cmd, "health-check-path", &req.HealthCheckPath)
|
||||
setOptionalStringFlag(cmd, "dockerfile-target-build", &req.DockerfileTargetBuild)
|
||||
|
||||
client, err := cli.GetAPIClient(cmd)
|
||||
if err != nil {
|
||||
@@ -138,6 +139,7 @@ Examples:
|
||||
cmd.Flags().String("limits-memory", "", "Memory limit")
|
||||
cmd.Flags().Bool("health-check-enabled", false, "Enable health checks")
|
||||
cmd.Flags().String("health-check-path", "", "Health check path")
|
||||
cmd.Flags().String("dockerfile-target-build", "", "Dockerfile target build stage")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
Vendored
+6
-1
@@ -60,6 +60,10 @@ func NewCreateEnvCommand() *cobra.Command {
|
||||
if cmd.Flags().Changed("runtime") {
|
||||
req.IsRuntime = &isRuntime
|
||||
}
|
||||
if cmd.Flags().Changed("comment") {
|
||||
comment, _ := cmd.Flags().GetString("comment")
|
||||
req.Comment = &comment
|
||||
}
|
||||
|
||||
appSvc := service.NewApplicationService(client)
|
||||
env, err := appSvc.CreateEnv(ctx, appUUID, req)
|
||||
@@ -67,7 +71,7 @@ func NewCreateEnvCommand() *cobra.Command {
|
||||
return fmt.Errorf("failed to create environment variable: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("Environment variable '%s' created successfully.\n", env.Key)
|
||||
fmt.Printf("Environment variable '%s' created successfully.\n", key)
|
||||
fmt.Printf("UUID: %s\n", env.UUID)
|
||||
return nil
|
||||
},
|
||||
@@ -80,5 +84,6 @@ func NewCreateEnvCommand() *cobra.Command {
|
||||
cmd.Flags().Bool("is-literal", false, "Treat value as literal (don't interpolate variables)")
|
||||
cmd.Flags().Bool("is-multiline", false, "Value is multiline")
|
||||
cmd.Flags().Bool("runtime", true, "Available at runtime (default: true)")
|
||||
cmd.Flags().String("comment", "", "Comment for the environment variable")
|
||||
return cmd
|
||||
}
|
||||
|
||||
Vendored
+23
-9
@@ -12,13 +12,14 @@ import (
|
||||
|
||||
func NewUpdateEnvCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "update <app_uuid>",
|
||||
Use: "update <app_uuid> <env_uuid_or_key>",
|
||||
Short: "Update an environment variable",
|
||||
Long: `Update an existing environment variable. UUID is the application.`,
|
||||
Args: cli.ExactArgs(1, "<app_uuid>"),
|
||||
Long: `Update an existing environment variable. Identify it by UUID or key name.`,
|
||||
Args: cli.ExactArgs(2, "<app_uuid> <env_uuid_or_key>"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := cmd.Context()
|
||||
appUUID := args[0]
|
||||
envIdentifier := args[1]
|
||||
|
||||
client, err := cli.GetAPIClient(cmd)
|
||||
if err != nil {
|
||||
@@ -30,12 +31,24 @@ func NewUpdateEnvCommand() *cobra.Command {
|
||||
return err
|
||||
}
|
||||
|
||||
appSvc := service.NewApplicationService(client)
|
||||
|
||||
// Look up the env var to resolve its key
|
||||
existingEnv, err := appSvc.GetEnv(ctx, appUUID, envIdentifier)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to find environment variable '%s': %w", envIdentifier, err)
|
||||
}
|
||||
|
||||
req := &models.EnvironmentVariableUpdateRequest{}
|
||||
|
||||
// Use existing key unless --key flag explicitly provides a new one
|
||||
if cmd.Flags().Changed("key") {
|
||||
key, _ := cmd.Flags().GetString("key")
|
||||
req.Key = &key
|
||||
} else {
|
||||
req.Key = &existingEnv.Key
|
||||
}
|
||||
|
||||
if cmd.Flags().Changed("value") {
|
||||
value, _ := cmd.Flags().GetString("value")
|
||||
req.Value = &value
|
||||
@@ -60,15 +73,15 @@ func NewUpdateEnvCommand() *cobra.Command {
|
||||
isRuntime, _ := cmd.Flags().GetBool("runtime")
|
||||
req.IsRuntime = &isRuntime
|
||||
}
|
||||
|
||||
if req.Key == nil {
|
||||
return fmt.Errorf("--key is required")
|
||||
if cmd.Flags().Changed("comment") {
|
||||
comment, _ := cmd.Flags().GetString("comment")
|
||||
req.Comment = &comment
|
||||
}
|
||||
|
||||
if req.Value == nil {
|
||||
return fmt.Errorf("--value is required")
|
||||
}
|
||||
|
||||
appSvc := service.NewApplicationService(client)
|
||||
env, err := appSvc.UpdateEnv(ctx, appUUID, req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to update environment variable: %w", err)
|
||||
@@ -79,12 +92,13 @@ func NewUpdateEnvCommand() *cobra.Command {
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().String("key", "", "New environment variable key")
|
||||
cmd.Flags().String("value", "", "New environment variable value")
|
||||
cmd.Flags().String("key", "", "New environment variable key (rename)")
|
||||
cmd.Flags().String("value", "", "New environment variable value (required)")
|
||||
cmd.Flags().Bool("build-time", true, "Available at build time (default: true)")
|
||||
cmd.Flags().Bool("preview", false, "Available in preview deployments")
|
||||
cmd.Flags().Bool("is-literal", false, "Treat value as literal")
|
||||
cmd.Flags().Bool("is-multiline", false, "Value is multiline")
|
||||
cmd.Flags().Bool("runtime", true, "Available at runtime (default: true)")
|
||||
cmd.Flags().String("comment", "", "Comment for the environment variable")
|
||||
return cmd
|
||||
}
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
package previews
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/coollabsio/coolify-cli/internal/cli"
|
||||
"github.com/coollabsio/coolify-cli/internal/service"
|
||||
)
|
||||
|
||||
func NewDeletePreviewCommand() *cobra.Command {
|
||||
deletePreviewCmd := &cobra.Command{
|
||||
Use: "delete <app_uuid> <pr_id>",
|
||||
Short: "Delete a preview deployment",
|
||||
Long: `Delete a preview deployment for an application. First argument is the application UUID, second is the pull request ID.`,
|
||||
Args: cli.ExactArgs(2, "<app_uuid> <pr_id>"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := cmd.Context()
|
||||
appUUID := args[0]
|
||||
prID := args[1]
|
||||
|
||||
prIDInt, err := strconv.Atoi(prID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid pr_id: must be an integer")
|
||||
}
|
||||
if prIDInt <= 0 {
|
||||
return fmt.Errorf("invalid pr_id: must be a positive integer")
|
||||
}
|
||||
|
||||
client, err := cli.GetAPIClient(cmd)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get API client: %w", err)
|
||||
}
|
||||
|
||||
if err := cli.CheckMinimumVersion(ctx, client, "4.0.0-beta.474"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
force, _ := cmd.Flags().GetBool("force")
|
||||
|
||||
// Prompt for confirmation unless --force is used
|
||||
if !force {
|
||||
var response string
|
||||
fmt.Printf("Are you sure you want to delete the preview deployment for PR %s? (yes/no): ", prID)
|
||||
_, err := fmt.Scanln(&response)
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read confirmation: %w", err)
|
||||
}
|
||||
|
||||
if response != "yes" && response != "y" {
|
||||
fmt.Println("Delete cancelled.")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
appSvc := service.NewApplicationService(client)
|
||||
err = appSvc.DeletePreview(ctx, appUUID, prID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete preview deployment: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("Preview deployment for PR %s deleted successfully.\n", prID)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
deletePreviewCmd.Flags().Bool("force", false, "Skip confirmation prompt")
|
||||
return deletePreviewCmd
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/coollabsio/coolify-cli/internal/cli"
|
||||
"github.com/coollabsio/coolify-cli/internal/models"
|
||||
"github.com/coollabsio/coolify-cli/internal/service"
|
||||
)
|
||||
|
||||
// NewCreateCommand returns the storage create command
|
||||
func NewCreateCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "create <app_uuid>",
|
||||
Short: "Create a storage for an application",
|
||||
Long: `Create a persistent volume or file storage for an application.
|
||||
|
||||
Examples:
|
||||
coolify app storage create <app_uuid> --type persistent --name my-volume --mount-path /data
|
||||
coolify app storage create <app_uuid> --type persistent --name my-volume --mount-path /data --host-path /var/data
|
||||
coolify app storage create <app_uuid> --type file --mount-path /app/config.yml --content "key: value"
|
||||
coolify app storage create <app_uuid> --type file --mount-path /app/data --is-directory --fs-path /app/data`,
|
||||
Args: cli.ExactArgs(1, "<app_uuid>"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := cmd.Context()
|
||||
|
||||
storageType, _ := cmd.Flags().GetString("type")
|
||||
mountPath, _ := cmd.Flags().GetString("mount-path")
|
||||
|
||||
if storageType == "" {
|
||||
return fmt.Errorf("--type is required (persistent or file)")
|
||||
}
|
||||
if storageType != "persistent" && storageType != "file" {
|
||||
return fmt.Errorf("--type must be 'persistent' or 'file'")
|
||||
}
|
||||
if mountPath == "" {
|
||||
return fmt.Errorf("--mount-path is required")
|
||||
}
|
||||
|
||||
req := &models.StorageCreateRequest{
|
||||
Type: storageType,
|
||||
MountPath: mountPath,
|
||||
}
|
||||
|
||||
if cmd.Flags().Changed("name") {
|
||||
val, _ := cmd.Flags().GetString("name")
|
||||
req.Name = &val
|
||||
}
|
||||
if cmd.Flags().Changed("host-path") {
|
||||
val, _ := cmd.Flags().GetString("host-path")
|
||||
req.HostPath = &val
|
||||
}
|
||||
if cmd.Flags().Changed("content") {
|
||||
val, _ := cmd.Flags().GetString("content")
|
||||
req.Content = &val
|
||||
}
|
||||
if cmd.Flags().Changed("is-directory") {
|
||||
val, _ := cmd.Flags().GetBool("is-directory")
|
||||
req.IsDirectory = &val
|
||||
}
|
||||
if cmd.Flags().Changed("fs-path") {
|
||||
val, _ := cmd.Flags().GetString("fs-path")
|
||||
req.FsPath = &val
|
||||
}
|
||||
|
||||
client, err := cli.GetAPIClient(cmd)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get API client: %w", err)
|
||||
}
|
||||
|
||||
if err := cli.CheckMinimumVersion(ctx, client, "4.0.0-beta.470"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
appSvc := service.NewApplicationService(client)
|
||||
if err := appSvc.CreateStorage(ctx, args[0], req); err != nil {
|
||||
return fmt.Errorf("failed to create storage: %w", err)
|
||||
}
|
||||
|
||||
fmt.Println("Storage created successfully.")
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().String("type", "", "Storage type: 'persistent' or 'file' (required)")
|
||||
cmd.Flags().String("mount-path", "", "Mount path inside the container (required)")
|
||||
cmd.Flags().String("name", "", "Volume name (persistent only)")
|
||||
cmd.Flags().String("host-path", "", "Host path (persistent only)")
|
||||
cmd.Flags().String("content", "", "File content (file only)")
|
||||
cmd.Flags().Bool("is-directory", false, "Whether this is a directory mount (file only)")
|
||||
cmd.Flags().String("fs-path", "", "Host directory path (file only, required when --is-directory is set)")
|
||||
|
||||
return cmd
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/coollabsio/coolify-cli/internal/cli"
|
||||
"github.com/coollabsio/coolify-cli/internal/service"
|
||||
)
|
||||
|
||||
// NewDeleteCommand returns the storage delete command
|
||||
func NewDeleteCommand() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "delete <app_uuid> <storage_uuid>",
|
||||
Short: "Delete a storage from an application",
|
||||
Long: `Delete a persistent volume or file storage from an application.
|
||||
|
||||
Examples:
|
||||
coolify app storage delete <app_uuid> <storage_uuid>`,
|
||||
Args: cli.ExactArgs(2, "<app_uuid> <storage_uuid>"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := cmd.Context()
|
||||
|
||||
client, err := cli.GetAPIClient(cmd)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get API client: %w", err)
|
||||
}
|
||||
|
||||
if err := cli.CheckMinimumVersion(ctx, client, "4.0.0-beta.470"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
appSvc := service.NewApplicationService(client)
|
||||
if err := appSvc.DeleteStorage(ctx, args[0], args[1]); err != nil {
|
||||
return fmt.Errorf("failed to delete storage: %w", err)
|
||||
}
|
||||
|
||||
fmt.Println("Storage deleted successfully.")
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/coollabsio/coolify-cli/internal/cli"
|
||||
"github.com/coollabsio/coolify-cli/internal/output"
|
||||
"github.com/coollabsio/coolify-cli/internal/service"
|
||||
)
|
||||
|
||||
// NewListCommand returns the storage list command
|
||||
func NewListCommand() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "list <app_uuid>",
|
||||
Short: "List all storages for an application",
|
||||
Long: `List all persistent volumes and file storages for a specific application.`,
|
||||
Args: cli.ExactArgs(1, "<app_uuid>"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := cmd.Context()
|
||||
|
||||
client, err := cli.GetAPIClient(cmd)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get API client: %w", err)
|
||||
}
|
||||
|
||||
if err := cli.CheckMinimumVersion(ctx, client, "4.0.0-beta.470"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
appSvc := service.NewApplicationService(client)
|
||||
storages, err := appSvc.ListStorages(ctx, args[0])
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to list storages: %w", err)
|
||||
}
|
||||
|
||||
format, _ := cmd.Flags().GetString("format")
|
||||
showSensitive, _ := cmd.Flags().GetBool("show-sensitive")
|
||||
|
||||
formatter, err := output.NewFormatter(format, output.Options{
|
||||
ShowSensitive: showSensitive,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return formatter.Format(storages)
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/coollabsio/coolify-cli/internal/cli"
|
||||
"github.com/coollabsio/coolify-cli/internal/models"
|
||||
"github.com/coollabsio/coolify-cli/internal/service"
|
||||
)
|
||||
|
||||
// NewUpdateCommand returns the storage update command
|
||||
func NewUpdateCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "update <app_uuid>",
|
||||
Short: "Update a storage for an application",
|
||||
Long: `Update a persistent volume or file storage for an application.
|
||||
|
||||
The --uuid and --type flags are required. Use 'coolify app storage list' to find storage UUIDs.
|
||||
|
||||
For read-only storages (from docker-compose or services), only --is-preview-suffix-enabled can be updated.
|
||||
|
||||
Examples:
|
||||
coolify app storage update <app_uuid> --uuid <storage_uuid> --type persistent --name my-volume --mount-path /data
|
||||
coolify app storage update <app_uuid> --uuid <storage_uuid> --type file --content "config content" --mount-path /app/config.yml
|
||||
coolify app storage update <app_uuid> --uuid <storage_uuid> --type persistent --is-preview-suffix-enabled`,
|
||||
Args: cli.ExactArgs(1, "<app_uuid>"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := cmd.Context()
|
||||
|
||||
storageUUID, _ := cmd.Flags().GetString("uuid")
|
||||
storageID, _ := cmd.Flags().GetInt("id")
|
||||
storageType, _ := cmd.Flags().GetString("type")
|
||||
|
||||
if storageUUID == "" && storageID == 0 {
|
||||
return fmt.Errorf("--uuid is required (or --id as deprecated fallback)")
|
||||
}
|
||||
if storageType == "" {
|
||||
return fmt.Errorf("--type is required (persistent or file)")
|
||||
}
|
||||
if storageType != "persistent" && storageType != "file" {
|
||||
return fmt.Errorf("--type must be 'persistent' or 'file'")
|
||||
}
|
||||
|
||||
req := &models.StorageUpdateRequest{
|
||||
Type: storageType,
|
||||
}
|
||||
|
||||
if storageUUID != "" {
|
||||
req.UUID = &storageUUID
|
||||
} else {
|
||||
req.ID = &storageID
|
||||
}
|
||||
|
||||
hasUpdates := false
|
||||
|
||||
if cmd.Flags().Changed("is-preview-suffix-enabled") {
|
||||
val, _ := cmd.Flags().GetBool("is-preview-suffix-enabled")
|
||||
req.IsPreviewSuffixEnabled = &val
|
||||
hasUpdates = true
|
||||
}
|
||||
if cmd.Flags().Changed("name") {
|
||||
val, _ := cmd.Flags().GetString("name")
|
||||
req.Name = &val
|
||||
hasUpdates = true
|
||||
}
|
||||
if cmd.Flags().Changed("mount-path") {
|
||||
val, _ := cmd.Flags().GetString("mount-path")
|
||||
req.MountPath = &val
|
||||
hasUpdates = true
|
||||
}
|
||||
if cmd.Flags().Changed("host-path") {
|
||||
val, _ := cmd.Flags().GetString("host-path")
|
||||
req.HostPath = &val
|
||||
hasUpdates = true
|
||||
}
|
||||
if cmd.Flags().Changed("content") {
|
||||
val, _ := cmd.Flags().GetString("content")
|
||||
req.Content = &val
|
||||
hasUpdates = true
|
||||
}
|
||||
|
||||
if !hasUpdates {
|
||||
return fmt.Errorf("no fields to update. Use --help to see available flags")
|
||||
}
|
||||
|
||||
client, err := cli.GetAPIClient(cmd)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get API client: %w", err)
|
||||
}
|
||||
|
||||
if err := cli.CheckMinimumVersion(ctx, client, "4.0.0-beta.470"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
appSvc := service.NewApplicationService(client)
|
||||
if err := appSvc.UpdateStorage(ctx, args[0], req); err != nil {
|
||||
return fmt.Errorf("failed to update storage: %w", err)
|
||||
}
|
||||
|
||||
fmt.Println("Storage updated successfully.")
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().String("uuid", "", "Storage UUID (required, use 'storage list' to find)")
|
||||
cmd.Flags().Int("id", 0, "Storage ID (deprecated, use --uuid instead)")
|
||||
cmd.Flags().String("type", "", "Storage type: 'persistent' or 'file' (required)")
|
||||
cmd.Flags().Bool("is-preview-suffix-enabled", false, "Enable preview suffix for this storage")
|
||||
cmd.Flags().String("name", "", "Storage name (persistent only)")
|
||||
cmd.Flags().String("mount-path", "", "Mount path inside the container")
|
||||
cmd.Flags().String("host-path", "", "Host path (persistent only)")
|
||||
cmd.Flags().String("content", "", "File content (file only)")
|
||||
|
||||
return cmd
|
||||
}
|
||||
@@ -104,6 +104,11 @@ func NewUpdateCommand() *cobra.Command {
|
||||
req.PortsMappings = &ports
|
||||
hasUpdates = true
|
||||
}
|
||||
if cmd.Flags().Changed("dockerfile-target-build") {
|
||||
targetBuild, _ := cmd.Flags().GetString("dockerfile-target-build")
|
||||
req.DockerfileTargetBuild = &targetBuild
|
||||
hasUpdates = true
|
||||
}
|
||||
if cmd.Flags().Changed("health-check-enabled") {
|
||||
enabled, _ := cmd.Flags().GetBool("health-check-enabled")
|
||||
req.HealthCheckEnabled = &enabled
|
||||
@@ -152,6 +157,7 @@ func NewUpdateCommand() *cobra.Command {
|
||||
cmd.Flags().String("dockerfile", "", "Dockerfile content")
|
||||
cmd.Flags().String("docker-image", "", "Docker image name")
|
||||
cmd.Flags().String("docker-tag", "", "Docker image tag")
|
||||
cmd.Flags().String("dockerfile-target-build", "", "Dockerfile target build stage")
|
||||
cmd.Flags().String("ports-exposes", "", "Exposed ports")
|
||||
cmd.Flags().String("ports-mappings", "", "Port mappings")
|
||||
cmd.Flags().Bool("health-check-enabled", false, "Enable health check")
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
// Package common holds flag structs and helpers shared between the
|
||||
// `coolify init` and `coolify firewall` command trees. Kept intentionally
|
||||
// small: only cross-command plumbing (SSH mesh flags, namespace validation)
|
||||
// lives here.
|
||||
//
|
||||
//nolint:revive // "common" is the conventional sharing point for these cobra subtrees
|
||||
package common
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// DefaultNamespace is the namespace used when the user does not pass
|
||||
// --namespaces. It is also always present (implicitly) so existing workflows
|
||||
// and coold defaults keep working.
|
||||
const DefaultNamespace = "default"
|
||||
|
||||
// PodmanNetworkFor returns the podman bridge network name that backs
|
||||
// namespace ns on every host. Derived as `coolify-<ns>-mesh` so the
|
||||
// namespace name is visible in `podman network ls`.
|
||||
func PodmanNetworkFor(ns string) string {
|
||||
return "coolify-" + ns + "-mesh"
|
||||
}
|
||||
|
||||
// MeshNetFlags holds the flag set shared between `coolify init` (which creates
|
||||
// per-namespace podman networks on every host) and `coolify firewall` (which
|
||||
// talks to coold about per-namespace rules).
|
||||
//
|
||||
// `init` binds it as a slice so a single command sets up the entire cluster;
|
||||
// `firewall` binds it as a single value since each allow/revoke/list call
|
||||
// operates on one namespace at a time.
|
||||
type MeshNetFlags struct {
|
||||
// Namespaces enumerates every namespace the mesh should carry. At least
|
||||
// one entry is required; the first element is the implicit "default"
|
||||
// unless the user overrides it.
|
||||
Namespaces []string
|
||||
|
||||
// ContainerPool is the shared address pool every namespace carves its
|
||||
// per-host /<ContainerPrefix> from. One pool covers all namespaces;
|
||||
// subnets never overlap.
|
||||
ContainerPool string
|
||||
|
||||
// ContainerPrefix is the prefix length of each per-host, per-namespace
|
||||
// container subnet (default 24 → 254 container IPs per host per ns).
|
||||
ContainerPrefix int
|
||||
}
|
||||
|
||||
// BindMeshNetMultiFlags registers --namespaces/--container-pool/--container-prefix
|
||||
// on cmd (init-style: many namespaces per invocation).
|
||||
func BindMeshNetMultiFlags(cmd *cobra.Command, f *MeshNetFlags) {
|
||||
pf := cmd.PersistentFlags()
|
||||
pf.StringSliceVar(&f.Namespaces, "namespaces", []string{DefaultNamespace},
|
||||
"Comma-separated list of namespaces to create on each host. Each "+
|
||||
"namespace is a separate Podman bridge network (coolify-<ns>-mesh) "+
|
||||
"with its own /<container-prefix> per host")
|
||||
pf.StringVar(&f.ContainerPool, "container-pool", "10.210.0.0/16",
|
||||
"Shared container address pool — each (namespace, host) pair gets a "+
|
||||
"/<container-prefix> from here, owned by that namespace's Podman bridge")
|
||||
pf.IntVar(&f.ContainerPrefix, "container-prefix", 24,
|
||||
"Prefix length of each per-host, per-namespace container subnet")
|
||||
}
|
||||
|
||||
// BindMeshNetSingleFlags registers --namespace on cmd (firewall-style: one
|
||||
// namespace per invocation).
|
||||
func BindMeshNetSingleFlags(cmd *cobra.Command, ns *string) {
|
||||
pf := cmd.PersistentFlags()
|
||||
pf.StringVar(ns, "namespace", DefaultNamespace,
|
||||
"Namespace the command operates against (must match a namespace created by `coolify init`)")
|
||||
}
|
||||
|
||||
// namespaceRegex matches a valid DNS label (namespace names appear in the
|
||||
// podman network name, in iptables chain names, and — post-coold-changes —
|
||||
// as DNS labels like web.<ns>.coolify.internal).
|
||||
var namespaceRegex = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$`)
|
||||
|
||||
// ValidateNamespaces checks that every namespace is a valid DNS label and
|
||||
// that the list has no duplicates.
|
||||
func (f *MeshNetFlags) ValidateNamespaces() error {
|
||||
if len(f.Namespaces) == 0 {
|
||||
return fmt.Errorf("--namespaces must list at least one namespace")
|
||||
}
|
||||
seen := make(map[string]struct{}, len(f.Namespaces))
|
||||
for _, ns := range f.Namespaces {
|
||||
if !namespaceRegex.MatchString(ns) {
|
||||
return fmt.Errorf("invalid namespace %q (must be a DNS label: lowercase alphanumerics + '-', 1-63 chars)", ns)
|
||||
}
|
||||
if _, dup := seen[ns]; dup {
|
||||
return fmt.Errorf("duplicate namespace %q in --namespaces", ns)
|
||||
}
|
||||
seen[ns] = struct{}{}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateNamespace validates a single namespace value (used by the firewall
|
||||
// command's --namespace flag).
|
||||
func ValidateNamespace(ns string) error {
|
||||
if !namespaceRegex.MatchString(ns) {
|
||||
return fmt.Errorf("invalid --namespace %q (must be a DNS label: lowercase alphanumerics + '-', 1-63 chars)", ns)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
// Package common hosts flag sets and helpers shared between multiple
|
||||
// top-level commands that SSH into a list of servers (init, firewall, ...).
|
||||
package common
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"golang.org/x/term"
|
||||
|
||||
internalssh "github.com/coollabsio/coolify-cli/internal/ssh"
|
||||
)
|
||||
|
||||
// SSHMeshFlags holds the flags shared by every command that fans out over
|
||||
// a list of SSH-reachable servers (coolify init, coolify firewall, ...).
|
||||
type SSHMeshFlags struct {
|
||||
Servers []string
|
||||
SSHKey string
|
||||
SSHUser string
|
||||
SSHPort int
|
||||
SSHPassphrasePrompt bool
|
||||
Concurrency int
|
||||
SSHTimeout string
|
||||
}
|
||||
|
||||
// BindSSHMeshFlags registers the shared flags as PersistentFlags on cmd.
|
||||
func BindSSHMeshFlags(cmd *cobra.Command, f *SSHMeshFlags) {
|
||||
pf := cmd.PersistentFlags()
|
||||
|
||||
pf.StringSliceVar(&f.Servers, "servers", nil,
|
||||
"Comma-separated server IPs (required)")
|
||||
pf.StringVar(&f.SSHKey, "ssh-key", "",
|
||||
"Path to SSH private key used to connect to servers (required)")
|
||||
pf.StringVar(&f.SSHUser, "ssh-user", "root",
|
||||
"SSH username")
|
||||
pf.IntVar(&f.SSHPort, "ssh-port", 22,
|
||||
"SSH port")
|
||||
pf.BoolVar(&f.SSHPassphrasePrompt, "ssh-passphrase-prompt", false,
|
||||
"Prompt for SSH key passphrase (also reads COOLIFY_SSH_PASSPHRASE env var)")
|
||||
pf.IntVar(&f.Concurrency, "concurrency", 10,
|
||||
"Maximum number of parallel SSH connections")
|
||||
pf.StringVar(&f.SSHTimeout, "ssh-timeout", "30s",
|
||||
"SSH connection timeout (e.g. 30s, 1m)")
|
||||
}
|
||||
|
||||
// ParseSSHTimeout parses SSHTimeout, falling back to 30s on error/zero.
|
||||
func (f *SSHMeshFlags) ParseSSHTimeout() time.Duration {
|
||||
d, err := time.ParseDuration(f.SSHTimeout)
|
||||
if err != nil || d <= 0 {
|
||||
return 30 * time.Second
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
// ResolvePassphrase returns the SSH key passphrase in this priority order:
|
||||
// 1. COOLIFY_SSH_PASSPHRASE env var
|
||||
// 2. Interactive prompt when --ssh-passphrase-prompt is set
|
||||
// 3. nil (no passphrase)
|
||||
func (f *SSHMeshFlags) ResolvePassphrase() ([]byte, error) {
|
||||
if env := os.Getenv("COOLIFY_SSH_PASSPHRASE"); env != "" {
|
||||
return []byte(env), nil
|
||||
}
|
||||
if f.SSHPassphrasePrompt {
|
||||
fmt.Fprint(os.Stderr, "SSH key passphrase: ")
|
||||
pass, err := term.ReadPassword(int(os.Stdin.Fd()))
|
||||
fmt.Fprintln(os.Stderr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read passphrase: %w", err)
|
||||
}
|
||||
return pass, nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// BuildSSHClient creates an SSH client, resolving any key passphrase first.
|
||||
func (f *SSHMeshFlags) BuildSSHClient() (*internalssh.Client, error) {
|
||||
passphrase, err := f.ResolvePassphrase()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return internalssh.NewClient(f.SSHKey, passphrase, f.ParseSSHTimeout())
|
||||
}
|
||||
|
||||
// Validate checks that the required flags are set.
|
||||
func (f *SSHMeshFlags) Validate() error {
|
||||
if len(f.Servers) == 0 {
|
||||
return fmt.Errorf("--servers is required")
|
||||
}
|
||||
if f.SSHKey == "" {
|
||||
return fmt.Errorf("--ssh-key is required")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestSSHMeshFlags_ParseSSHTimeout(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
want time.Duration
|
||||
}{
|
||||
{"30s", 30 * time.Second},
|
||||
{"1m", time.Minute},
|
||||
{"invalid", 30 * time.Second},
|
||||
{"0s", 30 * time.Second},
|
||||
{"", 30 * time.Second},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
f := &SSHMeshFlags{SSHTimeout: tt.input}
|
||||
assert.Equal(t, tt.want, f.ParseSSHTimeout(), "input: %q", tt.input)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSSHMeshFlags_Validate(t *testing.T) {
|
||||
t.Run("missing servers", func(t *testing.T) {
|
||||
err := (&SSHMeshFlags{SSHKey: "/k"}).Validate()
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "--servers")
|
||||
})
|
||||
t.Run("missing ssh key", func(t *testing.T) {
|
||||
err := (&SSHMeshFlags{Servers: []string{"1.1.1.1"}}).Validate()
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "--ssh-key")
|
||||
})
|
||||
t.Run("valid", func(t *testing.T) {
|
||||
err := (&SSHMeshFlags{Servers: []string{"1.1.1.1"}, SSHKey: "/k"}).Validate()
|
||||
require.NoError(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
func TestSSHMeshFlags_ResolvePassphrase_Env(t *testing.T) {
|
||||
t.Setenv("COOLIFY_SSH_PASSPHRASE", "hunter2")
|
||||
pass, err := (&SSHMeshFlags{}).ResolvePassphrase()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []byte("hunter2"), pass)
|
||||
}
|
||||
|
||||
func TestSSHMeshFlags_ResolvePassphrase_NoPrompt(t *testing.T) {
|
||||
t.Setenv("COOLIFY_SSH_PASSPHRASE", "")
|
||||
pass, err := (&SSHMeshFlags{SSHPassphrasePrompt: false}).ResolvePassphrase()
|
||||
require.NoError(t, err)
|
||||
assert.Nil(t, pass)
|
||||
}
|
||||
@@ -10,10 +10,6 @@ import (
|
||||
func TestNewConfigCommand(t *testing.T) {
|
||||
cmd := NewConfigCommand()
|
||||
|
||||
if cmd == nil {
|
||||
t.Fatal("NewConfigCommand() returned nil")
|
||||
}
|
||||
|
||||
if cmd.Use != "config" {
|
||||
t.Errorf("Expected Use to be 'config', got '%s'", cmd.Use)
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ import (
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/coollabsio/coolify-cli/cmd/database/backup"
|
||||
"github.com/coollabsio/coolify-cli/cmd/database/env"
|
||||
"github.com/coollabsio/coolify-cli/cmd/database/storage"
|
||||
)
|
||||
|
||||
// NewDatabaseCommand creates the database parent command with all subcommands
|
||||
@@ -25,6 +27,19 @@ func NewDatabaseCommand() *cobra.Command {
|
||||
cmd.AddCommand(NewUpdateCommand())
|
||||
cmd.AddCommand(NewDeleteCommand())
|
||||
|
||||
// Add env subcommand
|
||||
envCmd := &cobra.Command{
|
||||
Use: "env",
|
||||
Short: "Manage database environment variables",
|
||||
}
|
||||
envCmd.AddCommand(env.NewListCommand())
|
||||
envCmd.AddCommand(env.NewGetCommand())
|
||||
envCmd.AddCommand(env.NewCreateCommand())
|
||||
envCmd.AddCommand(env.NewUpdateCommand())
|
||||
envCmd.AddCommand(env.NewDeleteCommand())
|
||||
envCmd.AddCommand(env.NewSyncCommand())
|
||||
cmd.AddCommand(envCmd)
|
||||
|
||||
// Add backup subcommand
|
||||
backupCmd := &cobra.Command{
|
||||
Use: "backup",
|
||||
@@ -39,5 +54,18 @@ func NewDatabaseCommand() *cobra.Command {
|
||||
backupCmd.AddCommand(backup.NewDeleteExecutionCommand())
|
||||
cmd.AddCommand(backupCmd)
|
||||
|
||||
// Add storage subcommand
|
||||
storageCmd := &cobra.Command{
|
||||
Use: "storage",
|
||||
Aliases: []string{"storages"},
|
||||
Short: "Manage database storages",
|
||||
Long: `List and manage persistent volumes and file storages for databases.`,
|
||||
}
|
||||
storageCmd.AddCommand(storage.NewListCommand())
|
||||
storageCmd.AddCommand(storage.NewCreateCommand())
|
||||
storageCmd.AddCommand(storage.NewUpdateCommand())
|
||||
storageCmd.AddCommand(storage.NewDeleteCommand())
|
||||
cmd.AddCommand(storageCmd)
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
Vendored
+79
@@ -0,0 +1,79 @@
|
||||
package env
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/coollabsio/coolify-cli/internal/cli"
|
||||
"github.com/coollabsio/coolify-cli/internal/models"
|
||||
"github.com/coollabsio/coolify-cli/internal/service"
|
||||
)
|
||||
|
||||
func NewCreateCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "create <database_uuid>",
|
||||
Short: "Create an environment variable for a database",
|
||||
Long: `Create a new environment variable for a specific database. Use --key and --value flags to specify the variable.`,
|
||||
Args: cli.ExactArgs(1, "<database_uuid>"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := cmd.Context()
|
||||
uuid := args[0]
|
||||
|
||||
client, err := cli.GetAPIClient(cmd)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get API client: %w", err)
|
||||
}
|
||||
|
||||
key, _ := cmd.Flags().GetString("key")
|
||||
value, _ := cmd.Flags().GetString("value")
|
||||
|
||||
if key == "" {
|
||||
return fmt.Errorf("--key is required")
|
||||
}
|
||||
if value == "" {
|
||||
return fmt.Errorf("--value is required")
|
||||
}
|
||||
|
||||
req := &models.DatabaseEnvironmentVariableCreateRequest{
|
||||
Key: key,
|
||||
Value: value,
|
||||
}
|
||||
|
||||
if cmd.Flags().Changed("is-literal") {
|
||||
isLiteral, _ := cmd.Flags().GetBool("is-literal")
|
||||
req.IsLiteral = &isLiteral
|
||||
}
|
||||
if cmd.Flags().Changed("is-multiline") {
|
||||
isMultiline, _ := cmd.Flags().GetBool("is-multiline")
|
||||
req.IsMultiline = &isMultiline
|
||||
}
|
||||
if cmd.Flags().Changed("is-shown-once") {
|
||||
isShownOnce, _ := cmd.Flags().GetBool("is-shown-once")
|
||||
req.IsShownOnce = &isShownOnce
|
||||
}
|
||||
if cmd.Flags().Changed("comment") {
|
||||
comment, _ := cmd.Flags().GetString("comment")
|
||||
req.Comment = &comment
|
||||
}
|
||||
|
||||
dbSvc := service.NewDatabaseService(client)
|
||||
_, err = dbSvc.CreateEnv(ctx, uuid, req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create environment variable: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("Environment variable '%s' created successfully.\n", key)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().String("key", "", "Environment variable key (required)")
|
||||
cmd.Flags().String("value", "", "Environment variable value (required)")
|
||||
cmd.Flags().Bool("is-literal", false, "Treat value as literal (don't interpolate variables)")
|
||||
cmd.Flags().Bool("is-multiline", false, "Value is multiline")
|
||||
cmd.Flags().Bool("is-shown-once", false, "Only show value once")
|
||||
cmd.Flags().String("comment", "", "Comment for the environment variable")
|
||||
|
||||
return cmd
|
||||
}
|
||||
Vendored
+56
@@ -0,0 +1,56 @@
|
||||
package env
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/coollabsio/coolify-cli/internal/cli"
|
||||
"github.com/coollabsio/coolify-cli/internal/service"
|
||||
)
|
||||
|
||||
func NewDeleteCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "delete <database_uuid> <env_uuid>",
|
||||
Short: "Delete an environment variable",
|
||||
Long: `Delete an environment variable from a database. First UUID is the database, second is the specific environment variable to delete.`,
|
||||
Args: cli.ExactArgs(2, "<uuid1> <uuid2>"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := cmd.Context()
|
||||
dbUUID := args[0]
|
||||
envUUID := args[1]
|
||||
|
||||
client, err := cli.GetAPIClient(cmd)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get API client: %w", err)
|
||||
}
|
||||
|
||||
force, _ := cmd.Flags().GetBool("force")
|
||||
|
||||
// Prompt for confirmation unless --force is used
|
||||
if !force {
|
||||
var response string
|
||||
fmt.Printf("Are you sure you want to delete this environment variable? (yes/no): ")
|
||||
_, _ = fmt.Scanln(&response)
|
||||
|
||||
if response != "yes" && response != "y" {
|
||||
fmt.Println("Delete cancelled.")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
dbSvc := service.NewDatabaseService(client)
|
||||
err = dbSvc.DeleteEnv(ctx, dbUUID, envUUID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete environment variable: %w", err)
|
||||
}
|
||||
|
||||
fmt.Println("Environment variable deleted successfully.")
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().Bool("force", false, "Skip confirmation prompt")
|
||||
|
||||
return cmd
|
||||
}
|
||||
Vendored
+57
@@ -0,0 +1,57 @@
|
||||
package env
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/coollabsio/coolify-cli/internal/cli"
|
||||
"github.com/coollabsio/coolify-cli/internal/output"
|
||||
"github.com/coollabsio/coolify-cli/internal/service"
|
||||
)
|
||||
|
||||
func NewGetCommand() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "get <database_uuid> <env_uuid_or_key>",
|
||||
Short: "Get environment variable details",
|
||||
Long: `Get detailed information about a specific environment variable. First UUID is the database, second is the environment variable UUID or key name.`,
|
||||
Args: cli.ExactArgs(2, "<uuid1> <uuid2>"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := cmd.Context()
|
||||
dbUUID := args[0]
|
||||
envUUID := args[1]
|
||||
|
||||
client, err := cli.GetAPIClient(cmd)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get API client: %w", err)
|
||||
}
|
||||
|
||||
dbSvc := service.NewDatabaseService(client)
|
||||
env, err := dbSvc.GetEnv(ctx, dbUUID, envUUID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get environment variable: %w", err)
|
||||
}
|
||||
|
||||
showSensitive, _ := cmd.Flags().GetBool("show-sensitive")
|
||||
|
||||
// Mask sensitive value unless --show-sensitive is used
|
||||
if !showSensitive {
|
||||
env.Value = "********"
|
||||
if env.RealValue != nil {
|
||||
masked := "********"
|
||||
env.RealValue = &masked
|
||||
}
|
||||
}
|
||||
|
||||
format, _ := cmd.Flags().GetString("format")
|
||||
formatter, err := output.NewFormatter(format, output.Options{
|
||||
ShowSensitive: showSensitive,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create formatter: %w", err)
|
||||
}
|
||||
|
||||
return formatter.Format(env)
|
||||
},
|
||||
}
|
||||
}
|
||||
Vendored
+58
@@ -0,0 +1,58 @@
|
||||
package env
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/coollabsio/coolify-cli/internal/cli"
|
||||
"github.com/coollabsio/coolify-cli/internal/output"
|
||||
"github.com/coollabsio/coolify-cli/internal/service"
|
||||
)
|
||||
|
||||
func NewListCommand() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "list <database_uuid>",
|
||||
Short: "List all environment variables for a database",
|
||||
Long: `List all environment variables for a specific database.`,
|
||||
Args: cli.ExactArgs(1, "<uuid>"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := cmd.Context()
|
||||
uuid := args[0]
|
||||
|
||||
client, err := cli.GetAPIClient(cmd)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get API client: %w", err)
|
||||
}
|
||||
|
||||
dbSvc := service.NewDatabaseService(client)
|
||||
envs, err := dbSvc.ListEnvs(ctx, uuid)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to list environment variables: %w", err)
|
||||
}
|
||||
|
||||
showSensitive, _ := cmd.Flags().GetBool("show-sensitive")
|
||||
|
||||
// Mask sensitive values unless --show-sensitive is used
|
||||
if !showSensitive {
|
||||
for i := range envs {
|
||||
envs[i].Value = "********"
|
||||
if envs[i].RealValue != nil {
|
||||
masked := "********"
|
||||
envs[i].RealValue = &masked
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
format, _ := cmd.Flags().GetString("format")
|
||||
formatter, err := output.NewFormatter(format, output.Options{
|
||||
ShowSensitive: showSensitive,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create formatter: %w", err)
|
||||
}
|
||||
|
||||
return formatter.Format(envs)
|
||||
},
|
||||
}
|
||||
}
|
||||
Vendored
+145
@@ -0,0 +1,145 @@
|
||||
package env
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/coollabsio/coolify-cli/internal/cli"
|
||||
"github.com/coollabsio/coolify-cli/internal/models"
|
||||
"github.com/coollabsio/coolify-cli/internal/parser"
|
||||
"github.com/coollabsio/coolify-cli/internal/service"
|
||||
)
|
||||
|
||||
func NewSyncCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "sync <database_uuid>",
|
||||
Short: "Sync environment variables from a .env file",
|
||||
Long: `Sync environment variables from a .env file. This command intelligently:
|
||||
- Updates existing environment variables with new values
|
||||
- Creates new environment variables that don't exist yet
|
||||
- Uses efficient bulk operations where possible
|
||||
|
||||
Example: coolify db env sync abc123 --file .env.production`,
|
||||
Args: cli.ExactArgs(1, "<uuid>"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := cmd.Context()
|
||||
uuid := args[0]
|
||||
|
||||
client, err := cli.GetAPIClient(cmd)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get API client: %w", err)
|
||||
}
|
||||
|
||||
filePath, _ := cmd.Flags().GetString("file")
|
||||
if filePath == "" {
|
||||
return fmt.Errorf("--file is required")
|
||||
}
|
||||
|
||||
isLiteral, _ := cmd.Flags().GetBool("is-literal")
|
||||
|
||||
// Parse the .env file
|
||||
envVars, err := parser.ParseEnvFile(filePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse .env file: %w", err)
|
||||
}
|
||||
|
||||
if len(envVars) == 0 {
|
||||
fmt.Println("No environment variables found in file.")
|
||||
return nil
|
||||
}
|
||||
|
||||
fmt.Printf("Found %d environment variables in file. Syncing...\n", len(envVars))
|
||||
|
||||
// Fetch existing environment variables
|
||||
dbSvc := service.NewDatabaseService(client)
|
||||
existingEnvs, err := dbSvc.ListEnvs(ctx, uuid)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to list existing environment variables: %w", err)
|
||||
}
|
||||
|
||||
// Build a map of existing env vars by key
|
||||
existingMap := make(map[string]models.DatabaseEnvironmentVariable)
|
||||
for _, env := range existingEnvs {
|
||||
existingMap[env.Key] = env
|
||||
}
|
||||
|
||||
// Separate into updates and creates
|
||||
var toUpdate []models.DatabaseEnvironmentVariableCreateRequest
|
||||
var toCreate []models.DatabaseEnvironmentVariableCreateRequest
|
||||
|
||||
for _, envVar := range envVars {
|
||||
req := models.DatabaseEnvironmentVariableCreateRequest{
|
||||
Key: envVar.Key,
|
||||
Value: envVar.Value,
|
||||
}
|
||||
|
||||
// Apply flags if explicitly provided
|
||||
if cmd.Flags().Changed("is-literal") {
|
||||
req.IsLiteral = &isLiteral
|
||||
}
|
||||
|
||||
// Auto-detect multiline values
|
||||
if strings.Contains(envVar.Value, "\n") {
|
||||
multiline := true
|
||||
req.IsMultiline = &multiline
|
||||
}
|
||||
|
||||
if _, exists := existingMap[envVar.Key]; exists {
|
||||
toUpdate = append(toUpdate, req)
|
||||
} else {
|
||||
toCreate = append(toCreate, req)
|
||||
}
|
||||
}
|
||||
|
||||
updateCount := 0
|
||||
createCount := 0
|
||||
failCount := 0
|
||||
|
||||
// Perform bulk update if there are vars to update
|
||||
if len(toUpdate) > 0 {
|
||||
fmt.Printf("Updating %d existing variables...\n", len(toUpdate))
|
||||
bulkReq := &models.DatabaseEnvBulkUpdateRequest{
|
||||
Data: toUpdate,
|
||||
}
|
||||
_, err := dbSvc.BulkUpdateEnvs(ctx, uuid, bulkReq)
|
||||
if err != nil {
|
||||
fmt.Printf(" ✗ Bulk update failed: %v\n", err)
|
||||
failCount += len(toUpdate)
|
||||
} else {
|
||||
updateCount = len(toUpdate)
|
||||
fmt.Printf(" ✓ Successfully updated %d variables\n", updateCount)
|
||||
}
|
||||
}
|
||||
|
||||
// Create new variables one by one
|
||||
if len(toCreate) > 0 {
|
||||
fmt.Printf("Creating %d new variables...\n", len(toCreate))
|
||||
for _, req := range toCreate {
|
||||
_, err := dbSvc.CreateEnv(ctx, uuid, &req)
|
||||
if err != nil {
|
||||
fmt.Printf(" ✗ Failed to create '%s': %v\n", req.Key, err)
|
||||
failCount++
|
||||
} else {
|
||||
fmt.Printf(" ✓ Created '%s'\n", req.Key)
|
||||
createCount++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Printf("\nSync complete: %d updated, %d created, %d failed\n", updateCount, createCount, failCount)
|
||||
|
||||
if failCount > 0 {
|
||||
return fmt.Errorf("some environment variables failed to sync")
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringP("file", "f", "", "Path to .env file (required)")
|
||||
cmd.Flags().Bool("is-literal", false, "Treat all values as literal (don't interpolate variables)")
|
||||
|
||||
return cmd
|
||||
}
|
||||
Vendored
+95
@@ -0,0 +1,95 @@
|
||||
package env
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/coollabsio/coolify-cli/internal/cli"
|
||||
"github.com/coollabsio/coolify-cli/internal/models"
|
||||
"github.com/coollabsio/coolify-cli/internal/service"
|
||||
)
|
||||
|
||||
func NewUpdateCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "update <database_uuid> <env_uuid_or_key>",
|
||||
Short: "Update an environment variable",
|
||||
Long: `Update an existing environment variable. Identify it by UUID or key name.`,
|
||||
Args: cli.ExactArgs(2, "<database_uuid> <env_uuid_or_key>"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := cmd.Context()
|
||||
dbUUID := args[0]
|
||||
envIdentifier := args[1]
|
||||
|
||||
client, err := cli.GetAPIClient(cmd)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get API client: %w", err)
|
||||
}
|
||||
|
||||
// Check minimum version requirement
|
||||
if err := cli.CheckMinimumVersion(ctx, client, "4.0.0-beta.469"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
dbSvc := service.NewDatabaseService(client)
|
||||
|
||||
// Look up the env var to resolve its key
|
||||
existingEnv, err := dbSvc.GetEnv(ctx, dbUUID, envIdentifier)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to find environment variable '%s': %w", envIdentifier, err)
|
||||
}
|
||||
|
||||
req := &models.DatabaseEnvironmentVariableUpdateRequest{}
|
||||
|
||||
// Use existing key unless --key flag explicitly provides a new one
|
||||
if cmd.Flags().Changed("key") {
|
||||
key, _ := cmd.Flags().GetString("key")
|
||||
req.Key = &key
|
||||
} else {
|
||||
req.Key = &existingEnv.Key
|
||||
}
|
||||
|
||||
if cmd.Flags().Changed("value") {
|
||||
value, _ := cmd.Flags().GetString("value")
|
||||
req.Value = &value
|
||||
}
|
||||
if cmd.Flags().Changed("is-literal") {
|
||||
isLiteral, _ := cmd.Flags().GetBool("is-literal")
|
||||
req.IsLiteral = &isLiteral
|
||||
}
|
||||
if cmd.Flags().Changed("is-multiline") {
|
||||
isMultiline, _ := cmd.Flags().GetBool("is-multiline")
|
||||
req.IsMultiline = &isMultiline
|
||||
}
|
||||
if cmd.Flags().Changed("is-shown-once") {
|
||||
isShownOnce, _ := cmd.Flags().GetBool("is-shown-once")
|
||||
req.IsShownOnce = &isShownOnce
|
||||
}
|
||||
if cmd.Flags().Changed("comment") {
|
||||
comment, _ := cmd.Flags().GetString("comment")
|
||||
req.Comment = &comment
|
||||
}
|
||||
|
||||
if req.Value == nil {
|
||||
return fmt.Errorf("--value is required")
|
||||
}
|
||||
|
||||
env, err := dbSvc.UpdateEnv(ctx, dbUUID, req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to update environment variable: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("Environment variable '%s' updated successfully.\n", env.Key)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().String("key", "", "New environment variable key (rename)")
|
||||
cmd.Flags().String("value", "", "New environment variable value (required)")
|
||||
cmd.Flags().Bool("is-literal", false, "Treat value as literal")
|
||||
cmd.Flags().Bool("is-multiline", false, "Value is multiline")
|
||||
cmd.Flags().Bool("is-shown-once", false, "Only show value once")
|
||||
cmd.Flags().String("comment", "", "Comment for the environment variable")
|
||||
|
||||
return cmd
|
||||
}
|
||||
+12
-2
@@ -32,15 +32,25 @@ func NewGetCommand() *cobra.Command {
|
||||
return fmt.Errorf("failed to get database: %w", err)
|
||||
}
|
||||
|
||||
format, _ := cmd.Flags().GetString("format")
|
||||
showSensitive, _ := cmd.Flags().GetBool("show-sensitive")
|
||||
formatter, err := output.NewFormatter("table", output.Options{
|
||||
|
||||
formatter, err := output.NewFormatter(format, output.Options{
|
||||
ShowSensitive: showSensitive,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create formatter: %w", err)
|
||||
}
|
||||
|
||||
return formatter.Format(database)
|
||||
if err := formatter.Format(database); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !showSensitive && format == output.FormatTable {
|
||||
fmt.Println("\nNote: Use -s to show sensitive information.")
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
+15
-2
@@ -30,12 +30,25 @@ func NewListCommand() *cobra.Command {
|
||||
return fmt.Errorf("failed to list databases: %w", err)
|
||||
}
|
||||
|
||||
formatter, err := output.NewFormatter("table", output.Options{})
|
||||
format, _ := cmd.Flags().GetString("format")
|
||||
showSensitive, _ := cmd.Flags().GetBool("show-sensitive")
|
||||
|
||||
formatter, err := output.NewFormatter(format, output.Options{
|
||||
ShowSensitive: showSensitive,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create formatter: %w", err)
|
||||
}
|
||||
|
||||
return formatter.Format(databases)
|
||||
if err := formatter.Format(databases); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !showSensitive && format == output.FormatTable {
|
||||
fmt.Println("\nNote: Use -s to show sensitive information.")
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/coollabsio/coolify-cli/internal/cli"
|
||||
"github.com/coollabsio/coolify-cli/internal/models"
|
||||
"github.com/coollabsio/coolify-cli/internal/service"
|
||||
)
|
||||
|
||||
// NewCreateCommand returns the database storage create command
|
||||
func NewCreateCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "create <db_uuid>",
|
||||
Short: "Create a storage for a database",
|
||||
Long: `Create a persistent volume or file storage for a database.
|
||||
|
||||
Examples:
|
||||
coolify db storage create <db_uuid> --type persistent --name my-volume --mount-path /data
|
||||
coolify db storage create <db_uuid> --type file --mount-path /app/config.yml --content "key: value"`,
|
||||
Args: cli.ExactArgs(1, "<db_uuid>"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := cmd.Context()
|
||||
|
||||
storageType, _ := cmd.Flags().GetString("type")
|
||||
mountPath, _ := cmd.Flags().GetString("mount-path")
|
||||
|
||||
if storageType == "" {
|
||||
return fmt.Errorf("--type is required (persistent or file)")
|
||||
}
|
||||
if storageType != "persistent" && storageType != "file" {
|
||||
return fmt.Errorf("--type must be 'persistent' or 'file'")
|
||||
}
|
||||
if mountPath == "" {
|
||||
return fmt.Errorf("--mount-path is required")
|
||||
}
|
||||
|
||||
req := &models.StorageCreateRequest{
|
||||
Type: storageType,
|
||||
MountPath: mountPath,
|
||||
}
|
||||
|
||||
if cmd.Flags().Changed("name") {
|
||||
val, _ := cmd.Flags().GetString("name")
|
||||
req.Name = &val
|
||||
}
|
||||
if cmd.Flags().Changed("host-path") {
|
||||
val, _ := cmd.Flags().GetString("host-path")
|
||||
req.HostPath = &val
|
||||
}
|
||||
if cmd.Flags().Changed("content") {
|
||||
val, _ := cmd.Flags().GetString("content")
|
||||
req.Content = &val
|
||||
}
|
||||
if cmd.Flags().Changed("is-directory") {
|
||||
val, _ := cmd.Flags().GetBool("is-directory")
|
||||
req.IsDirectory = &val
|
||||
}
|
||||
if cmd.Flags().Changed("fs-path") {
|
||||
val, _ := cmd.Flags().GetString("fs-path")
|
||||
req.FsPath = &val
|
||||
}
|
||||
|
||||
client, err := cli.GetAPIClient(cmd)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get API client: %w", err)
|
||||
}
|
||||
|
||||
if err := cli.CheckMinimumVersion(ctx, client, "4.0.0-beta.470"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
dbSvc := service.NewDatabaseService(client)
|
||||
if err := dbSvc.CreateStorage(ctx, args[0], req); err != nil {
|
||||
return fmt.Errorf("failed to create storage: %w", err)
|
||||
}
|
||||
|
||||
fmt.Println("Storage created successfully.")
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().String("type", "", "Storage type: 'persistent' or 'file' (required)")
|
||||
cmd.Flags().String("mount-path", "", "Mount path inside the container (required)")
|
||||
cmd.Flags().String("name", "", "Volume name (persistent only)")
|
||||
cmd.Flags().String("host-path", "", "Host path (persistent only)")
|
||||
cmd.Flags().String("content", "", "File content (file only)")
|
||||
cmd.Flags().Bool("is-directory", false, "Whether this is a directory mount (file only)")
|
||||
cmd.Flags().String("fs-path", "", "Host directory path (file only, required when --is-directory is set)")
|
||||
|
||||
return cmd
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/coollabsio/coolify-cli/internal/cli"
|
||||
"github.com/coollabsio/coolify-cli/internal/service"
|
||||
)
|
||||
|
||||
// NewDeleteCommand returns the database storage delete command
|
||||
func NewDeleteCommand() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "delete <db_uuid> <storage_uuid>",
|
||||
Short: "Delete a storage from a database",
|
||||
Long: `Delete a persistent volume or file storage from a database.
|
||||
|
||||
Examples:
|
||||
coolify db storage delete <db_uuid> <storage_uuid>`,
|
||||
Args: cli.ExactArgs(2, "<db_uuid> <storage_uuid>"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := cmd.Context()
|
||||
|
||||
client, err := cli.GetAPIClient(cmd)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get API client: %w", err)
|
||||
}
|
||||
|
||||
if err := cli.CheckMinimumVersion(ctx, client, "4.0.0-beta.470"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
dbSvc := service.NewDatabaseService(client)
|
||||
if err := dbSvc.DeleteStorage(ctx, args[0], args[1]); err != nil {
|
||||
return fmt.Errorf("failed to delete storage: %w", err)
|
||||
}
|
||||
|
||||
fmt.Println("Storage deleted successfully.")
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/coollabsio/coolify-cli/internal/cli"
|
||||
"github.com/coollabsio/coolify-cli/internal/output"
|
||||
"github.com/coollabsio/coolify-cli/internal/service"
|
||||
)
|
||||
|
||||
// NewListCommand returns the database storage list command
|
||||
func NewListCommand() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "list <db_uuid>",
|
||||
Short: "List all storages for a database",
|
||||
Long: `List all persistent volumes and file storages for a specific database.`,
|
||||
Args: cli.ExactArgs(1, "<db_uuid>"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := cmd.Context()
|
||||
|
||||
client, err := cli.GetAPIClient(cmd)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get API client: %w", err)
|
||||
}
|
||||
|
||||
if err := cli.CheckMinimumVersion(ctx, client, "4.0.0-beta.470"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
dbSvc := service.NewDatabaseService(client)
|
||||
storages, err := dbSvc.ListStorages(ctx, args[0])
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to list storages: %w", err)
|
||||
}
|
||||
|
||||
format, _ := cmd.Flags().GetString("format")
|
||||
showSensitive, _ := cmd.Flags().GetBool("show-sensitive")
|
||||
|
||||
formatter, err := output.NewFormatter(format, output.Options{
|
||||
ShowSensitive: showSensitive,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return formatter.Format(storages)
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/coollabsio/coolify-cli/internal/cli"
|
||||
"github.com/coollabsio/coolify-cli/internal/models"
|
||||
"github.com/coollabsio/coolify-cli/internal/service"
|
||||
)
|
||||
|
||||
// NewUpdateCommand returns the database storage update command
|
||||
func NewUpdateCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "update <db_uuid>",
|
||||
Short: "Update a storage for a database",
|
||||
Long: `Update a persistent volume or file storage for a database.
|
||||
|
||||
The --uuid and --type flags are required. Use 'coolify db storage list' to find storage UUIDs.
|
||||
|
||||
Examples:
|
||||
coolify db storage update <db_uuid> --uuid <storage_uuid> --type persistent --name my-volume
|
||||
coolify db storage update <db_uuid> --uuid <storage_uuid> --type persistent --is-preview-suffix-enabled`,
|
||||
Args: cli.ExactArgs(1, "<db_uuid>"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := cmd.Context()
|
||||
|
||||
storageUUID, _ := cmd.Flags().GetString("uuid")
|
||||
storageID, _ := cmd.Flags().GetInt("id")
|
||||
storageType, _ := cmd.Flags().GetString("type")
|
||||
|
||||
if storageUUID == "" && storageID == 0 {
|
||||
return fmt.Errorf("--uuid is required (or --id as deprecated fallback)")
|
||||
}
|
||||
if storageType == "" {
|
||||
return fmt.Errorf("--type is required (persistent or file)")
|
||||
}
|
||||
if storageType != "persistent" && storageType != "file" {
|
||||
return fmt.Errorf("--type must be 'persistent' or 'file'")
|
||||
}
|
||||
|
||||
req := &models.StorageUpdateRequest{
|
||||
Type: storageType,
|
||||
}
|
||||
|
||||
if storageUUID != "" {
|
||||
req.UUID = &storageUUID
|
||||
} else {
|
||||
req.ID = &storageID
|
||||
}
|
||||
|
||||
hasUpdates := false
|
||||
|
||||
if cmd.Flags().Changed("is-preview-suffix-enabled") {
|
||||
val, _ := cmd.Flags().GetBool("is-preview-suffix-enabled")
|
||||
req.IsPreviewSuffixEnabled = &val
|
||||
hasUpdates = true
|
||||
}
|
||||
if cmd.Flags().Changed("name") {
|
||||
val, _ := cmd.Flags().GetString("name")
|
||||
req.Name = &val
|
||||
hasUpdates = true
|
||||
}
|
||||
if cmd.Flags().Changed("mount-path") {
|
||||
val, _ := cmd.Flags().GetString("mount-path")
|
||||
req.MountPath = &val
|
||||
hasUpdates = true
|
||||
}
|
||||
if cmd.Flags().Changed("host-path") {
|
||||
val, _ := cmd.Flags().GetString("host-path")
|
||||
req.HostPath = &val
|
||||
hasUpdates = true
|
||||
}
|
||||
if cmd.Flags().Changed("content") {
|
||||
val, _ := cmd.Flags().GetString("content")
|
||||
req.Content = &val
|
||||
hasUpdates = true
|
||||
}
|
||||
|
||||
if !hasUpdates {
|
||||
return fmt.Errorf("no fields to update. Use --help to see available flags")
|
||||
}
|
||||
|
||||
client, err := cli.GetAPIClient(cmd)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get API client: %w", err)
|
||||
}
|
||||
|
||||
if err := cli.CheckMinimumVersion(ctx, client, "4.0.0-beta.470"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
dbSvc := service.NewDatabaseService(client)
|
||||
if err := dbSvc.UpdateStorage(ctx, args[0], req); err != nil {
|
||||
return fmt.Errorf("failed to update storage: %w", err)
|
||||
}
|
||||
|
||||
fmt.Println("Storage updated successfully.")
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().String("uuid", "", "Storage UUID (required, use 'storage list' to find)")
|
||||
cmd.Flags().Int("id", 0, "Storage ID (deprecated, use --uuid instead)")
|
||||
cmd.Flags().String("type", "", "Storage type: 'persistent' or 'file' (required)")
|
||||
cmd.Flags().Bool("is-preview-suffix-enabled", false, "Enable preview suffix for this storage")
|
||||
cmd.Flags().String("name", "", "Storage name (persistent only)")
|
||||
cmd.Flags().String("mount-path", "", "Mount path inside the container")
|
||||
cmd.Flags().String("host-path", "", "Host path (persistent only)")
|
||||
cmd.Flags().String("content", "", "File content (file only)")
|
||||
|
||||
return cmd
|
||||
}
|
||||
@@ -27,6 +27,9 @@ Example: coolify deploy batch app1,app2,app3`,
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get API client: %w", err)
|
||||
}
|
||||
if err := validateDeployFlags(ctx, cmd, client); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Parse comma-separated names
|
||||
names := make([]string, 0)
|
||||
@@ -66,7 +69,6 @@ Example: coolify deploy batch app1,app2,app3`,
|
||||
}
|
||||
|
||||
// Deploy all resources
|
||||
force, _ := cmd.Flags().GetBool("force")
|
||||
deploySvc := service.NewDeploymentService(client)
|
||||
|
||||
type result struct {
|
||||
@@ -83,7 +85,7 @@ Example: coolify deploy batch app1,app2,app3`,
|
||||
uuid := nameToUUID[name]
|
||||
fmt.Printf("Deploying %s...\n", name)
|
||||
|
||||
res, err := deploySvc.Deploy(ctx, uuid, force)
|
||||
res, err := deploySvc.Deploy(ctx, getDeployRequest(cmd, uuid))
|
||||
if err != nil {
|
||||
results = append(results, result{
|
||||
Name: name,
|
||||
@@ -126,6 +128,6 @@ Example: coolify deploy batch app1,app2,app3`,
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().Bool("force", false, "Force deployment")
|
||||
addDeployFlags(cmd)
|
||||
return cmd
|
||||
}
|
||||
|
||||
@@ -1,6 +1,16 @@
|
||||
package deployment
|
||||
|
||||
import "github.com/spf13/cobra"
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/coollabsio/coolify-cli/internal/api"
|
||||
"github.com/coollabsio/coolify-cli/internal/cli"
|
||||
"github.com/coollabsio/coolify-cli/internal/models"
|
||||
)
|
||||
|
||||
const dockerTagMinVersion = "4.0.0-beta.471"
|
||||
|
||||
// NewDeploymentCommand creates the deployment parent command with all subcommands
|
||||
func NewDeploymentCommand() *cobra.Command {
|
||||
@@ -19,3 +29,38 @@ func NewDeploymentCommand() *cobra.Command {
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func addDeployFlags(cmd *cobra.Command) {
|
||||
cmd.Flags().Bool("force", false, "Force deployment")
|
||||
cmd.Flags().Int("pull-request-id", 0, "Pull request ID for preview deployments")
|
||||
cmd.Flags().String("docker-tag", "", "Docker image tag override for the deployment")
|
||||
}
|
||||
|
||||
func getDeployRequest(cmd *cobra.Command, uuid string) models.DeployRequest {
|
||||
req := models.DeployRequest{
|
||||
UUID: uuid,
|
||||
}
|
||||
|
||||
if cmd.Flags().Changed("force") {
|
||||
force, _ := cmd.Flags().GetBool("force")
|
||||
req.Force = &force
|
||||
}
|
||||
if cmd.Flags().Changed("pull-request-id") {
|
||||
pullRequestID, _ := cmd.Flags().GetInt("pull-request-id")
|
||||
req.PullRequestID = &pullRequestID
|
||||
}
|
||||
if cmd.Flags().Changed("docker-tag") {
|
||||
dockerTag, _ := cmd.Flags().GetString("docker-tag")
|
||||
req.DockerTag = &dockerTag
|
||||
}
|
||||
|
||||
return req
|
||||
}
|
||||
|
||||
func validateDeployFlags(ctx context.Context, cmd *cobra.Command, client *api.Client) error {
|
||||
if cmd.Flags().Changed("docker-tag") {
|
||||
return cli.CheckMinimumVersion(ctx, client, dockerTagMinVersion)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -24,6 +24,9 @@ func NewNameCommand() *cobra.Command {
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get API client: %w", err)
|
||||
}
|
||||
if err := validateDeployFlags(ctx, cmd, client); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Find resource by name
|
||||
resourceSvc := service.NewResourceService(client)
|
||||
@@ -45,9 +48,8 @@ func NewNameCommand() *cobra.Command {
|
||||
}
|
||||
|
||||
// Deploy using the found UUID
|
||||
force, _ := cmd.Flags().GetBool("force")
|
||||
deploySvc := service.NewDeploymentService(client)
|
||||
result, err := deploySvc.Deploy(ctx, matchedUUID, force)
|
||||
result, err := deploySvc.Deploy(ctx, getDeployRequest(cmd, matchedUUID))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to deploy resource: %w", err)
|
||||
}
|
||||
@@ -74,6 +76,6 @@ func NewNameCommand() *cobra.Command {
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().Bool("force", false, "Force deployment")
|
||||
addDeployFlags(cmd)
|
||||
return cmd
|
||||
}
|
||||
|
||||
@@ -30,10 +30,12 @@ func NewUUIDCommand() *cobra.Command {
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get API client: %w", err)
|
||||
}
|
||||
if err := validateDeployFlags(ctx, cmd, client); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
force, _ := cmd.Flags().GetBool("force")
|
||||
deploySvc := service.NewDeploymentService(client)
|
||||
result, err := deploySvc.Deploy(ctx, uuid, force)
|
||||
result, err := deploySvc.Deploy(ctx, getDeployRequest(cmd, uuid))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to deploy resource: %w", err)
|
||||
}
|
||||
@@ -60,6 +62,6 @@ func NewUUIDCommand() *cobra.Command {
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().Bool("force", false, "Force deployment")
|
||||
addDeployFlags(cmd)
|
||||
return cmd
|
||||
}
|
||||
|
||||
+531
@@ -4,9 +4,12 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/cobra/doc"
|
||||
"github.com/spf13/pflag"
|
||||
)
|
||||
|
||||
var docsCmd = &cobra.Command{
|
||||
@@ -85,12 +88,540 @@ The markdown files will be written to the specified directory (default: ./docs).
|
||||
},
|
||||
}
|
||||
|
||||
var llmsCmd = &cobra.Command{
|
||||
Use: "llms",
|
||||
Short: "Generate llms.txt and llms-full.txt for AI agents",
|
||||
Long: `Generate AI-friendly documentation files for the Coolify CLI.
|
||||
|
||||
This creates a concise llms.txt quick reference plus a complete llms-full.txt command catalog.
|
||||
The output files will be written to the specified paths (defaults: ./llms.txt and ./llms-full.txt).`,
|
||||
Example: ` coolify docs llms
|
||||
coolify docs llms --output=./llms.txt --full-output=./llms-full.txt`,
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
outputFile, _ := cmd.Flags().GetString("output")
|
||||
fullOutputFile, _ := cmd.Flags().GetString("full-output")
|
||||
|
||||
return writeLLMsArtifacts(outputFile, fullOutputFile)
|
||||
},
|
||||
}
|
||||
|
||||
const llmsQuickTemplate = `# Coolify CLI - llms.txt
|
||||
|
||||
> Quick AI/LLM instructions for the Coolify CLI.
|
||||
> Source: https://github.com/coollabsio/coolify-cli
|
||||
> API Spec: https://github.com/coollabsio/coolify/blob/v4.x/openapi.json
|
||||
|
||||
## Operating Rules
|
||||
|
||||
- Prefer ` + "`--format json`" + ` for automation and parsing.
|
||||
- Use Coolify UUIDs for resources; do not use internal numeric IDs.
|
||||
- Team commands are the exception: they use numeric team IDs.
|
||||
- Authenticate with a saved context when possible; use ` + "`--token`" + ` only for overrides.
|
||||
- Use ` + "`llms-full.txt`" + ` for the exhaustive command/flag catalog.
|
||||
|
||||
## Installation
|
||||
|
||||
` + "```bash" + `
|
||||
# Linux/macOS (recommended)
|
||||
curl -fsSL https://raw.githubusercontent.com/coollabsio/coolify-cli/main/scripts/install.sh | bash
|
||||
|
||||
# Homebrew (macOS/Linux)
|
||||
brew install coollabsio/coolify-cli/coolify-cli
|
||||
|
||||
# Windows (PowerShell)
|
||||
irm https://raw.githubusercontent.com/coollabsio/coolify-cli/main/scripts/install.ps1 | iex
|
||||
|
||||
# Go install
|
||||
go install github.com/coollabsio/coolify-cli/coolify@latest
|
||||
` + "```" + `
|
||||
|
||||
## Authentication
|
||||
|
||||
1. Get an API token from your Coolify dashboard at ` + "`/security/api-tokens`" + `
|
||||
2. For Coolify Cloud: ` + "`coolify context set-token cloud <token>`" + `
|
||||
3. For self-hosted: ` + "`coolify context add -d <context_name> <url> <token>`" + `
|
||||
4. Switch contexts with ` + "`coolify context use <context_name>`" + `
|
||||
|
||||
## Configuration
|
||||
|
||||
Config file location:
|
||||
- Linux/macOS: ` + "`~/.config/coolify/config.json`" + `
|
||||
- Windows: ` + "`%%APPDATA%%\\coolify\\config.json`" + `
|
||||
|
||||
Supports multiple contexts (instances) with ` + "`coolify context`" + ` commands.
|
||||
|
||||
## Output Formats
|
||||
|
||||
All commands support ` + "`--format`" + ` flag:
|
||||
- ` + "`table`" + ` (default) - human-readable tabular output
|
||||
- ` + "`json`" + ` - compact JSON for scripting
|
||||
- ` + "`pretty`" + ` - indented JSON for debugging
|
||||
|
||||
## Global Flags
|
||||
|
||||
- ` + "`--context <name>`" + ` - use a specific saved context
|
||||
- ` + "`--token <token>`" + ` - override token from config
|
||||
- ` + "`--format table|json|pretty`" + ` - choose output format
|
||||
- ` + "`--show-sensitive`" + ` - reveal sensitive values
|
||||
- ` + "`--debug`" + ` - enable debug output
|
||||
|
||||
## Common Workflows
|
||||
|
||||
### Contexts
|
||||
|
||||
` + "```bash" + `
|
||||
coolify context list
|
||||
coolify context verify
|
||||
coolify context version
|
||||
coolify context use prod
|
||||
` + "```" + `
|
||||
|
||||
### Inventory
|
||||
|
||||
` + "```bash" + `
|
||||
coolify server list
|
||||
coolify project list
|
||||
coolify resource list
|
||||
coolify app list
|
||||
coolify service list
|
||||
coolify database list
|
||||
` + "```" + `
|
||||
|
||||
### Applications
|
||||
|
||||
` + "```bash" + `
|
||||
coolify app get <uuid>
|
||||
coolify app start <uuid>
|
||||
coolify app stop <uuid>
|
||||
coolify app restart <uuid>
|
||||
coolify app logs <uuid> --follow
|
||||
coolify app deployments list <app-uuid>
|
||||
coolify app deployments logs <app-uuid> --follow
|
||||
` + "```" + `
|
||||
|
||||
### Environment Variables
|
||||
|
||||
` + "```bash" + `
|
||||
coolify app env list <app-uuid>
|
||||
coolify app env create <app-uuid> --key API_KEY --value secret123
|
||||
coolify app env update <app-uuid> <env-uuid-or-key> --value new-secret
|
||||
coolify app env sync <app-uuid> --file .env.production --build-time --preview
|
||||
` + "```" + `
|
||||
|
||||
### Deployments
|
||||
|
||||
` + "```bash" + `
|
||||
coolify deploy list
|
||||
coolify deploy name my-application
|
||||
coolify deploy batch api,worker,frontend --force
|
||||
coolify deploy cancel <deployment-uuid>
|
||||
` + "```" + `
|
||||
|
||||
### Databases and Services
|
||||
|
||||
` + "```bash" + `
|
||||
coolify database get <uuid>
|
||||
coolify database create postgresql --server-uuid <uuid> --project-uuid <uuid> --environment-name production
|
||||
coolify database backup list <database-uuid>
|
||||
coolify service get <uuid>
|
||||
coolify service create <type> --project-uuid <uuid> --server-uuid <uuid> --instant-deploy
|
||||
` + "```" + `
|
||||
|
||||
## Common Aliases
|
||||
|
||||
- ` + "`coolify app`" + ` | ` + "`coolify apps`" + ` | ` + "`coolify application`" + ` | ` + "`coolify applications`" + `
|
||||
- ` + "`coolify service`" + ` | ` + "`coolify services`" + ` | ` + "`coolify svc`" + `
|
||||
- ` + "`coolify database`" + ` | ` + "`coolify databases`" + ` | ` + "`coolify db`" + ` | ` + "`coolify dbs`" + `
|
||||
- ` + "`coolify teams`" + ` | ` + "`coolify team`" + `
|
||||
|
||||
## Full Reference
|
||||
|
||||
- Full command and parameter catalog: %s
|
||||
- Regenerate docs: ` + "`go run ./coolify docs llms`" + `
|
||||
`
|
||||
|
||||
const llmsFullIntro = `# Coolify CLI - llms-full.txt
|
||||
|
||||
> Full AI/LLM command catalog for the Coolify CLI.
|
||||
> Manage Coolify instances (cloud and self-hosted), servers, projects, applications, databases, services, deployments, domains, and private keys.
|
||||
> Source: https://github.com/coollabsio/coolify-cli
|
||||
> API Spec: https://github.com/coollabsio/coolify/blob/v4.x/openapi.json
|
||||
|
||||
## Companion Files
|
||||
|
||||
- Quick instructions: %s
|
||||
- Regenerate docs: ` + "`go run ./coolify docs llms`" + `
|
||||
|
||||
## Installation
|
||||
|
||||
` + "```bash" + `
|
||||
# Linux/macOS (recommended)
|
||||
curl -fsSL https://raw.githubusercontent.com/coollabsio/coolify-cli/main/scripts/install.sh | bash
|
||||
|
||||
# Homebrew (macOS/Linux)
|
||||
brew install coollabsio/coolify-cli/coolify-cli
|
||||
|
||||
# Windows (PowerShell)
|
||||
irm https://raw.githubusercontent.com/coollabsio/coolify-cli/main/scripts/install.ps1 | iex
|
||||
|
||||
# Go install
|
||||
go install github.com/coollabsio/coolify-cli/coolify@latest
|
||||
` + "```" + `
|
||||
|
||||
## Authentication
|
||||
|
||||
1. Get an API token from your Coolify dashboard at ` + "`/security/api-tokens`" + `
|
||||
2. For Coolify Cloud: ` + "`coolify context set-token cloud <token>`" + `
|
||||
3. For self-hosted: ` + "`coolify context add -d <context_name> <url> <token>`" + `
|
||||
|
||||
## Configuration
|
||||
|
||||
Config file location:
|
||||
- Linux/macOS: ` + "`~/.config/coolify/config.json`" + `
|
||||
- Windows: ` + "`%%APPDATA%%\\coolify\\config.json`" + `
|
||||
|
||||
Supports multiple contexts (instances) with ` + "`coolify context`" + ` commands.
|
||||
|
||||
## Output Formats
|
||||
|
||||
All commands support ` + "`--format`" + ` flag:
|
||||
- ` + "`table`" + ` (default) - human-readable tabular output
|
||||
- ` + "`json`" + ` - compact JSON for scripting
|
||||
- ` + "`pretty`" + ` - indented JSON for debugging
|
||||
`
|
||||
|
||||
const llmsFullBody = `
|
||||
|
||||
## Supported Database Types
|
||||
|
||||
When using ` + "`coolify database create <type>`" + `:
|
||||
- ` + "`postgresql`" + `
|
||||
- ` + "`mysql`" + `
|
||||
- ` + "`mariadb`" + `
|
||||
- ` + "`mongodb`" + `
|
||||
- ` + "`redis`" + `
|
||||
- ` + "`keydb`" + `
|
||||
- ` + "`clickhouse`" + `
|
||||
- ` + "`dragonfly`" + `
|
||||
|
||||
## Usage Examples
|
||||
|
||||
` + "```bash" + `
|
||||
# Multi-context workflow
|
||||
coolify context add prod https://prod.coolify.io <token>
|
||||
coolify context add staging https://staging.coolify.io <token>
|
||||
coolify context use prod
|
||||
coolify --context=staging server list
|
||||
|
||||
# Application lifecycle
|
||||
coolify app list
|
||||
coolify app get <uuid>
|
||||
coolify app start <uuid>
|
||||
coolify app stop <uuid>
|
||||
coolify app restart <uuid>
|
||||
coolify app logs <uuid> --follow
|
||||
|
||||
# Environment variable management
|
||||
coolify app env list <uuid>
|
||||
coolify app env create <uuid> --key API_KEY --value secret123
|
||||
coolify app env sync <uuid> --file .env.production --build-time --preview
|
||||
|
||||
# Deploy workflows
|
||||
coolify deploy name my-application
|
||||
coolify deploy batch api,worker,frontend --force
|
||||
coolify deploy list
|
||||
coolify deploy cancel <uuid>
|
||||
|
||||
# Database backup
|
||||
coolify database backup create <db-uuid> --frequency "0 2 * * *" --enabled --save-s3
|
||||
coolify database backup trigger <db-uuid> <backup-uuid>
|
||||
|
||||
# Application creation
|
||||
coolify app create public --project-uuid <uuid> --server-uuid <uuid> --git-repository https://github.com/user/repo --git-branch main --build-pack nixpacks --ports-exposes 3000
|
||||
coolify app create dockerfile --project-uuid <uuid> --server-uuid <uuid> --dockerfile "FROM node:18\nCOPY . .\nRUN npm install\nCMD [\"node\", \"index.js\"]"
|
||||
coolify app create dockerimage --project-uuid <uuid> --server-uuid <uuid> --docker-registry-image-name nginx --ports-exposes 80
|
||||
|
||||
# Service creation (one-click services)
|
||||
coolify service create <type> --project-uuid <uuid> --server-uuid <uuid> --instant-deploy
|
||||
coolify service create --list-types # list all available service types
|
||||
|
||||
# Storage management
|
||||
coolify app storage create <app-uuid> --type persistent --mount-path /data --name my-volume
|
||||
coolify app storage create <app-uuid> --type file --mount-path /app/config.yml --content "key: value"
|
||||
|
||||
# GitHub App integration
|
||||
coolify github list
|
||||
coolify github repos <app-uuid>
|
||||
coolify github branches <app-uuid> owner/repo
|
||||
|
||||
# Team management
|
||||
coolify team list
|
||||
coolify team current
|
||||
coolify team members list
|
||||
` + "```" + `
|
||||
|
||||
## API Notes
|
||||
|
||||
- All resource identifiers use UUIDs (not internal database IDs)
|
||||
- API base path: ` + "`/api/v1/`" + `
|
||||
- Authentication: Bearer token via ` + "`--token`" + ` flag or context configuration
|
||||
- ` + "`app env sync`" + ` behavior: updates existing variables, creates missing ones, does NOT delete variables not in the file
|
||||
- ` + "`app start`" + ` aliases to ` + "`app deploy`" + ` and also accepts ` + "`--force`" + ` and ` + "`--instant-deploy`" + ` flags
|
||||
- Deployment logs support ` + "`--follow`" + ` for real-time streaming and ` + "`--debuglogs`" + ` for internal operations
|
||||
- ` + "`app logs`" + ` defaults to 100 lines; ` + "`app deployments logs`" + ` defaults to 0 (all lines)
|
||||
- Short flag ` + "`-n`" + ` can be used instead of ` + "`--lines`" + ` for log commands
|
||||
- ` + "`completion`" + ` command supports shells: ` + "`bash`" + `, ` + "`zsh`" + `, ` + "`fish`" + `, ` + "`powershell`" + `
|
||||
- Resource statuses: ` + "`running`" + `, ` + "`stopped`" + `, ` + "`error`" + `
|
||||
- Teams use numeric IDs (not UUIDs) - this is the only resource that uses IDs
|
||||
- Fields marked ` + "`sensitive:\"true\"`" + ` (tokens, passwords, IPs, emails) are hidden by default; use ` + "`--show-sensitive`" + ` to reveal
|
||||
|
||||
---
|
||||
|
||||
## Command Reference
|
||||
|
||||
`
|
||||
|
||||
func buildQuickLLMSText(fullReferencePath string) string {
|
||||
return fmt.Sprintf(llmsQuickTemplate, fullReferencePath)
|
||||
}
|
||||
|
||||
func buildFullLLMSText(quickReferencePath string) string {
|
||||
var sb strings.Builder
|
||||
fmt.Fprintf(&sb, llmsFullIntro, quickReferencePath)
|
||||
writeLLMsAliases(&sb, rootCmd, "coolify")
|
||||
sb.WriteString(llmsFullBody)
|
||||
writeLLMsCommand(&sb, rootCmd, "coolify")
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func writeLLMsArtifacts(outputFile, fullOutputFile string) error {
|
||||
if filepath.Clean(outputFile) == filepath.Clean(fullOutputFile) {
|
||||
return fmt.Errorf("output and full-output must be different files")
|
||||
}
|
||||
|
||||
if err := ensureParentDir(outputFile); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ensureParentDir(fullOutputFile); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
quickReferencePath := llmsReferencePath(fullOutputFile, outputFile)
|
||||
fullReferencePath := llmsReferencePath(outputFile, fullOutputFile)
|
||||
|
||||
if err := os.WriteFile(outputFile, []byte(buildQuickLLMSText(fullReferencePath)), 0600); err != nil {
|
||||
return fmt.Errorf("failed to write llms.txt: %w", err)
|
||||
}
|
||||
if err := os.WriteFile(fullOutputFile, []byte(buildFullLLMSText(quickReferencePath)), 0600); err != nil {
|
||||
return fmt.Errorf("failed to write llms-full.txt: %w", err)
|
||||
}
|
||||
|
||||
absQuickPath, _ := filepath.Abs(outputFile)
|
||||
absFullPath, _ := filepath.Abs(fullOutputFile)
|
||||
fmt.Printf("llms.txt generated successfully: %s\n", absQuickPath)
|
||||
fmt.Printf("llms-full.txt generated successfully: %s\n", absFullPath)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func ensureParentDir(path string) error {
|
||||
parentDir := filepath.Dir(path)
|
||||
if err := os.MkdirAll(parentDir, 0750); err != nil {
|
||||
return fmt.Errorf("failed to create output directory: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func llmsReferencePath(fromFile, toFile string) string {
|
||||
referencePath, err := filepath.Rel(filepath.Dir(fromFile), toFile)
|
||||
if err != nil {
|
||||
return filepath.ToSlash(toFile)
|
||||
}
|
||||
referencePath = filepath.ToSlash(referencePath)
|
||||
if strings.HasPrefix(referencePath, ".") || strings.HasPrefix(referencePath, "/") {
|
||||
return referencePath
|
||||
}
|
||||
return "./" + referencePath
|
||||
}
|
||||
|
||||
// writeLLMsAliases writes aliases derived from the Cobra command tree.
|
||||
func writeLLMsAliases(sb *strings.Builder, cmd *cobra.Command, parentPath string) {
|
||||
aliases := collectLLMsAliases(cmd, parentPath)
|
||||
if len(aliases) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
sb.WriteString("\n## Command Aliases\n\n")
|
||||
sb.WriteString("Aliases are derived from the CLI command tree:\n")
|
||||
for _, aliasLine := range aliases {
|
||||
fmt.Fprintf(sb, "- %s\n", aliasLine)
|
||||
}
|
||||
}
|
||||
|
||||
func collectLLMsAliases(cmd *cobra.Command, parentPath string) []string {
|
||||
var aliases []string
|
||||
if cmd.Name() != "docs" && cmd.Name() != "help" {
|
||||
if len(cmd.Aliases) > 0 {
|
||||
aliasNames := append([]string{cmd.Name()}, cmd.Aliases...)
|
||||
for i := range aliasNames {
|
||||
aliasNames[i] = fmt.Sprintf("`%s`", commandPathPrefix(parentPath, cmd)+aliasNames[i])
|
||||
}
|
||||
aliases = append(aliases, strings.Join(aliasNames, " | "))
|
||||
}
|
||||
}
|
||||
|
||||
for _, child := range cmd.Commands() {
|
||||
if child.Hidden || child.Name() == "help" {
|
||||
continue
|
||||
}
|
||||
aliases = append(aliases, collectLLMsAliases(child, llmsCommandName(parentPath, cmd))...)
|
||||
}
|
||||
|
||||
slices.Sort(aliases)
|
||||
return slices.Compact(aliases)
|
||||
}
|
||||
|
||||
func llmsCommandName(parentPath string, cmd *cobra.Command) string {
|
||||
if !cmd.HasParent() {
|
||||
return parentPath
|
||||
}
|
||||
|
||||
parts := strings.Fields(cmd.Use)
|
||||
commandPath := parentPath + " " + parts[0]
|
||||
if len(parts) > 1 {
|
||||
commandPath += " " + strings.Join(parts[1:], " ")
|
||||
}
|
||||
return commandPath
|
||||
}
|
||||
|
||||
func commandPathPrefix(parentPath string, cmd *cobra.Command) string {
|
||||
if cmd.HasParent() {
|
||||
return parentPath + " "
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// writeLLMsCommand recursively writes command documentation in llms.txt format.
|
||||
func writeLLMsCommand(sb *strings.Builder, cmd *cobra.Command, parentPath string) {
|
||||
// Build the full command path including args from Use field
|
||||
commandPath := llmsCommandName(parentPath, cmd)
|
||||
|
||||
// Skip the docs command itself and help command
|
||||
if cmd.Name() == "docs" || cmd.Name() == "help" {
|
||||
return
|
||||
}
|
||||
|
||||
// Determine if this command should be written
|
||||
isRoot := !cmd.HasParent()
|
||||
isRunnable := cmd.RunE != nil || cmd.Run != nil
|
||||
hasVisibleChildren := false
|
||||
for _, child := range cmd.Commands() {
|
||||
if !child.Hidden && child.Name() != "help" {
|
||||
hasVisibleChildren = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Write the root command, runnable commands, and leaf commands (no children)
|
||||
if isRoot || isRunnable || !hasVisibleChildren {
|
||||
// Get description - prefer Long if it's a single clean sentence, otherwise use Short
|
||||
description := cmd.Short
|
||||
if cmd.Long != "" {
|
||||
longLines := strings.Split(strings.TrimSpace(cmd.Long), "\n")
|
||||
if len(longLines) == 1 && len(longLines[0]) < 200 {
|
||||
description = longLines[0]
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Fprintf(sb, "Command: %s\n", commandPath)
|
||||
fmt.Fprintf(sb, "Description: %s\n", description)
|
||||
|
||||
// For root command, show persistent flags; for others, show local flags
|
||||
var flags []*pflag.Flag
|
||||
if isRoot {
|
||||
cmd.PersistentFlags().VisitAll(func(f *pflag.Flag) {
|
||||
if f.Name == "help" {
|
||||
return
|
||||
}
|
||||
flags = append(flags, f)
|
||||
})
|
||||
} else {
|
||||
cmd.LocalFlags().VisitAll(func(f *pflag.Flag) {
|
||||
if f.Name == "help" {
|
||||
return
|
||||
}
|
||||
flags = append(flags, f)
|
||||
})
|
||||
}
|
||||
|
||||
if len(flags) == 0 {
|
||||
sb.WriteString("Parameters: (None)\n")
|
||||
} else {
|
||||
sb.WriteString("Parameters:\n")
|
||||
for _, f := range flags {
|
||||
flagType := f.Value.Type()
|
||||
// Normalize type names
|
||||
switch flagType {
|
||||
case "int", "int32", "int64":
|
||||
flagType = "integer"
|
||||
case "bool":
|
||||
flagType = "boolean"
|
||||
}
|
||||
|
||||
// Check if the flag is marked as required via cobra annotation
|
||||
// or via "(required)" in the usage string
|
||||
required := isFlagRequired(f)
|
||||
|
||||
if f.Shorthand != "" {
|
||||
fmt.Fprintf(sb, " - name: --%s (-%s)\n", f.Name, f.Shorthand)
|
||||
} else {
|
||||
fmt.Fprintf(sb, " - name: --%s\n", f.Name)
|
||||
}
|
||||
fmt.Fprintf(sb, " type: %s\n", flagType)
|
||||
fmt.Fprintf(sb, " description: %s\n", f.Usage)
|
||||
fmt.Fprintf(sb, " required: %t\n", required)
|
||||
if f.DefValue != "" && f.DefValue != "[]" {
|
||||
fmt.Fprintf(sb, " default: %s\n", f.DefValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
// Recurse into subcommands
|
||||
for _, child := range cmd.Commands() {
|
||||
if child.Hidden || child.Name() == "help" {
|
||||
continue
|
||||
}
|
||||
childPath := parentPath
|
||||
if cmd.HasParent() {
|
||||
childPath = llmsCommandName(parentPath, cmd)
|
||||
}
|
||||
writeLLMsCommand(sb, child, childPath)
|
||||
}
|
||||
}
|
||||
|
||||
// isFlagRequired checks if a flag is required by looking at cobra annotations
|
||||
// and the "(required)" convention in usage strings.
|
||||
func isFlagRequired(f *pflag.Flag) bool {
|
||||
// Check cobra's MarkFlagRequired annotation
|
||||
if ann, ok := f.Annotations[cobra.BashCompOneRequiredFlag]; ok && len(ann) > 0 && ann[0] == "true" {
|
||||
return true
|
||||
}
|
||||
// Check for "(required)" in usage string (convention used in this codebase)
|
||||
return strings.Contains(strings.ToLower(f.Usage), "(required)")
|
||||
}
|
||||
|
||||
func NewDocsCommand() *cobra.Command {
|
||||
docsCmd.AddCommand(manCmd)
|
||||
docsCmd.AddCommand(markdownCmd)
|
||||
docsCmd.AddCommand(llmsCmd)
|
||||
|
||||
manCmd.Flags().StringP("output-dir", "o", "./man", "Output directory for man pages")
|
||||
markdownCmd.Flags().StringP("output-dir", "o", "./docs", "Output directory for markdown files")
|
||||
llmsCmd.Flags().StringP("output", "o", "./llms.txt", "Output file path")
|
||||
llmsCmd.Flags().String("full-output", "./llms-full.txt", "Full output file path")
|
||||
|
||||
return docsCmd
|
||||
}
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestWriteLLMsCommandIncludesShorthandAndDefaults(t *testing.T) {
|
||||
root := &cobra.Command{Use: "coolify"}
|
||||
child := &cobra.Command{
|
||||
Use: "logs <uuid>",
|
||||
Short: "Show logs",
|
||||
Run: func(_ *cobra.Command, _ []string) {},
|
||||
}
|
||||
child.Flags().IntP("lines", "n", 0, "Number of log lines to display (0 = all)")
|
||||
child.Flags().Bool("verbose", false, "Verbose output")
|
||||
child.Flags().Bool("enabled", true, "Enabled by default")
|
||||
root.AddCommand(child)
|
||||
|
||||
var sb strings.Builder
|
||||
writeLLMsCommand(&sb, child, "coolify")
|
||||
got := sb.String()
|
||||
|
||||
for _, want := range []string{
|
||||
"Command: coolify logs <uuid>",
|
||||
" - name: --lines (-n)",
|
||||
" default: 0",
|
||||
" - name: --verbose",
|
||||
" default: false",
|
||||
" - name: --enabled",
|
||||
" default: true",
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("expected output to contain %q\nfull output:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteLLMsAliasesUsesCommandTree(t *testing.T) {
|
||||
root := &cobra.Command{Use: "coolify"}
|
||||
teams := &cobra.Command{Use: "teams", Aliases: []string{"team"}}
|
||||
members := &cobra.Command{Use: "members", Aliases: []string{"member"}}
|
||||
start := &cobra.Command{
|
||||
Use: "start <uuid>",
|
||||
Aliases: []string{"deploy"},
|
||||
}
|
||||
|
||||
root.AddCommand(teams)
|
||||
root.AddCommand(start)
|
||||
teams.AddCommand(members)
|
||||
|
||||
var sb strings.Builder
|
||||
writeLLMsAliases(&sb, root, "coolify")
|
||||
got := sb.String()
|
||||
|
||||
for _, want := range []string{
|
||||
"## Command Aliases",
|
||||
"`coolify start` | `coolify deploy`",
|
||||
"`coolify teams` | `coolify team`",
|
||||
"`coolify teams members` | `coolify teams member`",
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("expected alias output to contain %q\nfull output:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildQuickLLMSTextIncludesCoreGuidance(t *testing.T) {
|
||||
got := buildQuickLLMSText("./llms-full.txt")
|
||||
|
||||
for _, want := range []string{
|
||||
"# Coolify CLI - llms.txt",
|
||||
"Prefer `--format json` for automation and parsing.",
|
||||
"coolify context verify",
|
||||
"coolify app logs <uuid> --follow",
|
||||
"Full command and parameter catalog: ./llms-full.txt",
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("expected quick llms output to contain %q\nfull output:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteLLMsArtifactsWritesQuickAndFullFiles(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
quickPath := filepath.Join(tempDir, "llms.txt")
|
||||
fullPath := filepath.Join(tempDir, "nested", "llms-full.txt")
|
||||
|
||||
if err := writeLLMsArtifacts(quickPath, fullPath); err != nil {
|
||||
t.Fatalf("writeLLMsArtifacts() error = %v", err)
|
||||
}
|
||||
|
||||
quickContent, err := os.ReadFile(quickPath)
|
||||
if err != nil {
|
||||
t.Fatalf("failed reading quick file: %v", err)
|
||||
}
|
||||
fullContent, err := os.ReadFile(fullPath)
|
||||
if err != nil {
|
||||
t.Fatalf("failed reading full file: %v", err)
|
||||
}
|
||||
|
||||
for _, want := range []struct {
|
||||
content string
|
||||
substr string
|
||||
}{
|
||||
{string(quickContent), "./nested/llms-full.txt"},
|
||||
{string(fullContent), "../llms.txt"},
|
||||
{string(fullContent), "## Command Reference"},
|
||||
} {
|
||||
if !strings.Contains(want.content, want.substr) {
|
||||
t.Fatalf("expected generated content to contain %q", want.substr)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
package firewall
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/coollabsio/coolify-cli/cmd/common"
|
||||
ifw "github.com/coollabsio/coolify-cli/internal/firewall"
|
||||
"github.com/coollabsio/coolify-cli/internal/models"
|
||||
"github.com/coollabsio/coolify-cli/internal/output"
|
||||
"github.com/coollabsio/coolify-cli/internal/ssh"
|
||||
)
|
||||
|
||||
// allowRevokeFlags are the per-subcommand flags for `allow` / `revoke`.
|
||||
type allowRevokeFlags struct {
|
||||
From string
|
||||
To string
|
||||
Port int
|
||||
Proto string
|
||||
Bidirectional bool
|
||||
}
|
||||
|
||||
// newAllowCommand builds `coolify firewall allow`.
|
||||
func newAllowCommand(parent *Flags) *cobra.Command {
|
||||
local := &allowRevokeFlags{}
|
||||
cmd := &cobra.Command{
|
||||
Use: "allow",
|
||||
Short: "Add an allow rule (from container → to container:port)",
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
return runAllowRevoke(cmd.Context(), cmd, parent, local, false)
|
||||
},
|
||||
}
|
||||
bindAllowRevokeFlags(cmd, local)
|
||||
return cmd
|
||||
}
|
||||
|
||||
// newRevokeCommand builds `coolify firewall revoke`.
|
||||
func newRevokeCommand(parent *Flags) *cobra.Command {
|
||||
local := &allowRevokeFlags{}
|
||||
cmd := &cobra.Command{
|
||||
Use: "revoke",
|
||||
Short: "Remove an allow rule",
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
return runAllowRevoke(cmd.Context(), cmd, parent, local, true)
|
||||
},
|
||||
}
|
||||
bindAllowRevokeFlags(cmd, local)
|
||||
return cmd
|
||||
}
|
||||
|
||||
func bindAllowRevokeFlags(cmd *cobra.Command, f *allowRevokeFlags) {
|
||||
pf := cmd.Flags()
|
||||
pf.StringVar(&f.From, "from", "",
|
||||
"Source container (name, short-id, raw IP, or host:name) — required")
|
||||
pf.StringVar(&f.To, "to", "",
|
||||
"Destination container (name, short-id, raw IP, or host:name) — required")
|
||||
pf.IntVar(&f.Port, "port", 0,
|
||||
"Destination port (required unless --proto is empty)")
|
||||
pf.StringVar(&f.Proto, "proto", "tcp",
|
||||
"Protocol (tcp, udp, or empty for any)")
|
||||
pf.BoolVar(&f.Bidirectional, "bidirectional", false,
|
||||
"Also install the reverse rule on the source host (default: one-way; conntrack handles replies)")
|
||||
}
|
||||
|
||||
func validateAllowRevokeFlags(f *allowRevokeFlags) error {
|
||||
if f.From == "" {
|
||||
return fmt.Errorf("--from is required")
|
||||
}
|
||||
if f.To == "" {
|
||||
return fmt.Errorf("--to is required")
|
||||
}
|
||||
if f.Proto != "" && f.Proto != "tcp" && f.Proto != "udp" {
|
||||
return fmt.Errorf("--proto must be tcp, udp, or empty (got %q)", f.Proto)
|
||||
}
|
||||
if f.Proto != "" && f.Port <= 0 {
|
||||
return fmt.Errorf("--port is required when --proto is set")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func runAllowRevoke(
|
||||
ctx context.Context,
|
||||
cmd *cobra.Command,
|
||||
parent *Flags,
|
||||
local *allowRevokeFlags,
|
||||
revoke bool,
|
||||
) error {
|
||||
if err := parent.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := common.ValidateNamespace(parent.Namespace); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateAllowRevokeFlags(local); err != nil {
|
||||
return err
|
||||
}
|
||||
runner, err := parent.BuildSSHClient()
|
||||
if err != nil {
|
||||
return fmt.Errorf("SSH client: %w", err)
|
||||
}
|
||||
return emitAllowRevoke(ctx, cmd, parent, local, runner, revoke)
|
||||
}
|
||||
|
||||
// emitAllowRevoke is the core path: discover → resolve → build rule → apply.
|
||||
// Split from the cobra wrapper so tests inject a fake ssh.Runner.
|
||||
func emitAllowRevoke(
|
||||
ctx context.Context,
|
||||
cmd *cobra.Command,
|
||||
parent *Flags,
|
||||
local *allowRevokeFlags,
|
||||
runner ssh.Runner,
|
||||
revoke bool,
|
||||
) error {
|
||||
all, results := discoverAllViaPkg(ctx, runner, parent)
|
||||
for _, r := range results {
|
||||
if r.Err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Warning: discover %s: %v\n", r.Host, r.Err)
|
||||
}
|
||||
}
|
||||
|
||||
from, err := resolveEndpoint(local.From, all)
|
||||
if err != nil {
|
||||
return fmt.Errorf("--from: %w", err)
|
||||
}
|
||||
to, err := resolveEndpoint(local.To, all)
|
||||
if err != nil {
|
||||
return fmt.Errorf("--to: %w", err)
|
||||
}
|
||||
if from.IP == nil || to.IP == nil {
|
||||
return fmt.Errorf("failed to resolve endpoint IPs (from=%s to=%s)", local.From, local.To)
|
||||
}
|
||||
|
||||
// Determine destination host (rule owner). If `to` was resolved from a
|
||||
// raw IP with no container match, try to map it via discovery first.
|
||||
dstHost := to.Host
|
||||
if dstHost == "" {
|
||||
if h, ok := findHostForIP(to.IP, all); ok {
|
||||
dstHost = h
|
||||
}
|
||||
}
|
||||
if dstHost == "" {
|
||||
return fmt.Errorf("cannot determine destination host for IP %s — no container on the mesh owns it", to.IP)
|
||||
}
|
||||
|
||||
srcHost := from.Host
|
||||
if srcHost == "" {
|
||||
if h, ok := findHostForIP(from.IP, all); ok {
|
||||
srcHost = h
|
||||
}
|
||||
}
|
||||
|
||||
ns := parent.Namespace
|
||||
primary := ifw.AllowRule{
|
||||
Host: dstHost,
|
||||
Namespace: ns,
|
||||
Src: from.IP,
|
||||
Dst: to.IP,
|
||||
Proto: local.Proto,
|
||||
Port: local.Port,
|
||||
Comment: "cid:" + ifw.ComputeID(ns, from.IP, to.IP, local.Proto, local.Port),
|
||||
}
|
||||
rules := []ifw.AllowRule{primary}
|
||||
|
||||
if local.Bidirectional {
|
||||
if srcHost == "" {
|
||||
return fmt.Errorf("--bidirectional requires the source endpoint to belong to a mesh host")
|
||||
}
|
||||
reverse := ifw.AllowRule{
|
||||
Host: srcHost,
|
||||
Namespace: ns,
|
||||
Src: to.IP,
|
||||
Dst: from.IP,
|
||||
Proto: local.Proto,
|
||||
Port: local.Port,
|
||||
Comment: "cid:" + ifw.ComputeID(ns, to.IP, from.IP, local.Proto, local.Port),
|
||||
}
|
||||
rules = append(rules, reverse)
|
||||
}
|
||||
|
||||
action := "allow"
|
||||
past := "allowed"
|
||||
if revoke {
|
||||
action = "revoke"
|
||||
past = "revoked"
|
||||
}
|
||||
tokenFor := tokenResolver(ctx, runner, parent)
|
||||
for _, r := range rules {
|
||||
token, terr := tokenFor(r.Host)
|
||||
if terr != nil {
|
||||
return fmt.Errorf("%s on %s: %w", action, r.Host, terr)
|
||||
}
|
||||
var rerr error
|
||||
if revoke {
|
||||
// Revoke by id — coold is idempotent (204 even on unknown id).
|
||||
id := strings.TrimPrefix(r.Comment, "cid:")
|
||||
rerr = ifw.CooldRevoke(ctx, runner, r.Host, parent.SSHUser,
|
||||
parent.SSHPort, parent.CooldPort, parent.WGInterface, token, id)
|
||||
} else {
|
||||
rerr = ifw.CooldApply(ctx, runner, r.Host, parent.SSHUser,
|
||||
parent.SSHPort, parent.CooldPort, parent.WGInterface, token, r)
|
||||
}
|
||||
if rerr != nil {
|
||||
return fmt.Errorf("%s on %s: %w", action, r.Host, rerr)
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "%s on %s: %s → %s %s/%d\n",
|
||||
past, r.Host, ipOrAny(r.Src), ipOrAny(r.Dst),
|
||||
protoOrAny(r.Proto), r.Port)
|
||||
}
|
||||
|
||||
rows := make([]models.AllowRuleRow, 0, len(rules))
|
||||
for _, r := range rules {
|
||||
rows = append(rows, models.AllowRuleRow{
|
||||
Host: r.Host,
|
||||
Namespace: r.Namespace,
|
||||
ID: r.Comment,
|
||||
Src: r.Src.String(),
|
||||
Dst: r.Dst.String(),
|
||||
Proto: r.Proto,
|
||||
Port: r.Port,
|
||||
Comment: r.Comment,
|
||||
})
|
||||
}
|
||||
|
||||
format, _ := cmd.Root().PersistentFlags().GetString("format")
|
||||
if format == "" {
|
||||
format = output.FormatTable
|
||||
}
|
||||
formatter, err := output.NewFormatter(format, output.Options{Writer: os.Stdout})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if format == output.FormatJSON || format == output.FormatPretty {
|
||||
return formatter.Format(models.FirewallAllowOutput{Rules: rows})
|
||||
}
|
||||
return formatter.Format(rows)
|
||||
}
|
||||
|
||||
func ipOrAny(ip net.IP) string {
|
||||
if ip == nil {
|
||||
return "any"
|
||||
}
|
||||
return ip.String()
|
||||
}
|
||||
|
||||
func protoOrAny(p string) string {
|
||||
if p == "" {
|
||||
return "any"
|
||||
}
|
||||
return p
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
package firewall
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/coollabsio/coolify-cli/cmd/common"
|
||||
"github.com/coollabsio/coolify-cli/internal/ssh"
|
||||
)
|
||||
|
||||
func TestValidateAllowRevokeFlags(t *testing.T) {
|
||||
t.Run("missing from", func(t *testing.T) {
|
||||
err := validateAllowRevokeFlags(&allowRevokeFlags{To: "x", Port: 80, Proto: "tcp"})
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "--from")
|
||||
})
|
||||
t.Run("missing to", func(t *testing.T) {
|
||||
err := validateAllowRevokeFlags(&allowRevokeFlags{From: "x", Port: 80, Proto: "tcp"})
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "--to")
|
||||
})
|
||||
t.Run("missing port with proto", func(t *testing.T) {
|
||||
err := validateAllowRevokeFlags(&allowRevokeFlags{From: "a", To: "b", Proto: "tcp"})
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "--port")
|
||||
})
|
||||
t.Run("bad proto", func(t *testing.T) {
|
||||
err := validateAllowRevokeFlags(&allowRevokeFlags{From: "a", To: "b", Proto: "icmp", Port: 1})
|
||||
require.Error(t, err)
|
||||
})
|
||||
t.Run("ok tcp", func(t *testing.T) {
|
||||
err := validateAllowRevokeFlags(&allowRevokeFlags{From: "a", To: "b", Proto: "tcp", Port: 80})
|
||||
require.NoError(t, err)
|
||||
})
|
||||
t.Run("ok no-proto no-port", func(t *testing.T) {
|
||||
err := validateAllowRevokeFlags(&allowRevokeFlags{From: "a", To: "b", Proto: "", Port: 0})
|
||||
require.NoError(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
// cmdFakeRunner matches a Runner call against substrings in its response map
|
||||
// and returns the first hit. Mirrors cmd/init/plan_test.go's pattern.
|
||||
type cmdFakeRunner struct {
|
||||
responses map[string]string
|
||||
calls []string
|
||||
}
|
||||
|
||||
func (f *cmdFakeRunner) Run(_ context.Context, _, _ string, _ int, cmd string) (string, string, error) {
|
||||
f.calls = append(f.calls, cmd)
|
||||
for sub, resp := range f.responses {
|
||||
if strings.Contains(cmd, sub) {
|
||||
return resp, "", nil
|
||||
}
|
||||
}
|
||||
return "", "", nil
|
||||
}
|
||||
|
||||
var _ ssh.Runner = (*cmdFakeRunner)(nil)
|
||||
|
||||
func rootCmdFor(cmd *cobra.Command) {
|
||||
root := &cobra.Command{Use: "coolify"}
|
||||
root.PersistentFlags().String("format", "table", "")
|
||||
root.AddCommand(cmd)
|
||||
}
|
||||
|
||||
// parentWithToken builds a Flags pre-wired for the REST path:
|
||||
// single test host, coold port 8443, non-empty bearer token.
|
||||
func parentWithToken() *Flags {
|
||||
return &Flags{
|
||||
SSHMeshFlags: common.SSHMeshFlags{
|
||||
Servers: []string{"h1"}, SSHUser: "root", SSHPort: 22, Concurrency: 1,
|
||||
},
|
||||
Namespace: common.DefaultNamespace,
|
||||
CooldToken: "test-token",
|
||||
CooldPort: 8443,
|
||||
WGInterface: "wg0",
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmitAllowRevoke_PostsOneAllowToCoold(t *testing.T) {
|
||||
fr := &cmdFakeRunner{responses: map[string]string{
|
||||
"podman ps": "aaa111111111|web|10.210.0.10",
|
||||
}}
|
||||
parent := parentWithToken()
|
||||
local := &allowRevokeFlags{
|
||||
From: "10.210.1.5", To: "web", Proto: "tcp", Port: 80,
|
||||
}
|
||||
inner := &cobra.Command{Use: "allow"}
|
||||
rootCmdFor(inner)
|
||||
|
||||
err := emitAllowRevoke(context.Background(), inner, parent, local, fr, false)
|
||||
require.NoError(t, err)
|
||||
|
||||
var posts []string
|
||||
for _, c := range fr.calls {
|
||||
if strings.Contains(c, "-X POST") && strings.Contains(c, "/api/v1/firewall/allow") {
|
||||
posts = append(posts, c)
|
||||
}
|
||||
}
|
||||
assert.Len(t, posts, 1)
|
||||
// Token carried in Authorization header.
|
||||
assert.Contains(t, posts[0], "Authorization: Bearer test-token")
|
||||
// JSON body carries namespace + src/dst/port.
|
||||
assert.Contains(t, posts[0], `"namespace":"default"`)
|
||||
assert.Contains(t, posts[0], `"src":"10.210.1.5"`)
|
||||
assert.Contains(t, posts[0], `"dst":"10.210.0.10"`)
|
||||
assert.Contains(t, posts[0], `"port":80`)
|
||||
// Discovers mgmt IP via wg0 before curl.
|
||||
assert.Contains(t, posts[0], "ip -4 -o addr show wg0")
|
||||
}
|
||||
|
||||
// TestEmitAllowRevoke_CarriesNonDefaultNamespace verifies that the user's
|
||||
// chosen namespace propagates into the JSON body (and therefore into the
|
||||
// cid hash coold will compute).
|
||||
func TestEmitAllowRevoke_CarriesNonDefaultNamespace(t *testing.T) {
|
||||
fr := &cmdFakeRunner{responses: map[string]string{
|
||||
"podman ps": "aaa111111111|web|10.220.0.10",
|
||||
}}
|
||||
parent := parentWithToken()
|
||||
parent.Namespace = "alpha"
|
||||
local := &allowRevokeFlags{
|
||||
From: "10.220.1.5", To: "web", Proto: "tcp", Port: 80,
|
||||
}
|
||||
inner := &cobra.Command{Use: "allow"}
|
||||
rootCmdFor(inner)
|
||||
|
||||
err := emitAllowRevoke(context.Background(), inner, parent, local, fr, false)
|
||||
require.NoError(t, err)
|
||||
var post string
|
||||
for _, c := range fr.calls {
|
||||
if strings.Contains(c, "-X POST") {
|
||||
post = c
|
||||
}
|
||||
}
|
||||
assert.NotEmpty(t, post)
|
||||
assert.Contains(t, post, `"namespace":"alpha"`)
|
||||
// Discovery targets the alpha-namespace bridge, not the default one.
|
||||
var psCalls []string
|
||||
for _, c := range fr.calls {
|
||||
if strings.Contains(c, "podman ps") {
|
||||
psCalls = append(psCalls, c)
|
||||
}
|
||||
}
|
||||
assert.NotEmpty(t, psCalls)
|
||||
assert.Contains(t, psCalls[0], "coolify-alpha-mesh")
|
||||
}
|
||||
|
||||
func TestEmitAllowRevoke_Bidirectional(t *testing.T) {
|
||||
fr := &cmdFakeRunner{responses: map[string]string{
|
||||
"podman ps": "aaa111111111|web|10.210.0.10\nbbb222222222|client|10.210.1.5",
|
||||
}}
|
||||
parent := parentWithToken()
|
||||
local := &allowRevokeFlags{
|
||||
From: "10.210.1.5", To: "10.210.0.10", Proto: "tcp", Port: 80, Bidirectional: true,
|
||||
}
|
||||
inner := &cobra.Command{Use: "allow"}
|
||||
rootCmdFor(inner)
|
||||
|
||||
err := emitAllowRevoke(context.Background(), inner, parent, local, fr, false)
|
||||
require.NoError(t, err)
|
||||
|
||||
var posts int
|
||||
for _, c := range fr.calls {
|
||||
if strings.Contains(c, "-X POST") && strings.Contains(c, "/api/v1/firewall/allow") {
|
||||
posts++
|
||||
}
|
||||
}
|
||||
assert.Equal(t, 2, posts)
|
||||
}
|
||||
|
||||
func TestEmitAllowRevoke_RevokeIssuesDelete(t *testing.T) {
|
||||
fr := &cmdFakeRunner{responses: map[string]string{
|
||||
"podman ps": "aaa111111111|web|10.210.0.10",
|
||||
}}
|
||||
parent := parentWithToken()
|
||||
local := &allowRevokeFlags{
|
||||
From: "10.210.1.5", To: "web", Proto: "tcp", Port: 80,
|
||||
}
|
||||
inner := &cobra.Command{Use: "revoke"}
|
||||
rootCmdFor(inner)
|
||||
|
||||
err := emitAllowRevoke(context.Background(), inner, parent, local, fr, true)
|
||||
require.NoError(t, err)
|
||||
|
||||
var deletes []string
|
||||
for _, c := range fr.calls {
|
||||
if strings.Contains(c, "-X DELETE") && strings.Contains(c, "/api/v1/firewall/allow/") {
|
||||
deletes = append(deletes, c)
|
||||
}
|
||||
}
|
||||
assert.Len(t, deletes, 1)
|
||||
assert.Contains(t, deletes[0], "Authorization: Bearer test-token")
|
||||
}
|
||||
|
||||
func TestEmitAllowRevoke_FetchesTokenPerHostWhenOverrideAbsent(t *testing.T) {
|
||||
// No --coold-token override → CLI SSHes `cat /etc/coolify/api-token`
|
||||
// on the destination host and uses the result as the bearer.
|
||||
fr := &cmdFakeRunner{responses: map[string]string{
|
||||
"podman ps": "aaa111111111|web|10.210.0.10",
|
||||
"/etc/coolify/api-token": "per-host-token\n",
|
||||
}}
|
||||
parent := parentWithToken()
|
||||
parent.CooldToken = ""
|
||||
t.Setenv("COOLIFY_COOLD_TOKEN", "")
|
||||
local := &allowRevokeFlags{
|
||||
From: "10.210.1.5", To: "web", Proto: "tcp", Port: 80,
|
||||
}
|
||||
inner := &cobra.Command{Use: "allow"}
|
||||
rootCmdFor(inner)
|
||||
|
||||
err := emitAllowRevoke(context.Background(), inner, parent, local, fr, false)
|
||||
require.NoError(t, err)
|
||||
var post string
|
||||
for _, c := range fr.calls {
|
||||
if strings.Contains(c, "-X POST") && strings.Contains(c, "/api/v1/firewall/allow") {
|
||||
post = c
|
||||
}
|
||||
}
|
||||
assert.NotEmpty(t, post)
|
||||
assert.Contains(t, post, "Authorization: Bearer per-host-token")
|
||||
}
|
||||
|
||||
func TestEmitAllowRevoke_FetchFailurePropagates(t *testing.T) {
|
||||
// Empty /etc/coolify/api-token on the host → FetchCooldToken errors,
|
||||
// and the error surfaces to the caller instead of silently proceeding.
|
||||
fr := &cmdFakeRunner{responses: map[string]string{
|
||||
"podman ps": "aaa111111111|web|10.210.0.10",
|
||||
// No token file → empty stdout → "token is empty" error.
|
||||
}}
|
||||
parent := parentWithToken()
|
||||
parent.CooldToken = ""
|
||||
t.Setenv("COOLIFY_COOLD_TOKEN", "")
|
||||
local := &allowRevokeFlags{
|
||||
From: "10.210.1.5", To: "web", Proto: "tcp", Port: 80,
|
||||
}
|
||||
inner := &cobra.Command{Use: "allow"}
|
||||
rootCmdFor(inner)
|
||||
|
||||
err := emitAllowRevoke(context.Background(), inner, parent, local, fr, false)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "coold token")
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package firewall
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
ifw "github.com/coollabsio/coolify-cli/internal/firewall"
|
||||
"github.com/coollabsio/coolify-cli/internal/models"
|
||||
"github.com/coollabsio/coolify-cli/internal/output"
|
||||
"github.com/coollabsio/coolify-cli/internal/ssh"
|
||||
)
|
||||
|
||||
// newContainersCommand builds `coolify firewall containers`.
|
||||
func newContainersCommand(flags *Flags) *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "containers",
|
||||
Short: "List containers on the Coolify mesh bridge across all servers",
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
return runContainers(cmd.Context(), cmd, flags)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func runContainers(ctx context.Context, cmd *cobra.Command, flags *Flags) error {
|
||||
if err := flags.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
runner, err := flags.BuildSSHClient()
|
||||
if err != nil {
|
||||
return fmt.Errorf("SSH client: %w", err)
|
||||
}
|
||||
return emitContainers(ctx, cmd, flags, runner)
|
||||
}
|
||||
|
||||
// emitContainers is factored out so tests can pass a fake ssh.Runner.
|
||||
func emitContainers(
|
||||
ctx context.Context,
|
||||
cmd *cobra.Command,
|
||||
flags *Flags,
|
||||
runner ssh.Runner,
|
||||
) error {
|
||||
var (
|
||||
all []ifw.Container
|
||||
results []ssh.ServerResult[[]ifw.Container]
|
||||
)
|
||||
if flags.AllNamespaces {
|
||||
// Discover across every managed network on each host.
|
||||
nsList, nsResults := discoverNamespacesOnHosts(ctx, runner, flags)
|
||||
for _, r := range nsResults {
|
||||
if r.Err != nil {
|
||||
results = append(results, ssh.ServerResult[[]ifw.Container]{
|
||||
Host: r.Host, Err: r.Err,
|
||||
})
|
||||
}
|
||||
}
|
||||
var containerResults []ssh.ServerResult[[]ifw.Container]
|
||||
all, containerResults = discoverAcrossNamespaces(ctx, runner, flags, nsList)
|
||||
results = append(results, containerResults...)
|
||||
} else {
|
||||
all, results = discoverAllViaPkg(ctx, runner, flags)
|
||||
}
|
||||
|
||||
rows := make([]models.ContainerRow, 0, len(all))
|
||||
for _, c := range all {
|
||||
rows = append(rows, models.ContainerRow{
|
||||
Host: c.Host, Namespace: c.Namespace, ID: c.ID, Name: c.Name, IP: c.IP.String(),
|
||||
})
|
||||
}
|
||||
|
||||
var errs []string
|
||||
for _, r := range results {
|
||||
if r.Err != nil {
|
||||
errs = append(errs, fmt.Sprintf("%s: %v", r.Host, r.Err))
|
||||
}
|
||||
}
|
||||
for _, e := range errs {
|
||||
fmt.Fprintln(os.Stderr, "Warning:", e)
|
||||
}
|
||||
|
||||
format, _ := cmd.Root().PersistentFlags().GetString("format")
|
||||
if format == "" {
|
||||
format = output.FormatTable
|
||||
}
|
||||
formatter, err := output.NewFormatter(format, output.Options{Writer: os.Stdout})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if format == output.FormatJSON || format == output.FormatPretty {
|
||||
return formatter.Format(models.FirewallContainersOutput{
|
||||
Containers: rows, Errors: errs,
|
||||
})
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
if flags.AllNamespaces {
|
||||
fmt.Fprintln(os.Stderr, "No containers found on any coolify-<ns>-mesh network.")
|
||||
} else {
|
||||
fmt.Fprintf(os.Stderr, "No containers found on %s network.\n", flags.PodmanNetworkName())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return formatter.Format(rows)
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package firewall
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/coollabsio/coolify-cli/cmd/common"
|
||||
)
|
||||
|
||||
func TestEmitContainers_RunsAndFormatsTable(t *testing.T) {
|
||||
fr := &cmdFakeRunner{responses: map[string]string{
|
||||
"podman ps": "aaa111111111|web|10.210.0.10",
|
||||
}}
|
||||
parent := &Flags{
|
||||
SSHMeshFlags: common.SSHMeshFlags{
|
||||
Servers: []string{"h1"}, SSHUser: "root", SSHPort: 22, Concurrency: 1,
|
||||
},
|
||||
Namespace: common.DefaultNamespace,
|
||||
}
|
||||
inner := &cobra.Command{Use: "containers"}
|
||||
rootCmdFor(inner)
|
||||
|
||||
err := emitContainers(context.Background(), inner, parent, fr)
|
||||
require.NoError(t, err)
|
||||
// Discovery command was issued, targeting the default-namespace bridge.
|
||||
assert.Len(t, fr.calls, 1)
|
||||
assert.Contains(t, fr.calls[0], "podman ps")
|
||||
assert.Contains(t, fr.calls[0], "coolify-default-mesh")
|
||||
}
|
||||
|
||||
func TestEmitContainers_EmptyOutput(t *testing.T) {
|
||||
fr := &cmdFakeRunner{responses: map[string]string{}}
|
||||
parent := &Flags{
|
||||
SSHMeshFlags: common.SSHMeshFlags{
|
||||
Servers: []string{"h1"}, SSHUser: "root", SSHPort: 22, Concurrency: 1,
|
||||
},
|
||||
Namespace: common.DefaultNamespace,
|
||||
}
|
||||
inner := &cobra.Command{Use: "containers"}
|
||||
rootCmdFor(inner)
|
||||
|
||||
err := emitContainers(context.Background(), inner, parent, fr)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// TestEmitContainers_AllNamespaces_FansOutAcrossNetworks verifies that with
|
||||
// --all-namespaces the CLI first enumerates managed networks on every host
|
||||
// and then issues one podman-ps per namespace it found.
|
||||
func TestEmitContainers_AllNamespaces_FansOutAcrossNetworks(t *testing.T) {
|
||||
fr := &cmdFakeRunner{responses: map[string]string{
|
||||
// Host reports two managed namespaces via label inspection.
|
||||
"podman network ls": "default\nalpha\n",
|
||||
// Every subsequent podman-ps returns the same container.
|
||||
"podman ps": "aaa111111111|web|10.210.0.10",
|
||||
}}
|
||||
parent := &Flags{
|
||||
SSHMeshFlags: common.SSHMeshFlags{
|
||||
Servers: []string{"h1"}, SSHUser: "root", SSHPort: 22, Concurrency: 1,
|
||||
},
|
||||
Namespace: common.DefaultNamespace,
|
||||
AllNamespaces: true,
|
||||
}
|
||||
inner := &cobra.Command{Use: "containers"}
|
||||
rootCmdFor(inner)
|
||||
|
||||
err := emitContainers(context.Background(), inner, parent, fr)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Expect one `podman network ls` discovery call + one `podman ps` per
|
||||
// discovered namespace (default + alpha = 2).
|
||||
var ls, ps int
|
||||
for _, c := range fr.calls {
|
||||
switch {
|
||||
case containsAll(c, "podman network ls", "io.coolify.managed=true"):
|
||||
ls++
|
||||
case containsAll(c, "podman ps"):
|
||||
ps++
|
||||
}
|
||||
}
|
||||
assert.Equal(t, 1, ls, "one namespace-discovery call per host")
|
||||
assert.Equal(t, 2, ps, "one container-discovery call per namespace per host")
|
||||
}
|
||||
|
||||
func containsAll(s string, subs ...string) bool {
|
||||
for _, sub := range subs {
|
||||
if !contains(s, sub) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func contains(s, sub string) bool {
|
||||
// Tiny local wrapper so tests stay readable without importing strings
|
||||
// twice — the test file already uses it elsewhere via cmdFakeRunner.
|
||||
for i := 0; i+len(sub) <= len(s); i++ {
|
||||
if s[i:i+len(sub)] == sub {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package firewall
|
||||
|
||||
import (
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// NewFirewallCommand creates the parent `coolify firewall` command.
|
||||
// On bare invocation (no subcommand) it prints help.
|
||||
func NewFirewallCommand() *cobra.Command {
|
||||
flags := &Flags{}
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "firewall",
|
||||
Short: "[ALPHA] Manage cross-host container allow rules (Coolify v5)",
|
||||
Long: `[ALPHA] Manage the COOLIFY-ALLOW iptables chain installed by
|
||||
"coolify init --podman --default-deny". This is a test harness for the v5
|
||||
control-plane firewall flow: it SSHes into every server, discovers running
|
||||
containers on the Coolify mesh bridge (override with --podman-network), and
|
||||
lets you add/remove cross-host allow rules.
|
||||
|
||||
Subcommands:
|
||||
containers List discovered containers across the mesh.
|
||||
list Show installed allow rules.
|
||||
allow Add an allow rule (src container → dst container:port).
|
||||
revoke Remove an allow rule.`,
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
return cmd.Help()
|
||||
},
|
||||
}
|
||||
|
||||
bindFlags(cmd, flags)
|
||||
|
||||
cmd.AddCommand(newContainersCommand(flags))
|
||||
cmd.AddCommand(newListCommand(flags))
|
||||
cmd.AddCommand(newAllowCommand(flags))
|
||||
cmd.AddCommand(newRevokeCommand(flags))
|
||||
|
||||
return cmd
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package firewall
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestNewFirewallCommand_Subcommands(t *testing.T) {
|
||||
cmd := NewFirewallCommand()
|
||||
assert.Equal(t, "firewall", cmd.Use)
|
||||
subs := map[string]*cobra.Command{}
|
||||
for _, s := range cmd.Commands() {
|
||||
subs[s.Use] = s
|
||||
}
|
||||
assert.Contains(t, subs, "containers")
|
||||
assert.Contains(t, subs, "list")
|
||||
assert.Contains(t, subs, "allow")
|
||||
assert.Contains(t, subs, "revoke")
|
||||
}
|
||||
|
||||
func TestNewFirewallCommand_PersistentFlags(t *testing.T) {
|
||||
cmd := NewFirewallCommand()
|
||||
pf := cmd.PersistentFlags()
|
||||
for _, name := range []string{"servers", "ssh-key", "ssh-user", "ssh-port",
|
||||
"concurrency", "ssh-timeout", "namespace", "all-namespaces",
|
||||
"coold-token", "coold-port", "wg-interface"} {
|
||||
assert.NotNil(t, pf.Lookup(name), "missing --%s", name)
|
||||
}
|
||||
// Replaced by --namespace; must be gone.
|
||||
assert.Nil(t, pf.Lookup("podman-network"))
|
||||
}
|
||||
|
||||
func TestAllowCommand_LocalFlags(t *testing.T) {
|
||||
cmd := NewFirewallCommand()
|
||||
var allow *cobra.Command
|
||||
for _, s := range cmd.Commands() {
|
||||
if s.Use == "allow" {
|
||||
allow = s
|
||||
break
|
||||
}
|
||||
}
|
||||
if allow == nil {
|
||||
t.Fatal("allow subcommand not found")
|
||||
}
|
||||
for _, name := range []string{"from", "to", "port", "proto", "bidirectional"} {
|
||||
assert.NotNil(t, allow.Flags().Lookup(name), "missing --%s on allow", name)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
// Package firewall implements the `coolify firewall` command tree. It is a
|
||||
// thin SSH-bounced client for the coold agent's REST API: `allow` / `revoke`
|
||||
// / `list` POST/DELETE/GET against coold on the destination host, while
|
||||
// `containers` stays SSH+podman because coold has no container surface.
|
||||
// See CONTROL_PLANE.md §3.
|
||||
package firewall
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/coollabsio/coolify-cli/cmd/common"
|
||||
ifw "github.com/coollabsio/coolify-cli/internal/firewall"
|
||||
)
|
||||
|
||||
// Flags is the shared flag set for every `coolify firewall`
|
||||
// subcommand: SSH plumbing (via embed) + namespace selection + coold REST
|
||||
// endpoint/token. The podman network name is derived from the namespace
|
||||
// (coolify-<ns>-mesh) so the CLI and `coolify init` stay in sync.
|
||||
type Flags struct {
|
||||
common.SSHMeshFlags
|
||||
|
||||
// Namespace is the mesh namespace the command operates against. Derives
|
||||
// the podman network (common.PodmanNetworkFor) and is forwarded to coold
|
||||
// as part of every rule / list query.
|
||||
Namespace string
|
||||
|
||||
// AllNamespaces, when true, makes namespace-aware subcommands operate
|
||||
// across every namespace the mesh carries. Each subcommand interprets it
|
||||
// contextually (list: union across namespaces; containers: discover every
|
||||
// coolify-<ns>-mesh network on each host).
|
||||
AllNamespaces bool
|
||||
|
||||
// CooldToken is an optional bearer-token override for coold's REST API.
|
||||
// When unset (and COOLIFY_COOLD_TOKEN env is unset), the CLI SSHes into
|
||||
// each host and reads /etc/coolify/api-token instead — tokens are
|
||||
// generated per-host at install time and are not centrally shared.
|
||||
CooldToken string
|
||||
// CooldPort is the TCP port coold listens on (bound to the WG mgmt IP).
|
||||
// Must match COOLD_API_BIND emitted by internal/services/coold.go.
|
||||
CooldPort int
|
||||
// WGInterface is the WireGuard interface name used to discover coold's
|
||||
// bind IP on each host. Must match --wg-interface used at `coolify init`.
|
||||
WGInterface string
|
||||
}
|
||||
|
||||
// bindFlags registers the persistent flags on the parent command.
|
||||
func bindFlags(cmd *cobra.Command, f *Flags) {
|
||||
common.BindSSHMeshFlags(cmd, &f.SSHMeshFlags)
|
||||
common.BindMeshNetSingleFlags(cmd, &f.Namespace)
|
||||
pf := cmd.PersistentFlags()
|
||||
pf.BoolVar(&f.AllNamespaces, "all-namespaces", false,
|
||||
"Operate across every mesh namespace on each host (list/containers fan out; "+
|
||||
"allow/revoke still require a specific --namespace)")
|
||||
pf.StringVar(&f.CooldToken, "coold-token", "",
|
||||
"Bearer token override for coold REST API (also reads COOLIFY_COOLD_TOKEN env). "+
|
||||
"When unset, CLI reads /etc/coolify/api-token over SSH per host.")
|
||||
pf.IntVar(&f.CooldPort, "coold-port", 8443,
|
||||
"TCP port coold's REST API listens on (bound to the WG mgmt IP)")
|
||||
pf.StringVar(&f.WGInterface, "wg-interface", ifw.DefaultWGInterface,
|
||||
"WireGuard interface name on remote hosts (must match --wg-interface at init)")
|
||||
}
|
||||
|
||||
// ResolveCooldToken returns the bearer-token override supplied via flag or
|
||||
// env, or "" when neither is set. Callers treat an empty string as "no
|
||||
// override — SSH-fetch the per-host token instead".
|
||||
func (f *Flags) ResolveCooldToken() (string, error) {
|
||||
if f.CooldToken != "" {
|
||||
return f.CooldToken, nil
|
||||
}
|
||||
if env := os.Getenv("COOLIFY_COOLD_TOKEN"); env != "" {
|
||||
return env, nil
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// PodmanNetworkName returns the podman bridge that backs the selected
|
||||
// namespace on every host. Used by container discovery.
|
||||
func (f *Flags) PodmanNetworkName() string {
|
||||
return common.PodmanNetworkFor(f.Namespace)
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
package firewall
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/coollabsio/coolify-cli/cmd/common"
|
||||
ifw "github.com/coollabsio/coolify-cli/internal/firewall"
|
||||
"github.com/coollabsio/coolify-cli/internal/ssh"
|
||||
)
|
||||
|
||||
// discoverAllViaPkg is a thin wrapper around ifw.DiscoverAll /
|
||||
// ifw.DiscoverAllNamespaces that threads the Flags in. Used by
|
||||
// `containers` (SSH+podman) and by `allow` / `revoke` for endpoint
|
||||
// resolution; `list` goes straight to coold REST.
|
||||
//
|
||||
// When AllNamespaces is set, the fanout walks every supplied namespace; the
|
||||
// caller (containers subcommand) is responsible for enumerating which
|
||||
// namespaces exist on the hosts — absent that, falls back to the selected
|
||||
// single namespace.
|
||||
func discoverAllViaPkg(
|
||||
ctx context.Context,
|
||||
runner ssh.Runner,
|
||||
flags *Flags,
|
||||
) ([]ifw.Container, []ssh.ServerResult[[]ifw.Container]) {
|
||||
return ifw.DiscoverAll(ctx, runner, flags.Servers, flags.SSHUser,
|
||||
flags.SSHPort, flags.Namespace, flags.PodmanNetworkName(),
|
||||
flags.Concurrency)
|
||||
}
|
||||
|
||||
// discoverAcrossNamespaces runs DiscoverAllNamespaces for every supplied
|
||||
// namespace. Network name is derived from common.PodmanNetworkFor so the
|
||||
// caller only has to supply the namespace list.
|
||||
func discoverAcrossNamespaces(
|
||||
ctx context.Context,
|
||||
runner ssh.Runner,
|
||||
flags *Flags,
|
||||
namespaces []string,
|
||||
) ([]ifw.Container, []ssh.ServerResult[[]ifw.Container]) {
|
||||
return ifw.DiscoverAllNamespaces(ctx, runner, flags.Servers,
|
||||
flags.SSHUser, flags.SSHPort, namespaces,
|
||||
common.PodmanNetworkFor, flags.Concurrency)
|
||||
}
|
||||
|
||||
// discoverNamespacesOnHosts SSHes into every host and lists every podman
|
||||
// network carrying the io.coolify.managed=true label, collecting the unique
|
||||
// io.coolify.namespace label values. Used by `containers --all-namespaces`.
|
||||
// Returns the per-host results so host-level failures surface as warnings
|
||||
// instead of aborting the fanout.
|
||||
func discoverNamespacesOnHosts(
|
||||
ctx context.Context,
|
||||
runner ssh.Runner,
|
||||
flags *Flags,
|
||||
) ([]string, []ssh.ServerResult[[]string]) {
|
||||
// `podman network ls`'s `{{.Labels}}` renders as a comma-separated `k=v`
|
||||
// string (not a map, unlike `podman network inspect`), so `index` can't be
|
||||
// used — pull `io.coolify.namespace=<val>` out with sed instead.
|
||||
script := `podman network ls --filter label=io.coolify.managed=true ` +
|
||||
`--format '{{.Labels}}' 2>/dev/null | ` +
|
||||
`sed -n 's/.*io\.coolify\.namespace=\([^,]*\).*/\1/p' || true`
|
||||
results := ssh.ForEachServer(ctx, flags.Servers, flags.Concurrency,
|
||||
func(ctx context.Context, host string) ([]string, error) {
|
||||
stdout, _, err := runner.Run(ctx, host, flags.SSHUser,
|
||||
flags.SSHPort, script)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var nss []string
|
||||
for _, line := range strings.Split(stdout, "\n") {
|
||||
ns := strings.TrimSpace(line)
|
||||
if ns != "" {
|
||||
nss = append(nss, ns)
|
||||
}
|
||||
}
|
||||
return nss, nil
|
||||
})
|
||||
seen := map[string]struct{}{}
|
||||
for _, r := range results {
|
||||
for _, ns := range r.Result {
|
||||
seen[ns] = struct{}{}
|
||||
}
|
||||
}
|
||||
// Always probe the selected namespace too — caller may have just created
|
||||
// it and we haven't seen it on any host yet.
|
||||
seen[flags.Namespace] = struct{}{}
|
||||
all := make([]string, 0, len(seen))
|
||||
for ns := range seen {
|
||||
all = append(all, ns)
|
||||
}
|
||||
sort.Strings(all)
|
||||
return all, results
|
||||
}
|
||||
|
||||
// tokenResolver returns a closure that hands out coold bearer tokens
|
||||
// per-host. Precedence: explicit --coold-token (or COOLIFY_COOLD_TOKEN env)
|
||||
// wins for every host; otherwise SSH into the host once and cache the
|
||||
// contents of /etc/coolify/api-token. The cache is goroutine-safe so the
|
||||
// closure can be passed straight into CooldListAll's fanout.
|
||||
func tokenResolver(
|
||||
ctx context.Context,
|
||||
runner ssh.Runner,
|
||||
flags *Flags,
|
||||
) func(host string) (string, error) {
|
||||
if override, _ := flags.ResolveCooldToken(); override != "" {
|
||||
return func(string) (string, error) { return override, nil }
|
||||
}
|
||||
var (
|
||||
mu sync.Mutex
|
||||
cache = map[string]string{}
|
||||
)
|
||||
return func(host string) (string, error) {
|
||||
mu.Lock()
|
||||
if tok, ok := cache[host]; ok {
|
||||
mu.Unlock()
|
||||
return tok, nil
|
||||
}
|
||||
mu.Unlock()
|
||||
tok, err := ifw.FetchCooldToken(ctx, runner, host, flags.SSHUser, flags.SSHPort)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
mu.Lock()
|
||||
cache[host] = tok
|
||||
mu.Unlock()
|
||||
return tok, nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package firewall
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
ifw "github.com/coollabsio/coolify-cli/internal/firewall"
|
||||
"github.com/coollabsio/coolify-cli/internal/models"
|
||||
"github.com/coollabsio/coolify-cli/internal/output"
|
||||
"github.com/coollabsio/coolify-cli/internal/ssh"
|
||||
)
|
||||
|
||||
// newListCommand builds `coolify firewall list`.
|
||||
func newListCommand(flags *Flags) *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List installed allow rules across all servers",
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
return runList(cmd.Context(), cmd, flags)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func runList(ctx context.Context, cmd *cobra.Command, flags *Flags) error {
|
||||
if err := flags.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
runner, err := flags.BuildSSHClient()
|
||||
if err != nil {
|
||||
return fmt.Errorf("SSH client: %w", err)
|
||||
}
|
||||
return emitList(ctx, cmd, flags, runner)
|
||||
}
|
||||
|
||||
func emitList(
|
||||
ctx context.Context,
|
||||
cmd *cobra.Command,
|
||||
flags *Flags,
|
||||
runner ssh.Runner,
|
||||
) error {
|
||||
tokenFor := tokenResolver(ctx, runner, flags)
|
||||
|
||||
// --all-namespaces → omit the query param so coold returns the union.
|
||||
ns := flags.Namespace
|
||||
if flags.AllNamespaces {
|
||||
ns = ""
|
||||
}
|
||||
all, results := ifw.CooldListAll(ctx, runner, flags.Servers, flags.SSHUser,
|
||||
flags.SSHPort, flags.CooldPort, flags.WGInterface, tokenFor,
|
||||
flags.Concurrency, ns)
|
||||
|
||||
rows := make([]models.AllowRuleRow, 0, len(all))
|
||||
for _, r := range all {
|
||||
rows = append(rows, models.AllowRuleRow{
|
||||
Host: r.Host,
|
||||
Namespace: r.Namespace,
|
||||
ID: r.Comment,
|
||||
Src: r.Src.String(),
|
||||
Dst: r.Dst.String(),
|
||||
Proto: r.Proto,
|
||||
Port: r.Port,
|
||||
Comment: r.Comment,
|
||||
})
|
||||
}
|
||||
|
||||
var errs []string
|
||||
for _, r := range results {
|
||||
if r.Err != nil {
|
||||
errs = append(errs, fmt.Sprintf("%s: %v", r.Host, r.Err))
|
||||
}
|
||||
}
|
||||
for _, e := range errs {
|
||||
fmt.Fprintln(os.Stderr, "Warning:", e)
|
||||
}
|
||||
|
||||
format, _ := cmd.Root().PersistentFlags().GetString("format")
|
||||
if format == "" {
|
||||
format = output.FormatTable
|
||||
}
|
||||
formatter, err := output.NewFormatter(format, output.Options{Writer: os.Stdout})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if format == output.FormatJSON || format == output.FormatPretty {
|
||||
return formatter.Format(models.FirewallListOutput{
|
||||
Rules: rows, Errors: errs,
|
||||
})
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
fmt.Fprintln(os.Stderr, "No allow rules found. Run `coolify firewall allow ...` to add one.")
|
||||
return nil
|
||||
}
|
||||
return formatter.Format(rows)
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package firewall
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestEmitList_CallsCooldGet(t *testing.T) {
|
||||
fr := &cmdFakeRunner{responses: map[string]string{
|
||||
"/api/v1/firewall/allow": `[{"src":"10.0.0.1","dst":"10.0.0.2","proto":"tcp","port":80,"id":"abc123def456"}]`,
|
||||
}}
|
||||
parent := parentWithToken()
|
||||
inner := &cobra.Command{Use: "list"}
|
||||
rootCmdFor(inner)
|
||||
|
||||
err := emitList(context.Background(), inner, parent, fr)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, fr.calls, 1)
|
||||
assert.Contains(t, fr.calls[0], "curl")
|
||||
assert.Contains(t, fr.calls[0], "/api/v1/firewall/allow")
|
||||
assert.Contains(t, fr.calls[0], "Authorization: Bearer test-token")
|
||||
}
|
||||
|
||||
func TestEmitList_EmptyCoold(t *testing.T) {
|
||||
fr := &cmdFakeRunner{responses: map[string]string{}}
|
||||
parent := parentWithToken()
|
||||
inner := &cobra.Command{Use: "list"}
|
||||
rootCmdFor(inner)
|
||||
|
||||
err := emitList(context.Background(), inner, parent, fr)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestEmitList_FetchesPerHostTokenWhenOverrideAbsent(t *testing.T) {
|
||||
// Without --coold-token override, each host's token is read via SSH
|
||||
// `cat /etc/coolify/api-token` then used as the bearer for GET /allow.
|
||||
fr := &cmdFakeRunner{responses: map[string]string{
|
||||
"/etc/coolify/api-token": "per-host-token\n",
|
||||
"/api/v1/firewall/allow": `[]`,
|
||||
}}
|
||||
parent := parentWithToken()
|
||||
parent.CooldToken = ""
|
||||
t.Setenv("COOLIFY_COOLD_TOKEN", "")
|
||||
inner := &cobra.Command{Use: "list"}
|
||||
rootCmdFor(inner)
|
||||
|
||||
err := emitList(context.Background(), inner, parent, fr)
|
||||
require.NoError(t, err)
|
||||
var ranTokenFetch, ranGet bool
|
||||
for _, c := range fr.calls {
|
||||
if strings.Contains(c, "cat /etc/coolify/api-token") {
|
||||
ranTokenFetch = true
|
||||
}
|
||||
if strings.Contains(c, "curl") && strings.Contains(c, "Authorization: Bearer per-host-token") {
|
||||
ranGet = true
|
||||
}
|
||||
}
|
||||
assert.True(t, ranTokenFetch, "CLI should SSH-fetch the token")
|
||||
assert.True(t, ranGet, "bearer should be the fetched token")
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
package firewall
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"strings"
|
||||
|
||||
ifw "github.com/coollabsio/coolify-cli/internal/firewall"
|
||||
)
|
||||
|
||||
// resolveEndpoint turns a user-supplied reference (name, short-id, raw IP,
|
||||
// or "host:name") into the container it points at. When ref is a raw IP
|
||||
// that doesn't match any discovered container, it returns a synthetic
|
||||
// entry with Host="" — the caller must derive Host some other way.
|
||||
//
|
||||
// Ambiguous names across hosts are rejected; the user must disambiguate
|
||||
// with "host:name" or a short-ID.
|
||||
func resolveEndpoint(ref string, all []ifw.Container) (ifw.Container, error) {
|
||||
ref = strings.TrimSpace(ref)
|
||||
if ref == "" {
|
||||
return ifw.Container{}, fmt.Errorf("empty container reference")
|
||||
}
|
||||
|
||||
// "host:name" form — exact host disambiguator.
|
||||
if host, name, ok := splitHostName(ref); ok {
|
||||
for _, c := range all {
|
||||
if c.Host == host && c.Name == name {
|
||||
return c, nil
|
||||
}
|
||||
}
|
||||
return ifw.Container{}, fmt.Errorf("no container named %q on host %q", name, host)
|
||||
}
|
||||
|
||||
// Raw IP form.
|
||||
if ip := net.ParseIP(ref); ip != nil {
|
||||
for _, c := range all {
|
||||
if c.IP.Equal(ip) {
|
||||
return c, nil
|
||||
}
|
||||
}
|
||||
// Synthetic: caller must decide on Host.
|
||||
return ifw.Container{IP: ip}, nil
|
||||
}
|
||||
|
||||
// Name / short-id form. Collect matches, error on ambiguity.
|
||||
var matches []ifw.Container
|
||||
for _, c := range all {
|
||||
if c.Name == ref || strings.HasPrefix(c.ID, ref) {
|
||||
matches = append(matches, c)
|
||||
}
|
||||
}
|
||||
switch len(matches) {
|
||||
case 0:
|
||||
return ifw.Container{}, fmt.Errorf("no container matches %q", ref)
|
||||
case 1:
|
||||
return matches[0], nil
|
||||
default:
|
||||
return ifw.Container{}, fmt.Errorf(
|
||||
"reference %q is ambiguous across hosts (%s) — use host:name form",
|
||||
ref, hostList(matches))
|
||||
}
|
||||
}
|
||||
|
||||
func splitHostName(ref string) (host, name string, ok bool) {
|
||||
i := strings.IndexByte(ref, ':')
|
||||
if i <= 0 || i == len(ref)-1 {
|
||||
return "", "", false
|
||||
}
|
||||
// Reject if the part after `:` looks like a port (all digits) — likely
|
||||
// an IP:port form the user didn't mean.
|
||||
name = ref[i+1:]
|
||||
host = ref[:i]
|
||||
if allDigits(name) {
|
||||
return "", "", false
|
||||
}
|
||||
return host, name, true
|
||||
}
|
||||
|
||||
func allDigits(s string) bool {
|
||||
if s == "" {
|
||||
return false
|
||||
}
|
||||
for _, r := range s {
|
||||
if r < '0' || r > '9' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func hostList(cs []ifw.Container) string {
|
||||
seen := map[string]bool{}
|
||||
var hosts []string
|
||||
for _, c := range cs {
|
||||
if !seen[c.Host] {
|
||||
hosts = append(hosts, c.Host)
|
||||
seen[c.Host] = true
|
||||
}
|
||||
}
|
||||
return strings.Join(hosts, ", ")
|
||||
}
|
||||
|
||||
// findHostForIP returns the SSH host that owns ip (i.e. the host whose
|
||||
// coolify-mesh bridge has ip assigned). Used when --to/--from is given as
|
||||
// a raw IP not tied to a running container.
|
||||
func findHostForIP(ip net.IP, all []ifw.Container) (string, bool) {
|
||||
for _, c := range all {
|
||||
if c.IP.Equal(ip) {
|
||||
return c.Host, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package firewall
|
||||
|
||||
import (
|
||||
"net"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
ifw "github.com/coollabsio/coolify-cli/internal/firewall"
|
||||
)
|
||||
|
||||
func cs() []ifw.Container {
|
||||
return []ifw.Container{
|
||||
{Host: "h1", ID: "aaa111111111", Name: "web", IP: net.ParseIP("10.210.0.10")},
|
||||
{Host: "h2", ID: "bbb222222222", Name: "api", IP: net.ParseIP("10.210.1.10")},
|
||||
{Host: "h3", ID: "ccc333333333", Name: "web", IP: net.ParseIP("10.210.2.10")},
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveEndpoint_ByName_Unique(t *testing.T) {
|
||||
c, err := resolveEndpoint("api", cs())
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "h2", c.Host)
|
||||
assert.Equal(t, "10.210.1.10", c.IP.String())
|
||||
}
|
||||
|
||||
func TestResolveEndpoint_ByName_Ambiguous(t *testing.T) {
|
||||
_, err := resolveEndpoint("web", cs())
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "ambiguous")
|
||||
}
|
||||
|
||||
func TestResolveEndpoint_ByShortID(t *testing.T) {
|
||||
c, err := resolveEndpoint("bbb", cs())
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "h2", c.Host)
|
||||
}
|
||||
|
||||
func TestResolveEndpoint_ByHostName(t *testing.T) {
|
||||
c, err := resolveEndpoint("h3:web", cs())
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "h3", c.Host)
|
||||
assert.Equal(t, "10.210.2.10", c.IP.String())
|
||||
}
|
||||
|
||||
func TestResolveEndpoint_ByRawIP(t *testing.T) {
|
||||
c, err := resolveEndpoint("10.210.1.10", cs())
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "h2", c.Host)
|
||||
}
|
||||
|
||||
func TestResolveEndpoint_UnknownRawIP_Synthetic(t *testing.T) {
|
||||
c, err := resolveEndpoint("10.99.99.99", cs())
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, c.Host)
|
||||
assert.Equal(t, "10.99.99.99", c.IP.String())
|
||||
}
|
||||
|
||||
func TestResolveEndpoint_NotFound(t *testing.T) {
|
||||
_, err := resolveEndpoint("nobody", cs())
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestResolveEndpoint_Empty(t *testing.T) {
|
||||
_, err := resolveEndpoint("", cs())
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestFindHostForIP(t *testing.T) {
|
||||
h, ok := findHostForIP(net.ParseIP("10.210.0.10"), cs())
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, "h1", h)
|
||||
_, ok = findHostForIP(net.ParseIP("1.2.3.4"), cs())
|
||||
assert.False(t, ok)
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
package initcmd
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/mattn/go-isatty"
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/coollabsio/coolify-cli/internal/models"
|
||||
"github.com/coollabsio/coolify-cli/internal/output"
|
||||
internalssh "github.com/coollabsio/coolify-cli/internal/ssh"
|
||||
"github.com/coollabsio/coolify-cli/internal/wireguard"
|
||||
)
|
||||
|
||||
// Ensure internalssh is used (for *internalssh.Client in signatures).
|
||||
var _ *internalssh.Client
|
||||
|
||||
// applyOptions tweaks runApply per subcommand.
|
||||
type applyOptions struct {
|
||||
// SkipAlphaGate, when true, bypasses the interactive "press enter"
|
||||
// confirmation. upgrade/extend set it because those are called from the
|
||||
// Coolify backend in production, not a human at a terminal.
|
||||
SkipAlphaGate bool
|
||||
|
||||
// Header is a one-line banner describing the intent (e.g. "extending
|
||||
// mesh with 1 new host"). Printed to stderr before the plan.
|
||||
Header string
|
||||
}
|
||||
|
||||
func runApply(ctx context.Context, cmd *cobra.Command, flags *InitFlags, opts applyOptions) error {
|
||||
fmt.Fprint(os.Stderr, alphaBanner)
|
||||
|
||||
if err := validatePlanFlags(flags); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !opts.SkipAlphaGate && !shouldSkipGate(flags) {
|
||||
fmt.Fprintln(os.Stderr, "This command will modify network configuration on the listed servers.")
|
||||
fmt.Fprint(os.Stderr, "Press Enter to continue, or Ctrl+C to abort... ")
|
||||
reader := bufio.NewReader(os.Stdin)
|
||||
if _, err := reader.ReadString('\n'); err != nil {
|
||||
return fmt.Errorf("read confirmation: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
desired, err := buildDesired(flags)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := wireguard.ValidateIntent(desired); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
sshClient, err := flags.BuildSSHClient()
|
||||
if err != nil {
|
||||
return fmt.Errorf("SSH client: %w", err)
|
||||
}
|
||||
|
||||
if opts.Header != "" {
|
||||
fmt.Fprintln(os.Stderr, opts.Header)
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "Probing %d server(s)...\n", len(flags.Servers))
|
||||
|
||||
current, probeErr := wireguard.Reconstruct(ctx, sshClient, flags.Servers,
|
||||
flags.SSHUser, flags.SSHPort, flags.WGInterface,
|
||||
flags.Namespaces, flags.Concurrency)
|
||||
if probeErr != nil {
|
||||
fmt.Fprintf(os.Stderr, "Warning: %v\n", probeErr)
|
||||
}
|
||||
|
||||
plan, err := wireguard.BuildPlan(desired, current)
|
||||
if err != nil {
|
||||
return fmt.Errorf("build plan: %w", err)
|
||||
}
|
||||
|
||||
for _, w := range plan.Warnings {
|
||||
fmt.Fprintf(os.Stderr, "Warning [%s]: %s\n", w.Host, w.Reason)
|
||||
}
|
||||
|
||||
format, _ := cmd.Root().PersistentFlags().GetString("format")
|
||||
|
||||
if plan.IsEmpty() {
|
||||
fmt.Fprintln(os.Stderr, "No changes needed. Mesh is already converged.")
|
||||
} else {
|
||||
fmt.Fprintln(os.Stderr, "Plan:")
|
||||
for _, a := range plan.Actions {
|
||||
fmt.Fprintf(os.Stderr, " [%s] %s %s\n", a.Host, a.Type, a.Detail)
|
||||
}
|
||||
fmt.Fprintln(os.Stderr)
|
||||
}
|
||||
if len(plan.Skipped) > 0 {
|
||||
fmt.Fprintln(os.Stderr, "Skipped by intent filter:")
|
||||
for _, s := range plan.Skipped {
|
||||
fmt.Fprintf(os.Stderr, " [%s] %s — %s\n", s.Action.Host, s.Action.Type, s.Reason)
|
||||
}
|
||||
fmt.Fprintln(os.Stderr)
|
||||
}
|
||||
|
||||
if plan.IsEmpty() {
|
||||
return runVerify(ctx, sshClient, flags, desired, format)
|
||||
}
|
||||
|
||||
fmt.Fprintln(os.Stderr, "Applying...")
|
||||
actionResults, applyErr := wireguard.ApplyMesh(ctx, sshClient,
|
||||
flags.SSHUser, flags.SSHPort, desired, current, flags.Concurrency)
|
||||
|
||||
rows := make([]models.ApplyResultRow, len(actionResults))
|
||||
for i, r := range actionResults {
|
||||
status := "ok"
|
||||
detail := r.Action.Detail
|
||||
if r.Err != nil {
|
||||
status = "error"
|
||||
if detail == "" {
|
||||
detail = r.Err.Error()
|
||||
}
|
||||
}
|
||||
rows[i] = models.ApplyResultRow{
|
||||
Server: r.Action.Host,
|
||||
Action: string(r.Action.Type),
|
||||
Status: status,
|
||||
Detail: detail,
|
||||
}
|
||||
}
|
||||
|
||||
if format == output.FormatJSON || format == output.FormatPretty {
|
||||
verifyRows := collectVerifyRows(ctx, sshClient, flags, desired)
|
||||
out := models.ApplyOutput{Results: rows, Verified: verifyRows}
|
||||
formatter, ferr := output.NewFormatter(format, output.Options{Writer: os.Stdout})
|
||||
if ferr != nil {
|
||||
return ferr
|
||||
}
|
||||
if err := formatter.Format(out); err != nil {
|
||||
return err
|
||||
}
|
||||
return applyErr
|
||||
}
|
||||
|
||||
if len(rows) > 0 {
|
||||
formatter, _ := output.NewFormatter(output.FormatTable, output.Options{Writer: os.Stdout})
|
||||
_ = formatter.Format(rows)
|
||||
}
|
||||
|
||||
if err := runVerify(ctx, sshClient, flags, desired, format); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return applyErr
|
||||
}
|
||||
|
||||
// shouldSkipGate returns true when the interactive alpha gate should be bypassed.
|
||||
func shouldSkipGate(flags *InitFlags) bool {
|
||||
if flags.Yes {
|
||||
return true
|
||||
}
|
||||
if os.Getenv("COOLIFY_NON_INTERACTIVE") == "1" {
|
||||
return true
|
||||
}
|
||||
if !isatty.IsTerminal(os.Stdin.Fd()) && !isatty.IsCygwinTerminal(os.Stdin.Fd()) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func runVerify(ctx context.Context, sshClient *internalssh.Client, flags *InitFlags, desired *wireguard.DesiredMesh, format string) error {
|
||||
fmt.Fprintln(os.Stderr, "Verifying...")
|
||||
vrows := collectVerifyRows(ctx, sshClient, flags, desired)
|
||||
|
||||
formatter, err := output.NewFormatter(format, output.Options{Writer: os.Stdout})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return formatter.Format(vrows)
|
||||
}
|
||||
|
||||
func collectVerifyRows(ctx context.Context, sshClient *internalssh.Client, flags *InitFlags, desired *wireguard.DesiredMesh) []models.VerifyResultRow {
|
||||
vresults := wireguard.Verify(ctx, sshClient,
|
||||
flags.Servers, flags.SSHUser, flags.SSHPort, desired.Interface, flags.Concurrency)
|
||||
|
||||
rows := make([]models.VerifyResultRow, len(vresults))
|
||||
for i, v := range vresults {
|
||||
status := "ok"
|
||||
wgIP := ""
|
||||
if v.WireGuardIP != nil {
|
||||
wgIP = v.WireGuardIP.String()
|
||||
}
|
||||
if v.Err != nil || !v.Active {
|
||||
status = "error"
|
||||
}
|
||||
rows[i] = models.VerifyResultRow{
|
||||
Server: v.Host,
|
||||
WireGuardIP: wgIP,
|
||||
PeerCount: v.PeerCount,
|
||||
Status: status,
|
||||
}
|
||||
}
|
||||
return rows
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package initcmd
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestNewInitCommand verifies the command tree structure.
|
||||
func TestNewInitCommand(t *testing.T) {
|
||||
cmd := NewInitCommand()
|
||||
|
||||
assert.Equal(t, "init", cmd.Use)
|
||||
assert.NotEmpty(t, cmd.Short)
|
||||
|
||||
subCmds := map[string]*cobra.Command{}
|
||||
for _, sub := range cmd.Commands() {
|
||||
subCmds[sub.Use] = sub
|
||||
}
|
||||
assert.Contains(t, subCmds, "plan")
|
||||
assert.Contains(t, subCmds, "bootstrap")
|
||||
assert.Contains(t, subCmds, "extend")
|
||||
assert.Contains(t, subCmds, "upgrade")
|
||||
assert.NotContains(t, subCmds, "apply", "apply removed in favor of bootstrap/extend/upgrade")
|
||||
}
|
||||
|
||||
// TestNewInitCommand_PersistentFlags verifies shared flags are registered.
|
||||
func TestNewInitCommand_PersistentFlags(t *testing.T) {
|
||||
cmd := NewInitCommand()
|
||||
pf := cmd.PersistentFlags()
|
||||
|
||||
assert.NotNil(t, pf.Lookup("servers"))
|
||||
assert.NotNil(t, pf.Lookup("ssh-key"))
|
||||
assert.NotNil(t, pf.Lookup("ssh-user"))
|
||||
assert.NotNil(t, pf.Lookup("ssh-port"))
|
||||
assert.NotNil(t, pf.Lookup("wg-mgmt-pool"))
|
||||
assert.NotNil(t, pf.Lookup("container-pool"))
|
||||
assert.NotNil(t, pf.Lookup("container-prefix"))
|
||||
assert.NotNil(t, pf.Lookup("wg-interface"))
|
||||
assert.NotNil(t, pf.Lookup("wg-listen-port"))
|
||||
assert.NotNil(t, pf.Lookup("namespaces"))
|
||||
assert.NotNil(t, pf.Lookup("skip-default-deny"))
|
||||
assert.NotNil(t, pf.Lookup("concurrency"))
|
||||
assert.NotNil(t, pf.Lookup("ssh-timeout"))
|
||||
assert.NotNil(t, pf.Lookup("yes"))
|
||||
// Old flags removed.
|
||||
assert.Nil(t, pf.Lookup("wg-pool"))
|
||||
assert.Nil(t, pf.Lookup("wg-host-prefix"))
|
||||
assert.Nil(t, pf.Lookup("wg-subnet"))
|
||||
assert.Nil(t, pf.Lookup("podman"))
|
||||
assert.Nil(t, pf.Lookup("default-deny"))
|
||||
assert.Nil(t, pf.Lookup("install-coold"))
|
||||
// Replaced by --namespaces.
|
||||
assert.Nil(t, pf.Lookup("podman-network"))
|
||||
}
|
||||
|
||||
// TestNewInitCommand_FlagDefaults verifies default values.
|
||||
func TestNewInitCommand_FlagDefaults(t *testing.T) {
|
||||
cmd := NewInitCommand()
|
||||
pf := cmd.PersistentFlags()
|
||||
|
||||
user, err := pf.GetString("ssh-user")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "root", user)
|
||||
|
||||
port, err := pf.GetInt("ssh-port")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 22, port)
|
||||
|
||||
mgmtPool, err := pf.GetString("wg-mgmt-pool")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "100.64.0.0/16", mgmtPool)
|
||||
|
||||
contPool, err := pf.GetString("container-pool")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "10.210.0.0/16", contPool)
|
||||
|
||||
contPrefix, err := pf.GetInt("container-prefix")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 24, contPrefix)
|
||||
|
||||
iface, err := pf.GetString("wg-interface")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "wg0", iface)
|
||||
|
||||
listenPort, err := pf.GetInt("wg-listen-port")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 51820, listenPort)
|
||||
|
||||
namespaces, err := pf.GetStringSlice("namespaces")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []string{"default"}, namespaces)
|
||||
|
||||
skipDefaultDeny, err := pf.GetBool("skip-default-deny")
|
||||
require.NoError(t, err)
|
||||
assert.False(t, skipDefaultDeny)
|
||||
|
||||
concurrency, err := pf.GetInt("concurrency")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 10, concurrency)
|
||||
|
||||
timeout, err := pf.GetString("ssh-timeout")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "30s", timeout)
|
||||
}
|
||||
|
||||
// TestPlanCommand_FlagsInherited verifies that plan inherits parent persistent flags.
|
||||
func TestPlanCommand_FlagsInherited(t *testing.T) {
|
||||
init := NewInitCommand()
|
||||
_ = init.ParseFlags([]string{})
|
||||
|
||||
var planCmd *cobra.Command
|
||||
for _, sub := range init.Commands() {
|
||||
if sub.Use == "plan" {
|
||||
planCmd = sub
|
||||
break
|
||||
}
|
||||
}
|
||||
require.NotNil(t, planCmd)
|
||||
|
||||
f := planCmd.InheritedFlags().Lookup("servers")
|
||||
assert.NotNil(t, f, "plan should inherit --servers from parent")
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package initcmd
|
||||
|
||||
import (
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/coollabsio/coolify-cli/internal/wireguard"
|
||||
)
|
||||
|
||||
// NewBootstrapCommand creates the `coolify init bootstrap` subcommand — the
|
||||
// first-time mesh install. Runs every applicable action on every host and
|
||||
// keeps the interactive alpha gate (unless --yes / non-TTY / env override).
|
||||
func NewBootstrapCommand(flags *InitFlags) *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "bootstrap",
|
||||
Short: "First-time mesh install (all actions allowed)",
|
||||
Long: `Bootstrap a fresh WireGuard + Podman + coold mesh across every host in
|
||||
--servers. Idempotent: re-running with no changes produces an empty plan.
|
||||
|
||||
Use this for the initial install. For adding hosts later, see
|
||||
` + "`coolify init extend`" + `; for bumping agent versions, see
|
||||
` + "`coolify init upgrade`" + `.`,
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
flags.Intent = string(wireguard.IntentBootstrap)
|
||||
return runApply(cmd.Context(), cmd, flags, applyOptions{
|
||||
Header: "Bootstrapping mesh...",
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package initcmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
|
||||
"github.com/coollabsio/coolify-cli/internal/wireguard"
|
||||
)
|
||||
|
||||
// buildDesired turns the flag struct into a wireguard.DesiredMesh. Intent is
|
||||
// pulled from flags.Intent so each subcommand can set it before calling the
|
||||
// shared plan/apply pipeline.
|
||||
func buildDesired(flags *InitFlags) (*wireguard.DesiredMesh, error) {
|
||||
_, mgmtPool, err := net.ParseCIDR(flags.WGMgmtPool)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid --wg-mgmt-pool %q: %w", flags.WGMgmtPool, err)
|
||||
}
|
||||
_, contPool, err := net.ParseCIDR(flags.ContainerPool)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid --container-pool %q: %w", flags.ContainerPool, err)
|
||||
}
|
||||
|
||||
return &wireguard.DesiredMesh{
|
||||
Hosts: flags.Servers,
|
||||
Interface: flags.WGInterface,
|
||||
MgmtPool: mgmtPool,
|
||||
ContainerPool: contPool,
|
||||
ContainerPrefix: flags.ContainerPrefix,
|
||||
ListenPort: flags.WGListenPort,
|
||||
InstallPodman: true,
|
||||
Namespaces: flags.Namespaces,
|
||||
DefaultDenyContainers: !flags.SkipDefaultDeny,
|
||||
InstallCoold: true,
|
||||
CooldVersion: flags.CooldVersion,
|
||||
CorrosionVersion: flags.CorrosionVersion,
|
||||
CorrosionGossipPort: flags.CorrosionGossipPort,
|
||||
CorrosionAPIPort: flags.CorrosionAPIPort,
|
||||
CentralHost: flags.CentralHost,
|
||||
SchedulerVersion: flags.SchedulerVersion,
|
||||
EnableBuilder: flags.EnableBuilder,
|
||||
BuilderHosts: flags.BuilderHosts,
|
||||
BuilderCapacity: flags.BuilderCapacity,
|
||||
BuilderCPUQuota: flags.BuilderCPUQuota,
|
||||
BuilderMemoryMax: flags.BuilderMemoryMax,
|
||||
BuilderTimeoutSecs: flags.BuilderTimeoutSecs,
|
||||
Intent: wireguard.Intent(flags.Intent),
|
||||
NewHosts: flags.NewHosts,
|
||||
AllowReplace: flags.AllowReplace,
|
||||
AllowNightly: flags.AllowNightly,
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package initcmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/coollabsio/coolify-cli/internal/wireguard"
|
||||
)
|
||||
|
||||
// NewExtendCommand creates the `coolify init extend` subcommand. It adds the
|
||||
// hosts listed in --new-hosts to an existing mesh: new hosts get the full
|
||||
// first-time install; existing hosts get only peer-refresh actions (WG
|
||||
// AllowedIPs update, corrosion config refresh, firewall unit reinstall if
|
||||
// namespace list changed). Destructive actions on existing hosts are blocked
|
||||
// unless --allow-replace is set.
|
||||
func NewExtendCommand(flags *InitFlags) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "extend",
|
||||
Short: "Add new hosts to an existing mesh (existing hosts stay untouched)",
|
||||
Long: `Extend an existing mesh with brand-new hosts. --new-hosts lists the
|
||||
subset of --servers that is brand-new; those hosts receive the full
|
||||
first-time install (install WG, generate keys, install podman, install
|
||||
coold/corrosion, create bridges, etc.).
|
||||
|
||||
Existing hosts in --servers are re-probed and get only the peer-refresh
|
||||
actions required to route traffic to the new peer: WG config rewrite,
|
||||
corrosion peer list refresh, firewall unit reinstall when the namespace
|
||||
list changed. Agent binaries are not re-downloaded on existing hosts —
|
||||
use ` + "`coolify init upgrade`" + ` for that.
|
||||
|
||||
--allow-replace unlocks destructive-replace actions (e.g. recreating a
|
||||
drifted podman bridge) on existing hosts. Handle with care: containers
|
||||
on a recreated bridge are disconnected.`,
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
if len(flags.NewHosts) == 0 {
|
||||
return fmt.Errorf("--new-hosts is required: list the subset of --servers that is brand-new")
|
||||
}
|
||||
servers := make(map[string]struct{}, len(flags.Servers))
|
||||
for _, s := range flags.Servers {
|
||||
servers[s] = struct{}{}
|
||||
}
|
||||
for _, nh := range flags.NewHosts {
|
||||
if _, ok := servers[nh]; !ok {
|
||||
return fmt.Errorf("--new-hosts: %q is not in --servers", nh)
|
||||
}
|
||||
}
|
||||
|
||||
flags.Intent = string(wireguard.IntentExtend)
|
||||
|
||||
header := fmt.Sprintf("Extending mesh with %d new host(s): %v", len(flags.NewHosts), flags.NewHosts)
|
||||
return runApply(cmd.Context(), cmd, flags, applyOptions{
|
||||
SkipAlphaGate: true,
|
||||
Header: header,
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringSliceVar(&flags.NewHosts, "new-hosts", nil,
|
||||
"Comma-separated subset of --servers that is brand-new this run (required). Only these hosts receive the full first-time install; all other hosts get peer-refresh only.")
|
||||
cmd.Flags().BoolVar(&flags.AllowReplace, "allow-replace", false,
|
||||
"Unlock destructive-replace actions on existing hosts (e.g. recreating a drifted podman bridge). Off by default — drifted existing hosts are surfaced as skipped actions instead.")
|
||||
|
||||
return cmd
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
// Package initcmd implements the `coolify init` alpha WireGuard mesh
|
||||
// bootstrap command tree (Coolify v5).
|
||||
package initcmd
|
||||
|
||||
import (
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/coollabsio/coolify-cli/cmd/common"
|
||||
)
|
||||
|
||||
// InitFlags holds all flags shared between `plan` and `apply`.
|
||||
type InitFlags struct {
|
||||
common.SSHMeshFlags
|
||||
common.MeshNetFlags
|
||||
|
||||
WGMgmtPool string
|
||||
WGInterface string
|
||||
WGListenPort int
|
||||
SkipDefaultDeny bool
|
||||
CooldVersion string
|
||||
CorrosionVersion string
|
||||
CorrosionGossipPort int
|
||||
CorrosionAPIPort int
|
||||
Yes bool
|
||||
|
||||
// CentralHost is the SSH address of the central VM (from --central flag).
|
||||
// When non-empty, phases 4+5 install the scheduler on that host and push
|
||||
// per-host JWTs to all other hosts. Default empty = no scheduler setup.
|
||||
CentralHost string
|
||||
SchedulerVersion string
|
||||
|
||||
// EnableBuilder is a cluster-wide shorthand: when true (and BuilderHosts
|
||||
// is empty), every host in Servers is enrolled as builder-capable. When
|
||||
// BuilderHosts is non-empty, EnableBuilder is ignored and only the
|
||||
// listed subset gets the capability.
|
||||
EnableBuilder bool
|
||||
|
||||
// BuilderHosts is an explicit list of SSH addresses (subset of Servers)
|
||||
// to enroll with the builder capability. Empty = fall back to
|
||||
// EnableBuilder semantics. Mutually exclusive in practice with
|
||||
// EnableBuilder=false (leaves builder fully disabled).
|
||||
BuilderHosts []string
|
||||
BuilderCapacity int
|
||||
BuilderCPUQuota string
|
||||
BuilderMemoryMax string
|
||||
BuilderTimeoutSecs int
|
||||
|
||||
// NewHosts is the extend-subcommand-only list of brand-new hosts. Must
|
||||
// be a subset of Servers. Existing hosts in Servers get only peer-refresh
|
||||
// actions; new hosts get the full first-time install.
|
||||
NewHosts []string
|
||||
|
||||
// AllowReplace unlocks destructive-replace actions on existing hosts in
|
||||
// extend mode (e.g. recreating a podman bridge whose dns_enabled=true
|
||||
// pre-alpha drift would otherwise be blocked).
|
||||
AllowReplace bool
|
||||
|
||||
// AllowNightly permits the upgrade subcommand to accept "nightly" as a
|
||||
// version tag. Rejected by default because nightly forces a re-install on
|
||||
// every run instead of only when the pinned version changes.
|
||||
AllowNightly bool
|
||||
|
||||
// Intent selects the plan filter (bootstrap/extend/upgrade). Set by each
|
||||
// subcommand before calling runPlan/runApply; not bound to a flag.
|
||||
Intent string
|
||||
}
|
||||
|
||||
// bindInitFlags registers all shared flags as PersistentFlags on cmd.
|
||||
func bindInitFlags(cmd *cobra.Command, f *InitFlags) {
|
||||
common.BindSSHMeshFlags(cmd, &f.SSHMeshFlags)
|
||||
common.BindMeshNetMultiFlags(cmd, &f.MeshNetFlags)
|
||||
|
||||
pf := cmd.PersistentFlags()
|
||||
|
||||
pf.StringVar(&f.WGMgmtPool, "wg-mgmt-pool", "100.64.0.0/16",
|
||||
"WireGuard management address pool — each host gets a /32 from here, assigned to wg0")
|
||||
pf.StringVar(&f.WGInterface, "wg-interface", "wg0",
|
||||
"WireGuard interface name on the remote hosts")
|
||||
pf.IntVar(&f.WGListenPort, "wg-listen-port", 51820,
|
||||
"WireGuard UDP listen port")
|
||||
pf.BoolVar(&f.SkipDefaultDeny, "skip-default-deny", false,
|
||||
"Skip installing the default-deny firewall scaffold. By default, both cross-host and intra-host (same bridge) container traffic is blocked; coold manages the allow list at runtime")
|
||||
pf.StringVar(&f.CooldVersion, "coold-version", "nightly",
|
||||
`Release tag to download for coold (e.g. "nightly", "v1.2.3"). nightly always re-installs on every apply.`)
|
||||
pf.StringVar(&f.CorrosionVersion, "corrosion-version", "nightly",
|
||||
`Release tag to download for corrosion (e.g. "nightly", "v1.2.3"). nightly always re-installs on every apply.`)
|
||||
pf.IntVar(&f.CorrosionGossipPort, "corrosion-gossip-port", 8787,
|
||||
"Corrosion SWIM gossip port (bound to the wg0 mgmt IP)")
|
||||
pf.IntVar(&f.CorrosionAPIPort, "corrosion-api-port", 8080,
|
||||
"Corrosion HTTP API port (bound to 127.0.0.1)")
|
||||
pf.BoolVarP(&f.Yes, "yes", "y", false,
|
||||
"Skip the interactive alpha confirmation prompt")
|
||||
pf.StringVar(&f.CentralHost, "central", "",
|
||||
`SSH address of the central VM that will run the scheduler (and later Laravel).
|
||||
Must be one of the --servers entries. When set, phases 4+5 install the scheduler on that host
|
||||
and push a per-host JWT to every other server. Leave empty to skip scheduler setup.`)
|
||||
pf.StringVar(&f.SchedulerVersion, "scheduler-version", "nightly",
|
||||
`Release tag to download for scheduler (e.g. "nightly", "v1.2.3").`)
|
||||
pf.BoolVar(&f.EnableBuilder, "enable-builder", true,
|
||||
`Cluster-wide shorthand: enable the builder capability on every host
|
||||
(requires --central). Ignored when --builder-hosts is set.`)
|
||||
pf.StringSliceVar(&f.BuilderHosts, "builder-hosts", nil,
|
||||
`Explicit subset of --servers to enroll with the builder capability.
|
||||
Takes precedence over --enable-builder. Empty (default) means fall back to
|
||||
--enable-builder for the whole cluster.`)
|
||||
pf.IntVar(&f.BuilderCapacity, "builder-capacity", 2,
|
||||
"Concurrent builds accepted per host (COOLD_BUILDER_CAPACITY).")
|
||||
pf.StringVar(&f.BuilderCPUQuota, "builder-cpu-quota", "200%",
|
||||
`cgroup CPU quota for each build subprocess (COOLD_BUILDER_CPU_QUOTA).
|
||||
systemd CPUQuota format; "200%" = two full cores.`)
|
||||
pf.StringVar(&f.BuilderMemoryMax, "builder-memory-max", "2G",
|
||||
`cgroup memory cap for each build subprocess (COOLD_BUILDER_MEMORY_MAX).
|
||||
systemd MemoryMax format; e.g. "2G", "512M".`)
|
||||
pf.IntVar(&f.BuilderTimeoutSecs, "builder-timeout-secs", 1800,
|
||||
"Hard wall-clock timeout per build in seconds (COOLD_BUILDER_TIMEOUT_SECS).")
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package initcmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
const alphaBanner = `
|
||||
[ALPHA] coolify init targets Coolify v5 and is experimental.
|
||||
[ALPHA] WireGuard mesh bootstrap requires root/sudo and modifies network configuration.
|
||||
[ALPHA] Test in non-production environments first. Stability is not guaranteed.
|
||||
`
|
||||
|
||||
// NewInitCommand creates the parent `coolify init` command.
|
||||
// On bare invocation (no subcommand) it prints the alpha banner and help.
|
||||
func NewInitCommand() *cobra.Command {
|
||||
flags := &InitFlags{}
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "init",
|
||||
Short: "[ALPHA] Initialize WireGuard mesh for Coolify v5",
|
||||
Long: `[ALPHA] Bootstrap a WireGuard full-mesh overlay between servers and
|
||||
provision each host with the Coolify v5 runtime stack: Podman + bridge
|
||||
network, default-deny iptables scaffold, and the coold/corrosion
|
||||
control-plane agents.
|
||||
|
||||
Subcommands:
|
||||
plan Show what would change without touching anything (--intent
|
||||
selects the filter: bootstrap / extend / upgrade).
|
||||
bootstrap First-time install (all actions allowed).
|
||||
extend Add new hosts to an existing mesh; existing hosts get only
|
||||
peer-refresh actions.
|
||||
upgrade Bump agent versions (coold / corrosion / scheduler / builder);
|
||||
WG / podman / firewall untouched.`,
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
fmt.Fprint(os.Stderr, alphaBanner)
|
||||
return cmd.Help()
|
||||
},
|
||||
}
|
||||
|
||||
bindInitFlags(cmd, flags)
|
||||
|
||||
cmd.AddCommand(NewPlanCommand(flags))
|
||||
cmd.AddCommand(NewBootstrapCommand(flags))
|
||||
cmd.AddCommand(NewExtendCommand(flags))
|
||||
cmd.AddCommand(NewUpgradeCommand(flags))
|
||||
|
||||
return cmd
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
package initcmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/coollabsio/coolify-cli/internal/models"
|
||||
"github.com/coollabsio/coolify-cli/internal/output"
|
||||
"github.com/coollabsio/coolify-cli/internal/wireguard"
|
||||
)
|
||||
|
||||
// NewPlanCommand creates the `coolify init plan` subcommand.
|
||||
func NewPlanCommand(flags *InitFlags) *cobra.Command {
|
||||
var intentFlag string
|
||||
cmd := &cobra.Command{
|
||||
Use: "plan",
|
||||
Short: "Show WireGuard mesh changes without applying them",
|
||||
Long: `Reconstruct the current WireGuard state from each server via SSH and
|
||||
show the actions that apply would execute. Nothing is changed.
|
||||
|
||||
Pass --intent to preview a specific subcommand's behavior (bootstrap, extend,
|
||||
upgrade). bootstrap is the default and matches the pre-split behavior.`,
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
fmt.Fprint(os.Stderr, alphaBanner)
|
||||
flags.Intent = intentFlag
|
||||
return runPlan(cmd.Context(), cmd, flags)
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVar(&intentFlag, "intent", "bootstrap",
|
||||
`Preview filter: "bootstrap" (all actions), "extend" (treat --new-hosts as fresh, existing hosts peer-refresh only), "upgrade" (version bumps only).`)
|
||||
return cmd
|
||||
}
|
||||
|
||||
func runPlan(ctx context.Context, cmd *cobra.Command, flags *InitFlags) error {
|
||||
if err := validatePlanFlags(flags); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
desired, err := buildDesired(flags)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := wireguard.ValidateIntent(desired); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
sshClient, err := flags.BuildSSHClient()
|
||||
if err != nil {
|
||||
return fmt.Errorf("SSH client: %w", err)
|
||||
}
|
||||
|
||||
fmt.Fprintf(os.Stderr, "Probing %d server(s)...\n", len(flags.Servers))
|
||||
|
||||
current, err := wireguard.Reconstruct(ctx, sshClient, flags.Servers,
|
||||
flags.SSHUser, flags.SSHPort, flags.WGInterface,
|
||||
flags.Namespaces, flags.Concurrency)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Warning: %v\n", err)
|
||||
}
|
||||
|
||||
plan, err := wireguard.BuildPlan(desired, current)
|
||||
if err != nil {
|
||||
return fmt.Errorf("build plan: %w", err)
|
||||
}
|
||||
|
||||
for _, w := range plan.Warnings {
|
||||
fmt.Fprintf(os.Stderr, "Warning [%s]: %s\n", w.Host, w.Reason)
|
||||
}
|
||||
|
||||
format, _ := cmd.Root().PersistentFlags().GetString("format")
|
||||
intent := intentLabel(flags.Intent)
|
||||
|
||||
if plan.IsEmpty() && len(plan.Skipped) == 0 {
|
||||
msg := "No changes needed. Mesh is already converged."
|
||||
if format == output.FormatJSON {
|
||||
out := models.PlanOutput{
|
||||
Servers: flags.Servers,
|
||||
Intent: intent,
|
||||
Actions: []models.PlanActionRow{},
|
||||
Warnings: warningsToStrings(plan.Warnings),
|
||||
}
|
||||
formatter, _ := output.NewFormatter(format, output.Options{Writer: os.Stdout})
|
||||
return formatter.Format(out)
|
||||
}
|
||||
fmt.Println(msg)
|
||||
return nil
|
||||
}
|
||||
|
||||
rows := make([]models.PlanActionRow, len(plan.Actions))
|
||||
for i, a := range plan.Actions {
|
||||
rows[i] = models.PlanActionRow{
|
||||
Server: a.Host,
|
||||
Action: string(a.Type),
|
||||
Detail: a.Detail,
|
||||
}
|
||||
}
|
||||
skipped := skippedRows(plan.Skipped)
|
||||
|
||||
formatter, err := output.NewFormatter(format, output.Options{Writer: os.Stdout})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if format == output.FormatJSON || format == output.FormatPretty {
|
||||
return formatter.Format(models.PlanOutput{
|
||||
Servers: flags.Servers,
|
||||
Intent: intent,
|
||||
Actions: rows,
|
||||
Skipped: skipped,
|
||||
Warnings: warningsToStrings(plan.Warnings),
|
||||
})
|
||||
}
|
||||
|
||||
if len(rows) > 0 {
|
||||
if err := formatter.Format(rows); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
fmt.Println("No actions scheduled.")
|
||||
}
|
||||
if len(skipped) > 0 {
|
||||
fmt.Fprintln(os.Stderr)
|
||||
fmt.Fprintln(os.Stderr, "Skipped by intent filter:")
|
||||
for _, s := range skipped {
|
||||
fmt.Fprintf(os.Stderr, " [%s] %s — %s\n", s.Server, s.Action, s.Reason)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validatePlanFlags(f *InitFlags) error {
|
||||
if err := f.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
return f.ValidateNamespaces()
|
||||
}
|
||||
|
||||
// warningsToStrings formats allocator warnings as human-readable strings.
|
||||
func warningsToStrings(ws []wireguard.Warning) []string {
|
||||
if len(ws) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]string, len(ws))
|
||||
for i, w := range ws {
|
||||
out[i] = fmt.Sprintf("[%s] %s", w.Host, w.Reason)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// skippedRows converts the plan's intent-filtered actions into render rows.
|
||||
func skippedRows(ss []wireguard.SkippedAction) []models.PlanSkippedRow {
|
||||
if len(ss) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]models.PlanSkippedRow, len(ss))
|
||||
for i, s := range ss {
|
||||
out[i] = models.PlanSkippedRow{
|
||||
Server: s.Action.Host,
|
||||
Action: string(s.Action.Type),
|
||||
Reason: s.Reason,
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// intentLabel normalizes an empty or zero intent to "bootstrap" for display.
|
||||
func intentLabel(raw string) string {
|
||||
if raw == "" {
|
||||
return "bootstrap"
|
||||
}
|
||||
return raw
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package initcmd
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/coollabsio/coolify-cli/cmd/common"
|
||||
)
|
||||
|
||||
// TestValidatePlanFlags checks required flag validation.
|
||||
func TestValidatePlanFlags(t *testing.T) {
|
||||
t.Run("missing servers", func(t *testing.T) {
|
||||
err := validatePlanFlags(&InitFlags{
|
||||
SSHMeshFlags: common.SSHMeshFlags{SSHKey: "/path/to/key"},
|
||||
})
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "--servers")
|
||||
})
|
||||
|
||||
t.Run("missing ssh key", func(t *testing.T) {
|
||||
err := validatePlanFlags(&InitFlags{
|
||||
SSHMeshFlags: common.SSHMeshFlags{Servers: []string{"1.1.1.1"}},
|
||||
})
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "--ssh-key")
|
||||
})
|
||||
|
||||
t.Run("valid", func(t *testing.T) {
|
||||
err := validatePlanFlags(&InitFlags{
|
||||
SSHMeshFlags: common.SSHMeshFlags{
|
||||
Servers: []string{"1.1.1.1"},
|
||||
SSHKey: "/path/to/key",
|
||||
},
|
||||
MeshNetFlags: common.MeshNetFlags{
|
||||
Namespaces: []string{common.DefaultNamespace},
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("invalid namespace", func(t *testing.T) {
|
||||
err := validatePlanFlags(&InitFlags{
|
||||
SSHMeshFlags: common.SSHMeshFlags{
|
||||
Servers: []string{"1.1.1.1"},
|
||||
SSHKey: "/path/to/key",
|
||||
},
|
||||
MeshNetFlags: common.MeshNetFlags{
|
||||
Namespaces: []string{"Not Valid"},
|
||||
},
|
||||
})
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "invalid namespace")
|
||||
})
|
||||
}
|
||||
|
||||
// TestShouldSkipGate verifies the alpha gate bypass logic.
|
||||
func TestShouldSkipGate(t *testing.T) {
|
||||
// --yes flag
|
||||
assert.True(t, shouldSkipGate(&InitFlags{Yes: true}))
|
||||
|
||||
// Without --yes and without env var, behaviour depends on TTY.
|
||||
// We can't reliably test the TTY path in unit tests, but we can
|
||||
// confirm the env-var bypass.
|
||||
t.Setenv("COOLIFY_NON_INTERACTIVE", "1")
|
||||
assert.True(t, shouldSkipGate(&InitFlags{}))
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package initcmd
|
||||
|
||||
import (
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/coollabsio/coolify-cli/internal/wireguard"
|
||||
)
|
||||
|
||||
// NewUpgradeCommand creates the `coolify init upgrade` subcommand: bumps
|
||||
// coold/corrosion/scheduler/builder binaries across every host. Does not touch
|
||||
// WG config, podman networks, firewall rules, or the corrosion schema. Rejects
|
||||
// "nightly" version tags unless --allow-nightly is set.
|
||||
func NewUpgradeCommand(flags *InitFlags) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "upgrade",
|
||||
Short: "Bump agent binary versions (coold / corrosion / scheduler / builder) on every host",
|
||||
Long: `Upgrade the agent binaries managed by coolify init across every host in
|
||||
--servers. Only binary-fetch actions and their follow-up service restarts
|
||||
run; WG config, podman networks, firewall rules, and the corrosion schema
|
||||
are left untouched.
|
||||
|
||||
Pin each binary with --coold-version / --corrosion-version /
|
||||
--scheduler-version. "nightly" is rejected by default because it forces a
|
||||
re-install on every run; pass --allow-nightly to override.`,
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
flags.Intent = string(wireguard.IntentUpgrade)
|
||||
return runApply(cmd.Context(), cmd, flags, applyOptions{
|
||||
SkipAlphaGate: true,
|
||||
Header: "Upgrading agent binaries...",
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().BoolVar(&flags.AllowNightly, "allow-nightly", false,
|
||||
"Permit --coold-version/--corrosion-version/--scheduler-version=nightly. Off by default because nightly re-installs on every run instead of only when the pinned version changes.")
|
||||
|
||||
return cmd
|
||||
}
|
||||
@@ -15,7 +15,9 @@ import (
|
||||
"github.com/coollabsio/coolify-cli/cmd/context"
|
||||
"github.com/coollabsio/coolify-cli/cmd/database"
|
||||
"github.com/coollabsio/coolify-cli/cmd/deployment"
|
||||
"github.com/coollabsio/coolify-cli/cmd/firewall"
|
||||
"github.com/coollabsio/coolify-cli/cmd/github"
|
||||
initcmd "github.com/coollabsio/coolify-cli/cmd/init"
|
||||
"github.com/coollabsio/coolify-cli/cmd/privatekeys"
|
||||
"github.com/coollabsio/coolify-cli/cmd/project"
|
||||
"github.com/coollabsio/coolify-cli/cmd/resources"
|
||||
@@ -91,7 +93,9 @@ func init() {
|
||||
rootCmd.AddCommand(context.NewContextCommand())
|
||||
rootCmd.AddCommand(database.NewDatabaseCommand())
|
||||
rootCmd.AddCommand(deployment.NewDeploymentCommand())
|
||||
rootCmd.AddCommand(firewall.NewFirewallCommand())
|
||||
rootCmd.AddCommand(github.NewGitHubCommand())
|
||||
rootCmd.AddCommand(initcmd.NewInitCommand())
|
||||
rootCmd.AddCommand(privatekeys.NewPrivateKeysCommand())
|
||||
rootCmd.AddCommand(project.NewProjectCommand())
|
||||
rootCmd.AddCommand(resources.NewResourceCommand())
|
||||
|
||||
Vendored
+7
-2
@@ -57,14 +57,18 @@ func NewCreateCommand() *cobra.Command {
|
||||
if cmd.Flags().Changed("runtime") {
|
||||
req.IsRuntime = &isRuntime
|
||||
}
|
||||
if cmd.Flags().Changed("comment") {
|
||||
comment, _ := cmd.Flags().GetString("comment")
|
||||
req.Comment = &comment
|
||||
}
|
||||
|
||||
serviceSvc := service.NewService(client)
|
||||
env, err := serviceSvc.CreateEnv(ctx, uuid, req)
|
||||
_, err = serviceSvc.CreateEnv(ctx, uuid, req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create environment variable: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("Environment variable '%s' created successfully.\n", env.Key)
|
||||
fmt.Printf("Environment variable '%s' created successfully.\n", key)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
@@ -75,6 +79,7 @@ func NewCreateCommand() *cobra.Command {
|
||||
cmd.Flags().Bool("is-literal", false, "Treat value as literal (don't interpolate variables)")
|
||||
cmd.Flags().Bool("is-multiline", false, "Value is multiline")
|
||||
cmd.Flags().Bool("runtime", true, "Available at runtime (default: true)")
|
||||
cmd.Flags().String("comment", "", "Comment for the environment variable")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
Vendored
+23
-10
@@ -12,13 +12,14 @@ import (
|
||||
|
||||
func NewUpdateCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "update <service_uuid>",
|
||||
Use: "update <service_uuid> <env_uuid_or_key>",
|
||||
Short: "Update an environment variable",
|
||||
Long: `Update an existing environment variable. UUID is the service.`,
|
||||
Args: cli.ExactArgs(1, "<service_uuid>"),
|
||||
Long: `Update an existing environment variable. Identify it by UUID or key name.`,
|
||||
Args: cli.ExactArgs(2, "<service_uuid> <env_uuid_or_key>"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := cmd.Context()
|
||||
serviceUUID := args[0]
|
||||
envIdentifier := args[1]
|
||||
|
||||
client, err := cli.GetAPIClient(cmd)
|
||||
if err != nil {
|
||||
@@ -30,13 +31,24 @@ func NewUpdateCommand() *cobra.Command {
|
||||
return err
|
||||
}
|
||||
|
||||
serviceSvc := service.NewService(client)
|
||||
|
||||
// Look up the env var to resolve its key
|
||||
existingEnv, err := serviceSvc.GetEnv(ctx, serviceUUID, envIdentifier)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to find environment variable '%s': %w", envIdentifier, err)
|
||||
}
|
||||
|
||||
req := &models.ServiceEnvironmentVariableUpdateRequest{}
|
||||
|
||||
// Only set fields that were provided
|
||||
// Use existing key unless --key flag explicitly provides a new one
|
||||
if cmd.Flags().Changed("key") {
|
||||
key, _ := cmd.Flags().GetString("key")
|
||||
req.Key = &key
|
||||
} else {
|
||||
req.Key = &existingEnv.Key
|
||||
}
|
||||
|
||||
if cmd.Flags().Changed("value") {
|
||||
value, _ := cmd.Flags().GetString("value")
|
||||
req.Value = &value
|
||||
@@ -57,15 +69,15 @@ func NewUpdateCommand() *cobra.Command {
|
||||
isRuntime, _ := cmd.Flags().GetBool("runtime")
|
||||
req.IsRuntime = &isRuntime
|
||||
}
|
||||
|
||||
if req.Key == nil {
|
||||
return fmt.Errorf("--key is required")
|
||||
if cmd.Flags().Changed("comment") {
|
||||
comment, _ := cmd.Flags().GetString("comment")
|
||||
req.Comment = &comment
|
||||
}
|
||||
|
||||
if req.Value == nil {
|
||||
return fmt.Errorf("--value is required")
|
||||
}
|
||||
|
||||
serviceSvc := service.NewService(client)
|
||||
env, err := serviceSvc.UpdateEnv(ctx, serviceUUID, req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to update environment variable: %w", err)
|
||||
@@ -76,12 +88,13 @@ func NewUpdateCommand() *cobra.Command {
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().String("key", "", "New environment variable key")
|
||||
cmd.Flags().String("value", "", "New environment variable value")
|
||||
cmd.Flags().String("key", "", "New environment variable key (rename)")
|
||||
cmd.Flags().String("value", "", "New environment variable value (required)")
|
||||
cmd.Flags().Bool("build-time", true, "Available at build time (default: true)")
|
||||
cmd.Flags().Bool("is-literal", false, "Treat value as literal (don't interpolate variables)")
|
||||
cmd.Flags().Bool("is-multiline", false, "Value is multiline")
|
||||
cmd.Flags().Bool("runtime", true, "Available at runtime (default: true)")
|
||||
cmd.Flags().String("comment", "", "Comment for the environment variable")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
+2
-1
@@ -32,7 +32,8 @@ func NewGetCommand() *cobra.Command {
|
||||
return fmt.Errorf("failed to get service: %w", err)
|
||||
}
|
||||
|
||||
formatter, err := output.NewFormatter("table", output.Options{})
|
||||
format, _ := cmd.Flags().GetString("format")
|
||||
formatter, err := output.NewFormatter(format, output.Options{})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create formatter: %w", err)
|
||||
}
|
||||
|
||||
+2
-1
@@ -30,7 +30,8 @@ func NewListCommand() *cobra.Command {
|
||||
return fmt.Errorf("failed to list services: %w", err)
|
||||
}
|
||||
|
||||
formatter, err := output.NewFormatter("table", output.Options{})
|
||||
format, _ := cmd.Flags().GetString("format")
|
||||
formatter, err := output.NewFormatter(format, output.Options{})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create formatter: %w", err)
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/coollabsio/coolify-cli/cmd/service/env"
|
||||
"github.com/coollabsio/coolify-cli/cmd/service/storage"
|
||||
)
|
||||
|
||||
// NewServiceCommand creates the service parent command with all subcommands
|
||||
@@ -37,5 +38,18 @@ func NewServiceCommand() *cobra.Command {
|
||||
envCmd.AddCommand(env.NewSyncCommand())
|
||||
cmd.AddCommand(envCmd)
|
||||
|
||||
// Add storage subcommand
|
||||
storageCmd := &cobra.Command{
|
||||
Use: "storage",
|
||||
Aliases: []string{"storages"},
|
||||
Short: "Manage service storages",
|
||||
Long: `List and manage persistent volumes and file storages for services.`,
|
||||
}
|
||||
storageCmd.AddCommand(storage.NewListCommand())
|
||||
storageCmd.AddCommand(storage.NewCreateCommand())
|
||||
storageCmd.AddCommand(storage.NewUpdateCommand())
|
||||
storageCmd.AddCommand(storage.NewDeleteCommand())
|
||||
cmd.AddCommand(storageCmd)
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/coollabsio/coolify-cli/internal/cli"
|
||||
"github.com/coollabsio/coolify-cli/internal/models"
|
||||
"github.com/coollabsio/coolify-cli/internal/service"
|
||||
)
|
||||
|
||||
// NewCreateCommand returns the service storage create command
|
||||
func NewCreateCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "create <service_uuid>",
|
||||
Short: "Create a storage for a service",
|
||||
Long: `Create a persistent volume or file storage for a service.
|
||||
|
||||
The --resource-uuid flag is required to specify which service sub-resource
|
||||
(application or database) the storage belongs to.
|
||||
|
||||
Examples:
|
||||
coolify svc storage create <service_uuid> --resource-uuid <sub_resource_uuid> --type persistent --name my-volume --mount-path /data
|
||||
coolify svc storage create <service_uuid> --resource-uuid <sub_resource_uuid> --type file --mount-path /app/config.yml --content "key: value"`,
|
||||
Args: cli.ExactArgs(1, "<service_uuid>"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := cmd.Context()
|
||||
|
||||
storageType, _ := cmd.Flags().GetString("type")
|
||||
mountPath, _ := cmd.Flags().GetString("mount-path")
|
||||
resourceUUID, _ := cmd.Flags().GetString("resource-uuid")
|
||||
|
||||
if storageType == "" {
|
||||
return fmt.Errorf("--type is required (persistent or file)")
|
||||
}
|
||||
if storageType != "persistent" && storageType != "file" {
|
||||
return fmt.Errorf("--type must be 'persistent' or 'file'")
|
||||
}
|
||||
if mountPath == "" {
|
||||
return fmt.Errorf("--mount-path is required")
|
||||
}
|
||||
if resourceUUID == "" {
|
||||
return fmt.Errorf("--resource-uuid is required (UUID of the service sub-resource)")
|
||||
}
|
||||
|
||||
req := &models.ServiceStorageCreateRequest{
|
||||
Type: storageType,
|
||||
MountPath: mountPath,
|
||||
ResourceUUID: resourceUUID,
|
||||
}
|
||||
|
||||
if cmd.Flags().Changed("name") {
|
||||
val, _ := cmd.Flags().GetString("name")
|
||||
req.Name = &val
|
||||
}
|
||||
if cmd.Flags().Changed("host-path") {
|
||||
val, _ := cmd.Flags().GetString("host-path")
|
||||
req.HostPath = &val
|
||||
}
|
||||
if cmd.Flags().Changed("content") {
|
||||
val, _ := cmd.Flags().GetString("content")
|
||||
req.Content = &val
|
||||
}
|
||||
if cmd.Flags().Changed("is-directory") {
|
||||
val, _ := cmd.Flags().GetBool("is-directory")
|
||||
req.IsDirectory = &val
|
||||
}
|
||||
if cmd.Flags().Changed("fs-path") {
|
||||
val, _ := cmd.Flags().GetString("fs-path")
|
||||
req.FsPath = &val
|
||||
}
|
||||
|
||||
client, err := cli.GetAPIClient(cmd)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get API client: %w", err)
|
||||
}
|
||||
|
||||
if err := cli.CheckMinimumVersion(ctx, client, "4.0.0-beta.470"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
svcSvc := service.NewService(client)
|
||||
if err := svcSvc.CreateStorage(ctx, args[0], req); err != nil {
|
||||
return fmt.Errorf("failed to create storage: %w", err)
|
||||
}
|
||||
|
||||
fmt.Println("Storage created successfully.")
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().String("type", "", "Storage type: 'persistent' or 'file' (required)")
|
||||
cmd.Flags().String("mount-path", "", "Mount path inside the container (required)")
|
||||
cmd.Flags().String("resource-uuid", "", "UUID of the service sub-resource (required)")
|
||||
cmd.Flags().String("name", "", "Volume name (persistent only)")
|
||||
cmd.Flags().String("host-path", "", "Host path (persistent only)")
|
||||
cmd.Flags().String("content", "", "File content (file only)")
|
||||
cmd.Flags().Bool("is-directory", false, "Whether this is a directory mount (file only)")
|
||||
cmd.Flags().String("fs-path", "", "Host directory path (file only, required when --is-directory is set)")
|
||||
|
||||
return cmd
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/coollabsio/coolify-cli/internal/cli"
|
||||
"github.com/coollabsio/coolify-cli/internal/service"
|
||||
)
|
||||
|
||||
// NewDeleteCommand returns the service storage delete command
|
||||
func NewDeleteCommand() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "delete <service_uuid> <storage_uuid>",
|
||||
Short: "Delete a storage from a service",
|
||||
Long: `Delete a persistent volume or file storage from a service.
|
||||
|
||||
Examples:
|
||||
coolify svc storage delete <service_uuid> <storage_uuid>`,
|
||||
Args: cli.ExactArgs(2, "<service_uuid> <storage_uuid>"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := cmd.Context()
|
||||
|
||||
client, err := cli.GetAPIClient(cmd)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get API client: %w", err)
|
||||
}
|
||||
|
||||
if err := cli.CheckMinimumVersion(ctx, client, "4.0.0-beta.470"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
svcSvc := service.NewService(client)
|
||||
if err := svcSvc.DeleteStorage(ctx, args[0], args[1]); err != nil {
|
||||
return fmt.Errorf("failed to delete storage: %w", err)
|
||||
}
|
||||
|
||||
fmt.Println("Storage deleted successfully.")
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/coollabsio/coolify-cli/internal/cli"
|
||||
"github.com/coollabsio/coolify-cli/internal/output"
|
||||
"github.com/coollabsio/coolify-cli/internal/service"
|
||||
)
|
||||
|
||||
// NewListCommand returns the service storage list command
|
||||
func NewListCommand() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "list <service_uuid>",
|
||||
Short: "List all storages for a service",
|
||||
Long: `List all persistent volumes and file storages for a specific service.`,
|
||||
Args: cli.ExactArgs(1, "<service_uuid>"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := cmd.Context()
|
||||
|
||||
client, err := cli.GetAPIClient(cmd)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get API client: %w", err)
|
||||
}
|
||||
|
||||
if err := cli.CheckMinimumVersion(ctx, client, "4.0.0-beta.470"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
svcSvc := service.NewService(client)
|
||||
storages, err := svcSvc.ListStorages(ctx, args[0])
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to list storages: %w", err)
|
||||
}
|
||||
|
||||
format, _ := cmd.Flags().GetString("format")
|
||||
showSensitive, _ := cmd.Flags().GetBool("show-sensitive")
|
||||
|
||||
formatter, err := output.NewFormatter(format, output.Options{
|
||||
ShowSensitive: showSensitive,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return formatter.Format(storages)
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/coollabsio/coolify-cli/internal/cli"
|
||||
"github.com/coollabsio/coolify-cli/internal/models"
|
||||
"github.com/coollabsio/coolify-cli/internal/service"
|
||||
)
|
||||
|
||||
// NewUpdateCommand returns the service storage update command
|
||||
func NewUpdateCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "update <service_uuid>",
|
||||
Short: "Update a storage for a service",
|
||||
Long: `Update a persistent volume or file storage for a service.
|
||||
|
||||
The --uuid and --type flags are required. Use 'coolify svc storage list' to find storage UUIDs.
|
||||
|
||||
Examples:
|
||||
coolify svc storage update <service_uuid> --uuid <storage_uuid> --type persistent --name my-volume
|
||||
coolify svc storage update <service_uuid> --uuid <storage_uuid> --type persistent --is-preview-suffix-enabled`,
|
||||
Args: cli.ExactArgs(1, "<service_uuid>"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := cmd.Context()
|
||||
|
||||
storageUUID, _ := cmd.Flags().GetString("uuid")
|
||||
storageID, _ := cmd.Flags().GetInt("id")
|
||||
storageType, _ := cmd.Flags().GetString("type")
|
||||
|
||||
if storageUUID == "" && storageID == 0 {
|
||||
return fmt.Errorf("--uuid is required (or --id as deprecated fallback)")
|
||||
}
|
||||
if storageType == "" {
|
||||
return fmt.Errorf("--type is required (persistent or file)")
|
||||
}
|
||||
if storageType != "persistent" && storageType != "file" {
|
||||
return fmt.Errorf("--type must be 'persistent' or 'file'")
|
||||
}
|
||||
|
||||
req := &models.StorageUpdateRequest{
|
||||
Type: storageType,
|
||||
}
|
||||
|
||||
if storageUUID != "" {
|
||||
req.UUID = &storageUUID
|
||||
} else {
|
||||
req.ID = &storageID
|
||||
}
|
||||
|
||||
hasUpdates := false
|
||||
|
||||
if cmd.Flags().Changed("is-preview-suffix-enabled") {
|
||||
val, _ := cmd.Flags().GetBool("is-preview-suffix-enabled")
|
||||
req.IsPreviewSuffixEnabled = &val
|
||||
hasUpdates = true
|
||||
}
|
||||
if cmd.Flags().Changed("name") {
|
||||
val, _ := cmd.Flags().GetString("name")
|
||||
req.Name = &val
|
||||
hasUpdates = true
|
||||
}
|
||||
if cmd.Flags().Changed("mount-path") {
|
||||
val, _ := cmd.Flags().GetString("mount-path")
|
||||
req.MountPath = &val
|
||||
hasUpdates = true
|
||||
}
|
||||
if cmd.Flags().Changed("host-path") {
|
||||
val, _ := cmd.Flags().GetString("host-path")
|
||||
req.HostPath = &val
|
||||
hasUpdates = true
|
||||
}
|
||||
if cmd.Flags().Changed("content") {
|
||||
val, _ := cmd.Flags().GetString("content")
|
||||
req.Content = &val
|
||||
hasUpdates = true
|
||||
}
|
||||
|
||||
if !hasUpdates {
|
||||
return fmt.Errorf("no fields to update. Use --help to see available flags")
|
||||
}
|
||||
|
||||
client, err := cli.GetAPIClient(cmd)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get API client: %w", err)
|
||||
}
|
||||
|
||||
if err := cli.CheckMinimumVersion(ctx, client, "4.0.0-beta.470"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
svcSvc := service.NewService(client)
|
||||
if err := svcSvc.UpdateStorage(ctx, args[0], req); err != nil {
|
||||
return fmt.Errorf("failed to update storage: %w", err)
|
||||
}
|
||||
|
||||
fmt.Println("Storage updated successfully.")
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().String("uuid", "", "Storage UUID (required, use 'storage list' to find)")
|
||||
cmd.Flags().Int("id", 0, "Storage ID (deprecated, use --uuid instead)")
|
||||
cmd.Flags().String("type", "", "Storage type: 'persistent' or 'file' (required)")
|
||||
cmd.Flags().Bool("is-preview-suffix-enabled", false, "Enable preview suffix for this storage")
|
||||
cmd.Flags().String("name", "", "Storage name (persistent only)")
|
||||
cmd.Flags().String("mount-path", "", "Mount path inside the container")
|
||||
cmd.Flags().String("host-path", "", "Host path (persistent only)")
|
||||
cmd.Flags().String("content", "", "File content (file only)")
|
||||
|
||||
return cmd
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
echo "🔧 Setting up Coolify CLI workspace..."
|
||||
|
||||
# Check if Go is installed
|
||||
if ! command -v go &> /dev/null; then
|
||||
echo "❌ Error: Go is not installed"
|
||||
echo "Please install Go 1.24+ from https://go.dev/dl/"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check Go version
|
||||
GO_VERSION=$(go version | awk '{print $3}' | sed 's/go//')
|
||||
MAJOR_MINOR=$(echo $GO_VERSION | cut -d. -f1,2)
|
||||
|
||||
# Compare version (must be 1.24 or higher)
|
||||
if [ $(echo "$MAJOR_MINOR" | awk -F. '{print ($1 * 100) + $2}') -lt 124 ]; then
|
||||
echo "❌ Error: Go version 1.24+ is required"
|
||||
echo "Current version: $GO_VERSION"
|
||||
echo "Please upgrade Go from https://go.dev/dl/"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "✅ Go version $GO_VERSION detected"
|
||||
|
||||
# Download dependencies
|
||||
echo "📦 Downloading dependencies..."
|
||||
if ! go mod download; then
|
||||
echo "❌ Error: Failed to download dependencies"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "✅ Dependencies downloaded"
|
||||
|
||||
# Install air if not already installed
|
||||
if ! command -v air &> /dev/null; then
|
||||
echo "📦 Installing air (Go file watcher)..."
|
||||
if ! go install github.com/air-verse/air@latest; then
|
||||
echo "⚠️ Warning: Failed to install air, but continuing..."
|
||||
else
|
||||
echo "✅ air installed successfully"
|
||||
fi
|
||||
else
|
||||
echo "✅ air already installed"
|
||||
fi
|
||||
|
||||
# Build the binary
|
||||
echo "🔨 Building coolify binary..."
|
||||
if ! go build -o coolify ./coolify; then
|
||||
echo "❌ Error: Build failed"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "✅ Binary built successfully: ./coolify/coolify"
|
||||
echo "🎉 Workspace setup complete!"
|
||||
echo "🔥 Use the run script for hot reload during development"
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"scripts": {
|
||||
"setup": "./conductor-setup.sh",
|
||||
"run": "~/go/bin/air"
|
||||
},
|
||||
"runScriptMode": "nonconcurrent"
|
||||
}
|
||||
@@ -1,23 +1,33 @@
|
||||
module github.com/coollabsio/coolify-cli
|
||||
|
||||
go 1.24.6
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/adrg/xdg v0.5.3
|
||||
github.com/creativeprojects/go-selfupdate v1.5.1
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1
|
||||
github.com/hashicorp/go-version v1.7.0
|
||||
github.com/mattn/go-isatty v0.0.20
|
||||
github.com/olekukonko/tablewriter v1.1.2
|
||||
github.com/spf13/cobra v1.10.1
|
||||
github.com/spf13/pflag v1.0.10
|
||||
github.com/spf13/viper v1.21.0
|
||||
github.com/stretchr/testify v1.11.1
|
||||
golang.org/x/crypto v0.50.0
|
||||
golang.org/x/term v0.42.0
|
||||
)
|
||||
|
||||
require (
|
||||
code.gitea.io/sdk/gitea v0.22.0 // indirect
|
||||
github.com/42wim/httpsig v1.2.3 // indirect
|
||||
github.com/Masterminds/semver/v3 v3.4.0 // indirect
|
||||
github.com/clipperhouse/displaywidth v0.6.0 // indirect
|
||||
github.com/clipperhouse/stringish v0.1.1 // indirect
|
||||
github.com/clipperhouse/uax29/v2 v2.3.0 // indirect
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||
github.com/davidmz/go-pageant v1.0.2 // indirect
|
||||
github.com/fatih/color v1.16.0 // indirect
|
||||
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
||||
github.com/go-fed/httpsig v1.1.0 // indirect
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
|
||||
@@ -26,21 +36,24 @@ require (
|
||||
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
|
||||
github.com/hashicorp/go-retryablehttp v0.7.8 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.19 // indirect
|
||||
github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6 // indirect
|
||||
github.com/olekukonko/errors v1.1.0 // indirect
|
||||
github.com/olekukonko/ll v0.1.3 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||
github.com/russross/blackfriday/v2 v2.1.0 // indirect
|
||||
github.com/sagikazarmark/locafero v0.12.0 // indirect
|
||||
github.com/spf13/afero v1.15.0 // indirect
|
||||
github.com/spf13/cast v1.10.0 // indirect
|
||||
github.com/spf13/pflag v1.0.10 // indirect
|
||||
github.com/subosito/gotenv v1.6.0 // indirect
|
||||
github.com/ulikunitz/xz v0.5.15 // indirect
|
||||
github.com/xanzy/go-gitlab v0.115.0 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||
golang.org/x/crypto v0.45.0 // indirect
|
||||
golang.org/x/oauth2 v0.32.0 // indirect
|
||||
golang.org/x/sys v0.38.0 // indirect
|
||||
golang.org/x/text v0.31.0 // indirect
|
||||
golang.org/x/sys v0.43.0 // indirect
|
||||
golang.org/x/text v0.36.0 // indirect
|
||||
golang.org/x/time v0.14.0 // indirect
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
|
||||
@@ -6,6 +6,12 @@ github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1
|
||||
github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
|
||||
github.com/adrg/xdg v0.5.3 h1:xRnxJXne7+oWDatRhR1JLnvuccuIeCoBu2rtuLqQB78=
|
||||
github.com/adrg/xdg v0.5.3/go.mod h1:nlTsY+NNiCBGCK2tpm09vRqfVzrc2fLmXGpBLF0zlTQ=
|
||||
github.com/clipperhouse/displaywidth v0.6.0 h1:k32vueaksef9WIKCNcoqRNyKbyvkvkysNYnAWz2fN4s=
|
||||
github.com/clipperhouse/displaywidth v0.6.0/go.mod h1:R+kHuzaYWFkTm7xoMmK1lFydbci4X2CicfbGstSGg0o=
|
||||
github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
|
||||
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
|
||||
github.com/clipperhouse/uax29/v2 v2.3.0 h1:SNdx9DVUqMoBuBoW3iLOj4FQv3dN5mDtuqwuhIGpJy4=
|
||||
github.com/clipperhouse/uax29/v2 v2.3.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.7 h1:zbFlGlXEAKlwXpmvle3d8Oe3YnkKIK4xSRTd3sHPnBo=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||
@@ -25,6 +31,8 @@ github.com/go-fed/httpsig v1.1.0 h1:9M+hb0jkEICD8/cAiNqEB66R87tTINszBRTjwjQzWcI=
|
||||
github.com/go-fed/httpsig v1.1.0/go.mod h1:RCMrTZvN1bJYtofsG4rd5NaO5obxQ5xBkdiS7xsT7bM=
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
@@ -50,8 +58,19 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
||||
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
|
||||
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
||||
github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6 h1:zrbMGy9YXpIeTnGj4EljqMiZsIcE09mmF8XsD5AYOJc=
|
||||
github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6/go.mod h1:rEKTHC9roVVicUIfZK7DYrdIoM0EOr8mK1Hj5s3JjH0=
|
||||
github.com/olekukonko/errors v1.1.0 h1:RNuGIh15QdDenh+hNvKrJkmxxjV4hcS50Db478Ou5sM=
|
||||
github.com/olekukonko/errors v1.1.0/go.mod h1:ppzxA5jBKcO1vIpCXQ9ZqgDh8iwODz6OXIGKU8r5m4Y=
|
||||
github.com/olekukonko/ll v0.1.3 h1:sV2jrhQGq5B3W0nENUISCR6azIPf7UBUpVq0x/y70Fg=
|
||||
github.com/olekukonko/ll v0.1.3/go.mod h1:b52bVQRRPObe+yyBl0TxNfhesL0nedD4Cht0/zx55Ew=
|
||||
github.com/olekukonko/tablewriter v1.1.2 h1:L2kI1Y5tZBct/O/TyZK1zIE9GlBj/TVs+AY5tZDCDSc=
|
||||
github.com/olekukonko/tablewriter v1.1.2/go.mod h1:z7SYPugVqGVavWoA2sGsFIoOVNmEHxUAAMrhXONtfkg=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||
@@ -86,8 +105,8 @@ go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8=
|
||||
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
|
||||
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
|
||||
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
|
||||
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
|
||||
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
@@ -97,15 +116,17 @@ golang.org/x/oauth2 v0.32.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwE
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
||||
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
|
||||
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU=
|
||||
golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254=
|
||||
golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY=
|
||||
golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
|
||||
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
|
||||
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
|
||||
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
|
||||
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
|
||||
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
|
||||
@@ -0,0 +1,308 @@
|
||||
package firewall
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/coollabsio/coolify-cli/internal/ssh"
|
||||
)
|
||||
|
||||
// CooldAPIBasePath is the path prefix the coold REST router serves under.
|
||||
// Mirrors `src/firewall/api.rs` in the coold repo.
|
||||
const CooldAPIBasePath = "/api/v1/firewall"
|
||||
|
||||
// CooldAPITokenPath is the remote file coold reads its bearer token from.
|
||||
// Kept in sync with internal/services/coold.go — the CLI falls back to
|
||||
// reading this file over SSH when the user hasn't supplied --coold-token.
|
||||
const CooldAPITokenPath = "/etc/coolify/api-token" //nolint:gosec // filesystem path, not a credential
|
||||
|
||||
// FetchCooldToken SSHes into host and reads the coold bearer token at
|
||||
// CooldAPITokenPath. Each host generates its own random token at install
|
||||
// time (see EnsureCooldAPITokenCommand), so per-host fetch is the default
|
||||
// path when the user hasn't provided a global --coold-token override.
|
||||
func FetchCooldToken(
|
||||
ctx context.Context,
|
||||
runner ssh.Runner,
|
||||
host, user string,
|
||||
sshPort int,
|
||||
) (string, error) {
|
||||
cmd := "cat " + CooldAPITokenPath
|
||||
stdout, stderr, err := runner.Run(ctx, host, user, sshPort, cmd)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("fetch coold token from %s: %w (stderr: %s)",
|
||||
host, err, strings.TrimSpace(stderr))
|
||||
}
|
||||
tok := strings.TrimSpace(stdout)
|
||||
if tok == "" {
|
||||
return "", fmt.Errorf("coold token on %s is empty — is coold installed? (expected at %s)",
|
||||
host, CooldAPITokenPath)
|
||||
}
|
||||
return tok, nil
|
||||
}
|
||||
|
||||
// cooldRulePayload mirrors the JSON shape coold's REST API expects on POST
|
||||
// and returns on GET /allow. Kept aligned with coold/src/firewall/rule.rs:
|
||||
// namespace is required (defaults to "default" on the wire), src/dst are
|
||||
// string IPs, proto/port/id are omitted when absent.
|
||||
type cooldRulePayload struct {
|
||||
Namespace string `json:"namespace"`
|
||||
Src string `json:"src"`
|
||||
Dst string `json:"dst"`
|
||||
Proto string `json:"proto,omitempty"`
|
||||
Port uint16 `json:"port,omitempty"`
|
||||
ID string `json:"id,omitempty"`
|
||||
}
|
||||
|
||||
// toAllowRule converts a payload coming back from coold into the CLI's
|
||||
// AllowRule. The host field is filled in by the caller (it is the mesh host
|
||||
// the list came from, not part of the payload).
|
||||
func (p cooldRulePayload) toAllowRule() (AllowRule, bool) {
|
||||
src := net.ParseIP(p.Src)
|
||||
dst := net.ParseIP(p.Dst)
|
||||
if src == nil || dst == nil {
|
||||
return AllowRule{}, false
|
||||
}
|
||||
ns := p.Namespace
|
||||
if ns == "" {
|
||||
ns = "default"
|
||||
}
|
||||
r := AllowRule{
|
||||
Namespace: ns,
|
||||
Src: src,
|
||||
Dst: dst,
|
||||
Proto: p.Proto,
|
||||
Port: int(p.Port),
|
||||
}
|
||||
if p.ID != "" {
|
||||
r.Comment = "cid:" + p.ID
|
||||
}
|
||||
return r, true
|
||||
}
|
||||
|
||||
// allowRulePayload converts an AllowRule into the wire shape coold accepts.
|
||||
// coold normalizes and computes the id itself, so we send only the tuple.
|
||||
// Empty namespace is materialized as "default" on the wire so older coold
|
||||
// builds with a default-only schema keep working.
|
||||
func allowRulePayload(r AllowRule) cooldRulePayload {
|
||||
ns := r.Namespace
|
||||
if ns == "" {
|
||||
ns = "default"
|
||||
}
|
||||
p := cooldRulePayload{
|
||||
Namespace: ns,
|
||||
Src: r.Src.String(),
|
||||
Dst: r.Dst.String(),
|
||||
Proto: r.Proto,
|
||||
}
|
||||
if r.Port > 0 {
|
||||
p.Port = uint16(r.Port)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// CooldApply POSTs r to coold's /allow endpoint on host. coold is reached
|
||||
// via SSH-bounce: SSH into host, curl localhost wg0 mgmt IP. This is the
|
||||
// transport of choice for the alpha because the CLI runs on a laptop that
|
||||
// isn't a mesh peer — only hosts inside the wg0 network can reach coold.
|
||||
func CooldApply(
|
||||
ctx context.Context,
|
||||
runner ssh.Runner,
|
||||
host, user string,
|
||||
sshPort, cooldPort int,
|
||||
iface, token string,
|
||||
r AllowRule,
|
||||
) error {
|
||||
body, err := json.Marshal(allowRulePayload(r))
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal allow rule: %w", err)
|
||||
}
|
||||
cmd := buildCurlAllow(iface, token, cooldPort, string(body))
|
||||
if _, stderr, err := runner.Run(ctx, host, user, sshPort, cmd); err != nil {
|
||||
return fmt.Errorf("coold apply on %s: %w (stderr: %s)",
|
||||
host, err, strings.TrimSpace(stderr))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CooldRevoke DELETEs rule id from coold on host. coold returns 204 even
|
||||
// when the id is unknown, so missing rules are a silent no-op.
|
||||
func CooldRevoke(
|
||||
ctx context.Context,
|
||||
runner ssh.Runner,
|
||||
host, user string,
|
||||
sshPort, cooldPort int,
|
||||
iface, token, id string,
|
||||
) error {
|
||||
if id == "" {
|
||||
return fmt.Errorf("coold revoke: empty id")
|
||||
}
|
||||
cmd := buildCurlRevoke(iface, token, cooldPort, id)
|
||||
if _, stderr, err := runner.Run(ctx, host, user, sshPort, cmd); err != nil {
|
||||
return fmt.Errorf("coold revoke on %s: %w (stderr: %s)",
|
||||
host, err, strings.TrimSpace(stderr))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CooldList GETs coold's /allow endpoint on host and returns the parsed
|
||||
// rules. An empty namespace means "all namespaces"; a non-empty value is
|
||||
// forwarded to coold as `?namespace=<ns>`. Missing coold (no wg0 interface)
|
||||
// is treated as an empty slice so a partially-deployed mesh doesn't break
|
||||
// `firewall list`.
|
||||
func CooldList(
|
||||
ctx context.Context,
|
||||
runner ssh.Runner,
|
||||
host, user string,
|
||||
sshPort, cooldPort int,
|
||||
iface, token, namespace string,
|
||||
) ([]AllowRule, error) {
|
||||
cmd := buildCurlList(iface, token, cooldPort, namespace)
|
||||
stdout, stderr, err := runner.Run(ctx, host, user, sshPort, cmd)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("coold list on %s: %w (stderr: %s)",
|
||||
host, err, strings.TrimSpace(stderr))
|
||||
}
|
||||
stdout = strings.TrimSpace(stdout)
|
||||
if stdout == "" {
|
||||
return nil, nil
|
||||
}
|
||||
var payloads []cooldRulePayload
|
||||
if err := json.Unmarshal([]byte(stdout), &payloads); err != nil {
|
||||
return nil, fmt.Errorf("parse coold list on %s: %w (body: %s)",
|
||||
host, err, stdout)
|
||||
}
|
||||
out := make([]AllowRule, 0, len(payloads))
|
||||
for _, p := range payloads {
|
||||
r, ok := p.toAllowRule()
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
r.Host = host
|
||||
out = append(out, r)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// CooldListAll fans CooldList across every host in parallel and returns a
|
||||
// stably-sorted flattened slice plus the per-host results. tokenFor is
|
||||
// called once per host on its worker goroutine — fail here and the host
|
||||
// surfaces as a ServerResult.Err instead of polluting the rule slice. An
|
||||
// empty namespace forwards `?namespace=` omitted (coold returns all).
|
||||
func CooldListAll(
|
||||
ctx context.Context,
|
||||
runner ssh.Runner,
|
||||
hosts []string,
|
||||
user string,
|
||||
sshPort, cooldPort int,
|
||||
iface string,
|
||||
tokenFor func(host string) (string, error),
|
||||
concurrency int,
|
||||
namespace string,
|
||||
) ([]AllowRule, []ssh.ServerResult[[]AllowRule]) {
|
||||
results := ssh.ForEachServer(ctx, hosts, concurrency,
|
||||
func(ctx context.Context, host string) ([]AllowRule, error) {
|
||||
token, err := tokenFor(host)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return CooldList(ctx, runner, host, user, sshPort, cooldPort, iface, token, namespace)
|
||||
})
|
||||
var all []AllowRule
|
||||
for _, r := range results {
|
||||
all = append(all, r.Result...)
|
||||
}
|
||||
sort.Slice(all, func(i, j int) bool {
|
||||
if all[i].Host != all[j].Host {
|
||||
return all[i].Host < all[j].Host
|
||||
}
|
||||
if all[i].Namespace != all[j].Namespace {
|
||||
return all[i].Namespace < all[j].Namespace
|
||||
}
|
||||
si, sj := all[i].Src.String(), all[j].Src.String()
|
||||
if si != sj {
|
||||
return si < sj
|
||||
}
|
||||
di, dj := all[i].Dst.String(), all[j].Dst.String()
|
||||
if di != dj {
|
||||
return di < dj
|
||||
}
|
||||
return all[i].Port < all[j].Port
|
||||
})
|
||||
return all, results
|
||||
}
|
||||
|
||||
// shellSingleQuote wraps s in POSIX-shell single quotes, escaping any
|
||||
// embedded single quotes. Used to embed JSON bodies and tokens into shell
|
||||
// commands without breaking quoting.
|
||||
func shellSingleQuote(s string) string {
|
||||
return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
|
||||
}
|
||||
|
||||
// DefaultWGInterface is the WireGuard interface name the firewall CLI
|
||||
// assumes when no override is supplied. Matches the default of
|
||||
// `coolify init --wg-interface`.
|
||||
const DefaultWGInterface = "wg0"
|
||||
|
||||
// mgmtIPScript discovers coold's bind IP on the remote host by reading the
|
||||
// first IPv4 address on the host's WireGuard interface. Emitted as part of
|
||||
// every curl command so the CLI doesn't need to track per-host mgmt IPs
|
||||
// (they are already encoded in the host's own WG interface).
|
||||
func mgmtIPScript(iface string) string {
|
||||
return fmt.Sprintf(
|
||||
`MGMT=$(ip -4 -o addr show %[1]s 2>/dev/null | awk '{print $4}' | cut -d/ -f1); `+
|
||||
`test -n "$MGMT" || { echo "coold mgmt IP (%[1]s) not found on $(hostname) — is coold installed?" >&2; exit 1; }; `,
|
||||
iface)
|
||||
}
|
||||
|
||||
// mgmtIPScriptSoft is the same as mgmtIPScript but treats a missing WG
|
||||
// interface as "no rules" rather than a failure. Used by list so a host
|
||||
// without coold is simply absent from the output instead of aborting the
|
||||
// whole fanout.
|
||||
func mgmtIPScriptSoft(iface string) string {
|
||||
return fmt.Sprintf(
|
||||
`MGMT=$(ip -4 -o addr show %s 2>/dev/null | awk '{print $4}' | cut -d/ -f1); `+
|
||||
`if [ -z "$MGMT" ]; then echo '[]'; exit 0; fi; `,
|
||||
iface)
|
||||
}
|
||||
|
||||
// buildCurlAllow returns the shell one-liner that POSTs body to coold.
|
||||
// Token is embedded inline in the -H header; on the remote it is briefly
|
||||
// visible in /proc/<curl-pid>/cmdline to root only, for the ~ms lifetime of
|
||||
// the curl invocation. Acceptable for alpha; TLS + stdin-fed tokens are a
|
||||
// follow-up.
|
||||
func buildCurlAllow(iface, token string, port int, body string) string {
|
||||
return mgmtIPScript(iface) +
|
||||
`curl -fsS --max-time 10 ` +
|
||||
`-H ` + shellSingleQuote("Authorization: Bearer "+token) + ` ` +
|
||||
`-H 'Content-Type: application/json' ` +
|
||||
`-X POST -d ` + shellSingleQuote(body) + ` ` +
|
||||
fmt.Sprintf(`"http://$MGMT:%d%s/allow"`, port, CooldAPIBasePath)
|
||||
}
|
||||
|
||||
// buildCurlRevoke returns the shell one-liner that DELETEs rule id.
|
||||
func buildCurlRevoke(iface, token string, port int, id string) string {
|
||||
return mgmtIPScript(iface) +
|
||||
`curl -fsS --max-time 10 -o /dev/null ` +
|
||||
`-H ` + shellSingleQuote("Authorization: Bearer "+token) + ` ` +
|
||||
`-X DELETE ` +
|
||||
fmt.Sprintf(`"http://$MGMT:%d%s/allow/%s"`, port, CooldAPIBasePath, id)
|
||||
}
|
||||
|
||||
// buildCurlList returns the shell one-liner that GETs /allow. A missing
|
||||
// WG interface returns an empty JSON array so the caller sees "no rules"
|
||||
// instead of a transport error. A non-empty namespace is forwarded as
|
||||
// ?namespace=<ns>.
|
||||
func buildCurlList(iface, token string, port int, namespace string) string {
|
||||
query := ""
|
||||
if namespace != "" {
|
||||
query = "?namespace=" + namespace
|
||||
}
|
||||
return mgmtIPScriptSoft(iface) +
|
||||
`curl -fsS --max-time 10 ` +
|
||||
`-H ` + shellSingleQuote("Authorization: Bearer "+token) + ` ` +
|
||||
fmt.Sprintf(`"http://$MGMT:%d%s/allow%s"`, port, CooldAPIBasePath, query)
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
package firewall
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/coollabsio/coolify-cli/internal/ssh"
|
||||
)
|
||||
|
||||
// fakeCooldRunner is a minimal Runner for client-level tests. It captures
|
||||
// every command and replies based on substring-matched canned responses.
|
||||
// mu guards calls against concurrent appends from ForEachServer's parallel
|
||||
// goroutines.
|
||||
type fakeCooldRunner struct {
|
||||
mu sync.Mutex
|
||||
responses map[string]string
|
||||
calls []string
|
||||
}
|
||||
|
||||
func (f *fakeCooldRunner) Run(_ context.Context, _, _ string, _ int, cmd string) (string, string, error) {
|
||||
f.mu.Lock()
|
||||
f.calls = append(f.calls, cmd)
|
||||
f.mu.Unlock()
|
||||
for sub, resp := range f.responses {
|
||||
if strings.Contains(cmd, sub) {
|
||||
return resp, "", nil
|
||||
}
|
||||
}
|
||||
return "", "", nil
|
||||
}
|
||||
|
||||
var _ ssh.Runner = (*fakeCooldRunner)(nil)
|
||||
|
||||
func TestShellSingleQuote_Escapes(t *testing.T) {
|
||||
assert.Equal(t, `'plain'`, shellSingleQuote("plain"))
|
||||
assert.Equal(t, `'it'\''s'`, shellSingleQuote("it's"))
|
||||
}
|
||||
|
||||
func TestBuildCurlAllow_Shape(t *testing.T) {
|
||||
cmd := buildCurlAllow("wg0", "tok-xyz", 8443, `{"src":"10.0.0.1","dst":"10.0.0.2"}`)
|
||||
assert.Contains(t, cmd, "ip -4 -o addr show wg0")
|
||||
assert.Contains(t, cmd, "curl -fsS")
|
||||
assert.Contains(t, cmd, "Authorization: Bearer tok-xyz")
|
||||
assert.Contains(t, cmd, "Content-Type: application/json")
|
||||
assert.Contains(t, cmd, "-X POST")
|
||||
assert.Contains(t, cmd, `{"src":"10.0.0.1","dst":"10.0.0.2"}`)
|
||||
assert.Contains(t, cmd, `:8443/api/v1/firewall/allow`)
|
||||
}
|
||||
|
||||
func TestBuildCurlRevoke_Shape(t *testing.T) {
|
||||
cmd := buildCurlRevoke("wg0", "tok-xyz", 8443, "abc123def456")
|
||||
assert.Contains(t, cmd, "curl -fsS")
|
||||
assert.Contains(t, cmd, "-X DELETE")
|
||||
assert.Contains(t, cmd, "Authorization: Bearer tok-xyz")
|
||||
assert.Contains(t, cmd, `:8443/api/v1/firewall/allow/abc123def456`)
|
||||
}
|
||||
|
||||
func TestBuildCurlList_SoftMgmtIP(t *testing.T) {
|
||||
cmd := buildCurlList("wg0", "tok-xyz", 8443, "")
|
||||
// Missing wg0 yields an empty array and success exit.
|
||||
assert.Contains(t, cmd, `echo '[]'; exit 0`)
|
||||
assert.Contains(t, cmd, "Authorization: Bearer tok-xyz")
|
||||
assert.Contains(t, cmd, `:8443/api/v1/firewall/allow`)
|
||||
// Empty namespace → no query string.
|
||||
assert.NotContains(t, cmd, "namespace=")
|
||||
}
|
||||
|
||||
// TestBuildCurlList_WithNamespace verifies that a non-empty namespace is
|
||||
// forwarded as ?namespace=<ns> so coold can filter on its side.
|
||||
func TestBuildCurlList_WithNamespace(t *testing.T) {
|
||||
cmd := buildCurlList("wg0", "tok-xyz", 8443, "alpha")
|
||||
assert.Contains(t, cmd, `:8443/api/v1/firewall/allow?namespace=alpha`)
|
||||
}
|
||||
|
||||
func TestCooldApply_SendsJSONPayload(t *testing.T) {
|
||||
fr := &fakeCooldRunner{}
|
||||
r := AllowRule{
|
||||
Src: net.ParseIP("10.0.0.1"), Dst: net.ParseIP("10.0.0.2"),
|
||||
Proto: "tcp", Port: 80,
|
||||
}
|
||||
err := CooldApply(context.Background(), fr, "h1", "root", 22, 8443, "wg0", "t", r)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, fr.calls, 1)
|
||||
assert.Contains(t, fr.calls[0], `"src":"10.0.0.1"`)
|
||||
assert.Contains(t, fr.calls[0], `"dst":"10.0.0.2"`)
|
||||
assert.Contains(t, fr.calls[0], `"proto":"tcp"`)
|
||||
assert.Contains(t, fr.calls[0], `"port":80`)
|
||||
}
|
||||
|
||||
func TestCooldApply_OmitsProtoWhenEmpty(t *testing.T) {
|
||||
fr := &fakeCooldRunner{}
|
||||
r := AllowRule{
|
||||
Src: net.ParseIP("10.0.0.1"), Dst: net.ParseIP("10.0.0.2"),
|
||||
}
|
||||
err := CooldApply(context.Background(), fr, "h1", "root", 22, 8443, "wg0", "t", r)
|
||||
require.NoError(t, err)
|
||||
// omitempty drops zero port and empty proto — avoids tripping coold's
|
||||
// "port requires proto" validation.
|
||||
assert.NotContains(t, fr.calls[0], `"proto"`)
|
||||
assert.NotContains(t, fr.calls[0], `"port"`)
|
||||
}
|
||||
|
||||
func TestCooldRevoke_RejectsEmptyID(t *testing.T) {
|
||||
fr := &fakeCooldRunner{}
|
||||
err := CooldRevoke(context.Background(), fr, "h1", "root", 22, 8443, "wg0", "t", "")
|
||||
require.Error(t, err)
|
||||
assert.Empty(t, fr.calls, "no SSH call for empty id")
|
||||
}
|
||||
|
||||
func TestCooldList_ParsesJSON(t *testing.T) {
|
||||
fr := &fakeCooldRunner{responses: map[string]string{
|
||||
"/api/v1/firewall/allow": `[
|
||||
{"src":"10.0.0.1","dst":"10.0.0.2","proto":"tcp","port":80,"id":"abc123def456"},
|
||||
{"src":"10.0.0.3","dst":"10.0.0.4"}
|
||||
]`,
|
||||
}}
|
||||
rules, err := CooldList(context.Background(), fr, "h1", "root", 22, 8443, "wg0", "t", "")
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, rules, 2)
|
||||
assert.Equal(t, "h1", rules[0].Host)
|
||||
assert.Equal(t, "cid:abc123def456", rules[0].Comment)
|
||||
assert.Equal(t, "tcp", rules[0].Proto)
|
||||
assert.Equal(t, 80, rules[0].Port)
|
||||
// Rule without proto/port/id comes through with zero values, no cid.
|
||||
assert.Empty(t, rules[1].Proto)
|
||||
assert.Equal(t, 0, rules[1].Port)
|
||||
assert.Empty(t, rules[1].Comment)
|
||||
}
|
||||
|
||||
func TestCooldList_EmptyBody(t *testing.T) {
|
||||
fr := &fakeCooldRunner{}
|
||||
rules, err := CooldList(context.Background(), fr, "h1", "root", 22, 8443, "wg0", "t", "")
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, rules)
|
||||
}
|
||||
|
||||
func TestCooldListAll_SortsByHost(t *testing.T) {
|
||||
// Fake returns the same JSON regardless of host; the sort guarantees the
|
||||
// fanout output is stable across runs.
|
||||
fr := &fakeCooldRunner{responses: map[string]string{
|
||||
"/api/v1/firewall/allow": `[{"src":"10.0.0.1","dst":"10.0.0.2","proto":"tcp","port":80,"id":"aaa111111111"}]`,
|
||||
}}
|
||||
tokenFor := func(string) (string, error) { return "t", nil }
|
||||
rules, results := CooldListAll(context.Background(), fr,
|
||||
[]string{"hB", "hA"}, "root", 22, 8443, "wg0", tokenFor, 2, "")
|
||||
assert.Len(t, rules, 2)
|
||||
assert.Equal(t, "hA", rules[0].Host)
|
||||
assert.Equal(t, "hB", rules[1].Host)
|
||||
assert.Len(t, results, 2)
|
||||
}
|
||||
|
||||
func TestFetchCooldToken_ReadsFile(t *testing.T) {
|
||||
fr := &fakeCooldRunner{responses: map[string]string{
|
||||
"/etc/coolify/api-token": "deadbeefcafe\n",
|
||||
}}
|
||||
tok, err := FetchCooldToken(context.Background(), fr, "h1", "root", 22)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "deadbeefcafe", tok)
|
||||
}
|
||||
|
||||
func TestFetchCooldToken_EmptyErrors(t *testing.T) {
|
||||
fr := &fakeCooldRunner{}
|
||||
_, err := FetchCooldToken(context.Background(), fr, "h1", "root", 22)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "is empty")
|
||||
}
|
||||
|
||||
func TestCooldListAll_PropagatesTokenFetchError(t *testing.T) {
|
||||
fr := &fakeCooldRunner{responses: map[string]string{
|
||||
"/api/v1/firewall/allow": `[]`,
|
||||
}}
|
||||
tokenFor := func(h string) (string, error) {
|
||||
if h == "hBad" {
|
||||
return "", assertError("no token")
|
||||
}
|
||||
return "t", nil
|
||||
}
|
||||
_, results := CooldListAll(context.Background(), fr,
|
||||
[]string{"hOk", "hBad"}, "root", 22, 8443, "wg0", tokenFor, 2, "")
|
||||
var okCount, errCount int
|
||||
for _, r := range results {
|
||||
if r.Err != nil {
|
||||
errCount++
|
||||
} else {
|
||||
okCount++
|
||||
}
|
||||
}
|
||||
assert.Equal(t, 1, okCount)
|
||||
assert.Equal(t, 1, errCount)
|
||||
}
|
||||
|
||||
type assertError string
|
||||
|
||||
func (e assertError) Error() string { return string(e) }
|
||||
@@ -0,0 +1,173 @@
|
||||
package firewall
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/coollabsio/coolify-cli/internal/ssh"
|
||||
)
|
||||
|
||||
// Container is a single running podman container on one mesh host and one
|
||||
// namespace (podman bridge network).
|
||||
type Container struct {
|
||||
Host string // SSH host the container runs on
|
||||
Namespace string // mesh namespace (podman network is coolify-<ns>-mesh)
|
||||
ID string // short (12-char) podman ID
|
||||
Name string // podman container name
|
||||
IP net.IP // IP on the coolify-<ns>-mesh bridge network
|
||||
}
|
||||
|
||||
// discoverScript prints one `id|name|ip` line per running container on the
|
||||
// target network. Piped through `podman inspect` to resolve the per-network
|
||||
// IP because `podman ps` doesn't surface that directly. `|| true` keeps the
|
||||
// script from erroring when podman is absent or the network has no members.
|
||||
func discoverScript(networkName string) string {
|
||||
return fmt.Sprintf(
|
||||
`podman ps --filter network=%[1]s --format '{{.ID}}|{{.Names}}' 2>/dev/null | `+
|
||||
`while IFS='|' read id name; do `+
|
||||
` [ -z "$id" ] && continue; `+
|
||||
` ip=$(podman inspect --format '{{(index .NetworkSettings.Networks %[2]q).IPAddress}}' "$id" 2>/dev/null); `+
|
||||
` printf '%%s|%%s|%%s\n' "$id" "$name" "$ip"; `+
|
||||
`done || true`,
|
||||
networkName, networkName)
|
||||
}
|
||||
|
||||
// ParseDiscoverLine parses one `id|name|ip` line from discoverScript.
|
||||
// Returns (_, false) when the line is blank or malformed.
|
||||
func ParseDiscoverLine(line string) (id, name string, ip net.IP, ok bool) {
|
||||
parts := strings.SplitN(strings.TrimSpace(line), "|", 3)
|
||||
if len(parts) != 3 {
|
||||
return "", "", nil, false
|
||||
}
|
||||
if parts[0] == "" || parts[1] == "" || parts[2] == "" {
|
||||
return "", "", nil, false
|
||||
}
|
||||
ip = net.ParseIP(parts[2])
|
||||
if ip == nil {
|
||||
return "", "", nil, false
|
||||
}
|
||||
id = parts[0]
|
||||
if len(id) > 12 {
|
||||
id = id[:12]
|
||||
}
|
||||
return id, parts[1], ip, true
|
||||
}
|
||||
|
||||
// DiscoverContainers SSHes into host and returns every container on
|
||||
// networkName (the podman bridge backing namespace) with its bridge IP.
|
||||
func DiscoverContainers(
|
||||
ctx context.Context,
|
||||
runner ssh.Runner,
|
||||
host, user string,
|
||||
port int,
|
||||
namespace, networkName string,
|
||||
) ([]Container, error) {
|
||||
stdout, _, err := runner.Run(ctx, host, user, port, discoverScript(networkName))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("discover containers on %s: %w", host, err)
|
||||
}
|
||||
var out []Container
|
||||
for _, line := range strings.Split(stdout, "\n") {
|
||||
id, name, ip, ok := ParseDiscoverLine(line)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
out = append(out, Container{
|
||||
Host: host, Namespace: namespace,
|
||||
ID: id, Name: name, IP: ip,
|
||||
})
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
if out[i].Host != out[j].Host {
|
||||
return out[i].Host < out[j].Host
|
||||
}
|
||||
if out[i].Namespace != out[j].Namespace {
|
||||
return out[i].Namespace < out[j].Namespace
|
||||
}
|
||||
return out[i].Name < out[j].Name
|
||||
})
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// DiscoverAll runs DiscoverContainers across every host in parallel.
|
||||
// Returns a flattened, sort-stable slice plus the per-host results so
|
||||
// callers can surface partial failures.
|
||||
func DiscoverAll(
|
||||
ctx context.Context,
|
||||
runner ssh.Runner,
|
||||
hosts []string,
|
||||
user string,
|
||||
port int,
|
||||
namespace, networkName string,
|
||||
concurrency int,
|
||||
) ([]Container, []ssh.ServerResult[[]Container]) {
|
||||
results := ssh.ForEachServer(ctx, hosts, concurrency,
|
||||
func(ctx context.Context, host string) ([]Container, error) {
|
||||
return DiscoverContainers(ctx, runner, host, user, port, namespace, networkName)
|
||||
})
|
||||
var all []Container
|
||||
for _, r := range results {
|
||||
all = append(all, r.Result...)
|
||||
}
|
||||
sort.Slice(all, func(i, j int) bool {
|
||||
if all[i].Host != all[j].Host {
|
||||
return all[i].Host < all[j].Host
|
||||
}
|
||||
if all[i].Namespace != all[j].Namespace {
|
||||
return all[i].Namespace < all[j].Namespace
|
||||
}
|
||||
return all[i].Name < all[j].Name
|
||||
})
|
||||
return all, results
|
||||
}
|
||||
|
||||
// DiscoverAllNamespaces runs DiscoverAll for every (namespace, network) pair
|
||||
// and merges the results. Used by `containers --all-namespaces` and by the
|
||||
// allow/revoke resolver so references can be matched across every namespace
|
||||
// the user might have set up on the mesh.
|
||||
func DiscoverAllNamespaces(
|
||||
ctx context.Context,
|
||||
runner ssh.Runner,
|
||||
hosts []string,
|
||||
user string,
|
||||
port int,
|
||||
namespaces []string,
|
||||
networkFor func(ns string) string,
|
||||
concurrency int,
|
||||
) ([]Container, []ssh.ServerResult[[]Container]) {
|
||||
var (
|
||||
all []Container
|
||||
allResults []ssh.ServerResult[[]Container]
|
||||
seenHosts = map[string]struct{}{}
|
||||
)
|
||||
for _, ns := range namespaces {
|
||||
nsContainers, results := DiscoverAll(ctx, runner, hosts, user, port,
|
||||
ns, networkFor(ns), concurrency)
|
||||
all = append(all, nsContainers...)
|
||||
for _, r := range results {
|
||||
// Keep only the first error per host to avoid N-duplicate warnings
|
||||
// (most errors — SSH failures — are host-level, not per-namespace).
|
||||
if r.Err == nil {
|
||||
continue
|
||||
}
|
||||
if _, ok := seenHosts[r.Host]; ok {
|
||||
continue
|
||||
}
|
||||
seenHosts[r.Host] = struct{}{}
|
||||
allResults = append(allResults, r)
|
||||
}
|
||||
}
|
||||
sort.Slice(all, func(i, j int) bool {
|
||||
if all[i].Host != all[j].Host {
|
||||
return all[i].Host < all[j].Host
|
||||
}
|
||||
if all[i].Namespace != all[j].Namespace {
|
||||
return all[i].Namespace < all[j].Namespace
|
||||
}
|
||||
return all[i].Name < all[j].Name
|
||||
})
|
||||
return all, allResults
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
package firewall
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestParseDiscoverLine(t *testing.T) {
|
||||
tests := []struct {
|
||||
line string
|
||||
wantOk bool
|
||||
wantID string
|
||||
wantNm string
|
||||
wantIP string
|
||||
}{
|
||||
{"abcdef123456|web|10.210.0.10", true, "abcdef123456", "web", "10.210.0.10"},
|
||||
{"abcdef1234567890|web|10.210.0.10", true, "abcdef123456", "web", "10.210.0.10"},
|
||||
{"|name|10.0.0.1", false, "", "", ""},
|
||||
{"id|name|", false, "", "", ""},
|
||||
{"id|name|not-an-ip", false, "", "", ""},
|
||||
{"", false, "", "", ""},
|
||||
{"a|b", false, "", "", ""},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.line, func(t *testing.T) {
|
||||
id, name, ip, ok := ParseDiscoverLine(tt.line)
|
||||
assert.Equal(t, tt.wantOk, ok)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
assert.Equal(t, tt.wantID, id)
|
||||
assert.Equal(t, tt.wantNm, name)
|
||||
assert.Equal(t, tt.wantIP, ip.String())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// fakeRunner is a deterministic ssh.Runner for firewall tests. Responses
|
||||
// map a command substring to its canned stdout. mu guards calls against
|
||||
// concurrent appends from ForEachServer's parallel goroutines.
|
||||
type fakeRunner struct {
|
||||
mu sync.Mutex
|
||||
responses map[string]string
|
||||
calls []string
|
||||
}
|
||||
|
||||
func (f *fakeRunner) Run(_ context.Context, _, _ string, _ int, cmd string) (string, string, error) {
|
||||
f.mu.Lock()
|
||||
f.calls = append(f.calls, cmd)
|
||||
f.mu.Unlock()
|
||||
for sub, resp := range f.responses {
|
||||
if strings.Contains(cmd, sub) {
|
||||
return resp, "", nil
|
||||
}
|
||||
}
|
||||
return "", "", nil
|
||||
}
|
||||
|
||||
func TestDiscoverContainers(t *testing.T) {
|
||||
r := &fakeRunner{responses: map[string]string{
|
||||
"podman ps": "abc111111111|web|10.210.0.10\ndef222222222|api|10.210.0.11\n\n",
|
||||
}}
|
||||
got, err := DiscoverContainers(context.Background(), r, "h1", "root", 22,
|
||||
"default", "coolify-default-mesh")
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, got, 2)
|
||||
assert.Equal(t, "api", got[0].Name) // sorted by name
|
||||
assert.Equal(t, "web", got[1].Name)
|
||||
assert.Equal(t, "h1", got[0].Host)
|
||||
assert.Equal(t, "default", got[0].Namespace)
|
||||
assert.Equal(t, "10.210.0.11", got[0].IP.String())
|
||||
}
|
||||
|
||||
func TestDiscoverContainers_EmptyOutput(t *testing.T) {
|
||||
r := &fakeRunner{responses: map[string]string{}}
|
||||
got, err := DiscoverContainers(context.Background(), r, "h1", "root", 22,
|
||||
"default", "coolify-default-mesh")
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, got)
|
||||
}
|
||||
|
||||
func TestDiscoverContainers_BadLinesSkipped(t *testing.T) {
|
||||
r := &fakeRunner{responses: map[string]string{
|
||||
"podman ps": "abc111111111|web|10.210.0.10\ngarbage\n|noid|1.1.1.1\n",
|
||||
}}
|
||||
got, err := DiscoverContainers(context.Background(), r, "h1", "root", 22,
|
||||
"default", "coolify-default-mesh")
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, got, 1)
|
||||
assert.Equal(t, "web", got[0].Name)
|
||||
}
|
||||
|
||||
func TestDiscoverAll_Sorted(t *testing.T) {
|
||||
r := &fakeRunner{responses: map[string]string{
|
||||
"podman ps": "aaa111111111|x|10.210.0.10",
|
||||
}}
|
||||
all, perHost := DiscoverAll(context.Background(), r,
|
||||
[]string{"h2", "h1"}, "root", 22,
|
||||
"default", "coolify-default-mesh", 2)
|
||||
assert.Len(t, all, 2)
|
||||
assert.Equal(t, "h1", all[0].Host)
|
||||
assert.Equal(t, "h2", all[1].Host)
|
||||
assert.Equal(t, "default", all[0].Namespace)
|
||||
assert.Len(t, perHost, 2)
|
||||
}
|
||||
|
||||
// TestDiscoverAllNamespaces_MergesAcrossNamespaces verifies that the
|
||||
// multi-namespace discover fanout emits containers for every (ns, host)
|
||||
// pair and stamps them with the correct namespace.
|
||||
func TestDiscoverAllNamespaces_MergesAcrossNamespaces(t *testing.T) {
|
||||
r := &fakeRunner{responses: map[string]string{
|
||||
// Same podman ps response for every namespace — we only care that the
|
||||
// namespace label is applied correctly after parsing.
|
||||
"podman ps": "aaa111111111|web|10.210.0.10",
|
||||
}}
|
||||
networkFor := func(ns string) string { return "coolify-" + ns + "-mesh" }
|
||||
all, _ := DiscoverAllNamespaces(context.Background(), r,
|
||||
[]string{"h1"}, "root", 22,
|
||||
[]string{"default", "alpha"}, networkFor, 2)
|
||||
assert.Len(t, all, 2)
|
||||
// Sorted by host, then namespace — alpha before default.
|
||||
assert.Equal(t, "alpha", all[0].Namespace)
|
||||
assert.Equal(t, "default", all[1].Namespace)
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
// Package firewall implements the `coolify firewall` command logic: per-host
|
||||
// container discovery (SSH+podman) and the SSH-bounced REST client that
|
||||
// drives the coold agent's firewall surface on each mesh host.
|
||||
//
|
||||
// Rule-rendering and iptables IO live entirely in coold now (see the coold
|
||||
// repo, `src/firewall/`). The CLI's job is to resolve endpoints, compute
|
||||
// stable rule identities, and POST/DELETE/GET against coold over SSH. Rules
|
||||
// go on the host that owns the destination IP, matching CONTROL_PLANE.md §3.
|
||||
package firewall
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"net"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// AllowRule is a single cross-host container allow entry.
|
||||
//
|
||||
// The rule lives on the host that owns Dst's container subnet (the default-
|
||||
// deny jump fires on `-d <subnet> -j COOLIFY-INTRA`). Src may belong to any
|
||||
// host in the mesh. Proto/Port are optional; zero values mean "any".
|
||||
//
|
||||
// Namespace qualifies the tuple so identical src/dst/proto/port pairs in
|
||||
// different namespaces produce different rule IDs and are managed
|
||||
// independently. Empty namespace is normalized to "default" at the transport
|
||||
// boundary for legacy coold peers.
|
||||
type AllowRule struct {
|
||||
Host string // host that owns Dst's container subnet
|
||||
Namespace string // e.g. "default", "alpha"
|
||||
Src net.IP
|
||||
Dst net.IP
|
||||
Proto string // "tcp" | "udp" | ""
|
||||
Port int // 0 = any
|
||||
Comment string // "cid:<12-hex>" stable identity for list/revoke
|
||||
}
|
||||
|
||||
// ComputeID returns a 12-hex stable identity hash over
|
||||
// (namespace, src, dst, proto, port). Used as the rule comment so `list` can
|
||||
// display it and `revoke --from ... --to ... --port ...` finds the right rule
|
||||
// without needing to parse.
|
||||
//
|
||||
// Byte-compatible with coold's ComputeID_ (src/firewall/rule.rs): namespace
|
||||
// defaults to "default" when empty, proto lowercased (empty when unset), port
|
||||
// rendered as 0 when unset. Mixed writers (CLI + coold) produce identical IDs
|
||||
// for identical tuples.
|
||||
func ComputeID(namespace string, src, dst net.IP, proto string, port int) string {
|
||||
if namespace == "" {
|
||||
namespace = "default"
|
||||
}
|
||||
h := sha256.New()
|
||||
fmt.Fprintf(h, "%s|%s|%s|%s|%d",
|
||||
namespace, src.String(), dst.String(), strings.ToLower(proto), port)
|
||||
return hex.EncodeToString(h.Sum(nil))[:12]
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package firewall
|
||||
|
||||
import (
|
||||
"net"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestComputeID_Stable(t *testing.T) {
|
||||
a := ComputeID("default", net.ParseIP("10.210.0.10"), net.ParseIP("10.210.1.10"), "tcp", 80)
|
||||
b := ComputeID("default", net.ParseIP("10.210.0.10"), net.ParseIP("10.210.1.10"), "tcp", 80)
|
||||
assert.Equal(t, a, b)
|
||||
assert.Len(t, a, 12)
|
||||
}
|
||||
|
||||
func TestComputeID_CaseInsensitiveProto(t *testing.T) {
|
||||
a := ComputeID("default", net.ParseIP("1.1.1.1"), net.ParseIP("2.2.2.2"), "TCP", 80)
|
||||
b := ComputeID("default", net.ParseIP("1.1.1.1"), net.ParseIP("2.2.2.2"), "tcp", 80)
|
||||
assert.Equal(t, a, b)
|
||||
}
|
||||
|
||||
func TestComputeID_DifferentInputsDifferent(t *testing.T) {
|
||||
a := ComputeID("default", net.ParseIP("1.1.1.1"), net.ParseIP("2.2.2.2"), "tcp", 80)
|
||||
b := ComputeID("default", net.ParseIP("1.1.1.1"), net.ParseIP("2.2.2.2"), "tcp", 443)
|
||||
assert.NotEqual(t, a, b)
|
||||
}
|
||||
|
||||
// TestComputeID_DifferentNamespacesDifferent verifies that identical
|
||||
// src/dst/proto/port tuples in different namespaces produce different IDs —
|
||||
// this is the whole point of per-namespace rule identity.
|
||||
func TestComputeID_DifferentNamespacesDifferent(t *testing.T) {
|
||||
a := ComputeID("default", net.ParseIP("10.0.0.1"), net.ParseIP("10.0.0.2"), "tcp", 80)
|
||||
b := ComputeID("alpha", net.ParseIP("10.0.0.1"), net.ParseIP("10.0.0.2"), "tcp", 80)
|
||||
assert.NotEqual(t, a, b)
|
||||
}
|
||||
|
||||
// TestComputeID_EmptyNamespaceMatchesDefault guards the wire-compat rule:
|
||||
// an empty namespace must hash the same as "default" so older coold builds
|
||||
// and newer CLI callers agree on the same ID.
|
||||
func TestComputeID_EmptyNamespaceMatchesDefault(t *testing.T) {
|
||||
empty := ComputeID("", net.ParseIP("10.0.0.1"), net.ParseIP("10.0.0.2"), "tcp", 80)
|
||||
def := ComputeID("default", net.ParseIP("10.0.0.1"), net.ParseIP("10.0.0.2"), "tcp", 80)
|
||||
assert.Equal(t, empty, def)
|
||||
}
|
||||
+249
-16
@@ -1,16 +1,116 @@
|
||||
package models
|
||||
|
||||
// ApplicationSettings represents the settings for a Coolify application
|
||||
type ApplicationSettings struct {
|
||||
ID int `json:"-" table:"-"`
|
||||
ApplicationID int `json:"-" table:"-"`
|
||||
IsStatic *bool `json:"is_static,omitempty"`
|
||||
IsBuildServerEnabled *bool `json:"is_build_server_enabled,omitempty"`
|
||||
IsPreserveRepositoryEnabled *bool `json:"is_preserve_repository_enabled,omitempty"`
|
||||
IsAutoDeployEnabled *bool `json:"is_auto_deploy_enabled,omitempty"`
|
||||
IsForceHTTPSEnabled *bool `json:"is_force_https_enabled,omitempty"`
|
||||
IsDebugEnabled *bool `json:"is_debug_enabled,omitempty"`
|
||||
IsPreviewDeploymentsEnabled *bool `json:"is_preview_deployments_enabled,omitempty"`
|
||||
IsGitSubmodulesEnabled *bool `json:"is_git_submodules_enabled,omitempty"`
|
||||
IsGitLFSEnabled *bool `json:"is_git_lfs_enabled,omitempty"`
|
||||
CreatedAt string `json:"-" table:"-"`
|
||||
UpdatedAt string `json:"-" table:"-"`
|
||||
}
|
||||
|
||||
// Application represents a Coolify application
|
||||
type Application struct {
|
||||
ID int `json:"-" table:"-"`
|
||||
UUID string `json:"uuid"`
|
||||
Name string `json:"name"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
Status string `json:"status"`
|
||||
GitBranch *string `json:"git_branch,omitempty"`
|
||||
FQDN *string `json:"fqdn,omitempty"`
|
||||
CreatedAt string `json:"-" table:"-"`
|
||||
UpdatedAt string `json:"-" table:"-"`
|
||||
// Table-visible fields
|
||||
UUID string `json:"uuid"`
|
||||
Name string `json:"name"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
Status string `json:"status"`
|
||||
FQDN *string `json:"fqdn,omitempty"`
|
||||
GitRepository *string `json:"git_repository,omitempty"`
|
||||
GitBranch *string `json:"git_branch,omitempty"`
|
||||
BuildPack *string `json:"build_pack,omitempty"`
|
||||
PortsExposes *string `json:"ports_exposes,omitempty"`
|
||||
|
||||
// Git extended (JSON-only)
|
||||
GitCommitSHA *string `json:"git_commit_sha,omitempty" table:"-"`
|
||||
GitFullURL *string `json:"git_full_url,omitempty" table:"-"`
|
||||
|
||||
// Build configuration (JSON-only)
|
||||
InstallCommand *string `json:"install_command,omitempty" table:"-"`
|
||||
BuildCommand *string `json:"build_command,omitempty" table:"-"`
|
||||
StartCommand *string `json:"start_command,omitempty" table:"-"`
|
||||
BaseDirectory *string `json:"base_directory,omitempty" table:"-"`
|
||||
PublishDirectory *string `json:"publish_directory,omitempty" table:"-"`
|
||||
StaticImage *string `json:"static_image,omitempty" table:"-"`
|
||||
|
||||
// Docker configuration (JSON-only)
|
||||
Dockerfile *string `json:"dockerfile,omitempty" table:"-"`
|
||||
DockerfileLocation *string `json:"dockerfile_location,omitempty" table:"-"`
|
||||
DockerRegistryImageName *string `json:"docker_registry_image_name,omitempty" table:"-"`
|
||||
DockerRegistryImageTag *string `json:"docker_registry_image_tag,omitempty" table:"-"`
|
||||
DockerCompose *string `json:"docker_compose,omitempty" table:"-"`
|
||||
DockerComposeRaw *string `json:"docker_compose_raw,omitempty" table:"-"`
|
||||
DockerComposeLocation *string `json:"docker_compose_location,omitempty" table:"-"`
|
||||
CustomDockerRunOptions *string `json:"custom_docker_run_options,omitempty" table:"-"`
|
||||
CustomLabels *string `json:"custom_labels,omitempty" table:"-"`
|
||||
CustomNginxConfiguration *string `json:"custom_nginx_configuration,omitempty" table:"-"`
|
||||
|
||||
// Networking (JSON-only)
|
||||
PortsMappings *string `json:"ports_mappings,omitempty" table:"-"`
|
||||
Domains *string `json:"domains,omitempty" table:"-"`
|
||||
Redirect *string `json:"redirect,omitempty" table:"-"`
|
||||
PreviewURLTemplate *string `json:"preview_url_template,omitempty" table:"-"`
|
||||
|
||||
// Health checks (JSON-only)
|
||||
HealthCheckEnabled *bool `json:"health_check_enabled,omitempty" table:"-"`
|
||||
HealthCheckPath *string `json:"health_check_path,omitempty" table:"-"`
|
||||
HealthCheckPort *string `json:"health_check_port,omitempty" table:"-"`
|
||||
HealthCheckHost *string `json:"health_check_host,omitempty" table:"-"`
|
||||
HealthCheckMethod *string `json:"health_check_method,omitempty" table:"-"`
|
||||
HealthCheckScheme *string `json:"health_check_scheme,omitempty" table:"-"`
|
||||
HealthCheckReturnCode *int `json:"health_check_return_code,omitempty" table:"-"`
|
||||
HealthCheckResponseText *string `json:"health_check_response_text,omitempty" table:"-"`
|
||||
HealthCheckInterval *int `json:"health_check_interval,omitempty" table:"-"`
|
||||
HealthCheckTimeout *int `json:"health_check_timeout,omitempty" table:"-"`
|
||||
HealthCheckRetries *int `json:"health_check_retries,omitempty" table:"-"`
|
||||
HealthCheckStartPeriod *int `json:"health_check_start_period,omitempty" table:"-"`
|
||||
|
||||
// Resource limits (JSON-only)
|
||||
LimitsCPUs *string `json:"limits_cpus,omitempty" table:"-"`
|
||||
LimitsCPUShares *int `json:"limits_cpu_shares,omitempty" table:"-"`
|
||||
LimitsCPUSet *string `json:"limits_cpuset,omitempty" table:"-"`
|
||||
LimitsMemory *string `json:"limits_memory,omitempty" table:"-"`
|
||||
LimitsMemoryReservation *string `json:"limits_memory_reservation,omitempty" table:"-"`
|
||||
LimitsMemorySwap *string `json:"limits_memory_swap,omitempty" table:"-"`
|
||||
LimitsMemorySwappiness *int `json:"limits_memory_swappiness,omitempty" table:"-"`
|
||||
|
||||
// Deployment hooks (JSON-only)
|
||||
PreDeploymentCommand *string `json:"pre_deployment_command,omitempty" table:"-"`
|
||||
PreDeploymentCommandContainer *string `json:"pre_deployment_command_container,omitempty" table:"-"`
|
||||
PostDeploymentCommand *string `json:"post_deployment_command,omitempty" table:"-"`
|
||||
PostDeploymentCommandContainer *string `json:"post_deployment_command_container,omitempty" table:"-"`
|
||||
|
||||
// Webhook secrets (JSON-only)
|
||||
ManualWebhookSecretGitHub *string `json:"manual_webhook_secret_github,omitempty" table:"-" sensitive:"true"`
|
||||
ManualWebhookSecretGitLab *string `json:"manual_webhook_secret_gitlab,omitempty" table:"-" sensitive:"true"`
|
||||
ManualWebhookSecretBitbucket *string `json:"manual_webhook_secret_bitbucket,omitempty" table:"-" sensitive:"true"`
|
||||
ManualWebhookSecretGitea *string `json:"manual_webhook_secret_gitea,omitempty" table:"-" sensitive:"true"`
|
||||
|
||||
// Misc (JSON-only)
|
||||
WatchPaths *string `json:"watch_paths,omitempty" table:"-"`
|
||||
SwarmReplicas *int `json:"swarm_replicas,omitempty" table:"-"`
|
||||
ConfigHash *string `json:"config_hash,omitempty" table:"-"`
|
||||
|
||||
// Nested settings (JSON-only)
|
||||
Settings *ApplicationSettings `json:"settings,omitempty" table:"-"`
|
||||
|
||||
// Hidden fields (not in JSON or table output)
|
||||
ID int `json:"-" table:"-"`
|
||||
EnvironmentID *int `json:"-" table:"-"`
|
||||
DestinationID *int `json:"-" table:"-"`
|
||||
SourceID *int `json:"-" table:"-"`
|
||||
PrivateKeyID *int `json:"-" table:"-"`
|
||||
CreatedAt string `json:"-" table:"-"`
|
||||
UpdatedAt string `json:"-" table:"-"`
|
||||
}
|
||||
|
||||
// ApplicationListItem represents a simplified application for list view
|
||||
@@ -43,6 +143,7 @@ type ApplicationUpdateRequest struct {
|
||||
|
||||
// Docker configuration
|
||||
Dockerfile *string `json:"dockerfile,omitempty"`
|
||||
DockerfileTargetBuild *string `json:"dockerfile_target_build,omitempty"`
|
||||
DockerRegistryImageName *string `json:"docker_registry_image_name,omitempty"`
|
||||
DockerRegistryImageTag *string `json:"docker_registry_image_tag,omitempty"`
|
||||
CustomDockerRunOptions *string `json:"custom_docker_run_options,omitempty"`
|
||||
@@ -106,6 +207,7 @@ type EnvironmentVariable struct {
|
||||
IsShownOnce bool `json:"is_shown_once"`
|
||||
IsRuntime bool `json:"is_runtime"`
|
||||
IsShared bool `json:"is_shared"`
|
||||
Comment *string `json:"comment,omitempty"`
|
||||
RealValue *string `json:"real_value,omitempty" sensitive:"true"`
|
||||
ApplicationID *int `json:"-" table:"-"`
|
||||
CreatedAt string `json:"-" table:"-"`
|
||||
@@ -114,13 +216,14 @@ type EnvironmentVariable struct {
|
||||
|
||||
// EnvironmentVariableCreateRequest represents the request to create an environment variable
|
||||
type EnvironmentVariableCreateRequest struct {
|
||||
Key string `json:"key"`
|
||||
Value string `json:"value"`
|
||||
IsBuildTime *bool `json:"is_build_time,omitempty"`
|
||||
IsPreview *bool `json:"is_preview,omitempty"`
|
||||
IsLiteral *bool `json:"is_literal,omitempty"`
|
||||
IsMultiline *bool `json:"is_multiline,omitempty"`
|
||||
IsRuntime *bool `json:"is_runtime,omitempty"`
|
||||
Key string `json:"key"`
|
||||
Value string `json:"value"`
|
||||
IsBuildTime *bool `json:"is_build_time,omitempty"`
|
||||
IsPreview *bool `json:"is_preview,omitempty"`
|
||||
IsLiteral *bool `json:"is_literal,omitempty"`
|
||||
IsMultiline *bool `json:"is_multiline,omitempty"`
|
||||
IsRuntime *bool `json:"is_runtime,omitempty"`
|
||||
Comment *string `json:"comment,omitempty"`
|
||||
}
|
||||
|
||||
// EnvironmentVariableUpdateRequest represents the request to update an environment variable
|
||||
@@ -132,6 +235,131 @@ type EnvironmentVariableUpdateRequest struct {
|
||||
IsLiteral *bool `json:"is_literal,omitempty"`
|
||||
IsMultiline *bool `json:"is_multiline,omitempty"`
|
||||
IsRuntime *bool `json:"is_runtime,omitempty"`
|
||||
Comment *string `json:"comment,omitempty"`
|
||||
}
|
||||
|
||||
// StoragesResponse represents the API response for listing storages
|
||||
type StoragesResponse struct {
|
||||
PersistentStorages []PersistentStorage `json:"persistent_storages"`
|
||||
FileStorages []FileStorage `json:"file_storages"`
|
||||
}
|
||||
|
||||
// PersistentStorage represents a persistent volume for an application
|
||||
type PersistentStorage struct {
|
||||
ID int `json:"id" table:"-"`
|
||||
UUID string `json:"uuid"`
|
||||
Name string `json:"name"`
|
||||
MountPath string `json:"mount_path"`
|
||||
HostPath *string `json:"host_path,omitempty"`
|
||||
IsPreviewSuffixEnabled bool `json:"is_preview_suffix_enabled"`
|
||||
IsReadOnly bool `json:"is_readonly"`
|
||||
ResourceType string `json:"resource_type" table:"-"`
|
||||
ResourceID int `json:"resource_id" table:"-"`
|
||||
}
|
||||
|
||||
// FileStorage represents a file storage for an application
|
||||
type FileStorage struct {
|
||||
ID int `json:"id"`
|
||||
UUID string `json:"uuid"`
|
||||
FsPath string `json:"fs_path"`
|
||||
MountPath string `json:"mount_path"`
|
||||
Content *string `json:"content,omitempty"`
|
||||
IsDirectory bool `json:"is_directory"`
|
||||
IsBasedOnGit bool `json:"is_based_on_git"`
|
||||
IsPreviewSuffixEnabled bool `json:"is_preview_suffix_enabled"`
|
||||
Chown *string `json:"chown,omitempty"`
|
||||
Chmod *string `json:"chmod,omitempty"`
|
||||
ResourceType string `json:"resource_type" table:"-"`
|
||||
ResourceID int `json:"resource_id" table:"-"`
|
||||
}
|
||||
|
||||
// StorageListItem is a unified view of both storage types for table output
|
||||
type StorageListItem struct {
|
||||
ID int `json:"id" table:"-"`
|
||||
UUID string `json:"uuid"`
|
||||
Type string `json:"type"`
|
||||
Name string `json:"name"`
|
||||
MountPath string `json:"mount_path"`
|
||||
HostPath string `json:"host_path,omitempty"`
|
||||
IsPreviewSuffixEnabled bool `json:"is_preview_suffix_enabled"`
|
||||
Content string `json:"content,omitempty" table:"-"`
|
||||
}
|
||||
|
||||
// MergeStorages converts a StoragesResponse into a unified list of StorageListItem
|
||||
func MergeStorages(resp StoragesResponse) []StorageListItem {
|
||||
var items []StorageListItem
|
||||
for _, ps := range resp.PersistentStorages {
|
||||
hostPath := ""
|
||||
if ps.HostPath != nil {
|
||||
hostPath = *ps.HostPath
|
||||
}
|
||||
items = append(items, StorageListItem{
|
||||
ID: ps.ID,
|
||||
UUID: ps.UUID,
|
||||
Type: "persistent",
|
||||
Name: ps.Name,
|
||||
MountPath: ps.MountPath,
|
||||
HostPath: hostPath,
|
||||
IsPreviewSuffixEnabled: ps.IsPreviewSuffixEnabled,
|
||||
})
|
||||
}
|
||||
for _, fs := range resp.FileStorages {
|
||||
content := ""
|
||||
if fs.Content != nil {
|
||||
content = *fs.Content
|
||||
}
|
||||
items = append(items, StorageListItem{
|
||||
ID: fs.ID,
|
||||
UUID: fs.UUID,
|
||||
Type: "file",
|
||||
Name: fs.FsPath,
|
||||
MountPath: fs.MountPath,
|
||||
IsPreviewSuffixEnabled: fs.IsPreviewSuffixEnabled,
|
||||
Content: content,
|
||||
})
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
// StorageCreateRequest represents the request to create a storage for applications and databases
|
||||
type StorageCreateRequest struct {
|
||||
Type string `json:"type"` // "persistent" or "file"
|
||||
MountPath string `json:"mount_path"` // required
|
||||
Name *string `json:"name,omitempty"`
|
||||
HostPath *string `json:"host_path,omitempty"`
|
||||
Content *string `json:"content,omitempty"`
|
||||
IsDirectory *bool `json:"is_directory,omitempty"`
|
||||
FsPath *string `json:"fs_path,omitempty"`
|
||||
}
|
||||
|
||||
// ServiceStorageCreateRequest represents the request to create a storage for services
|
||||
// Services require resource_uuid to identify which sub-resource the storage belongs to
|
||||
type ServiceStorageCreateRequest struct {
|
||||
Type string `json:"type"` // "persistent" or "file"
|
||||
MountPath string `json:"mount_path"` // required
|
||||
ResourceUUID string `json:"resource_uuid"` // required for services
|
||||
Name *string `json:"name,omitempty"`
|
||||
HostPath *string `json:"host_path,omitempty"`
|
||||
Content *string `json:"content,omitempty"`
|
||||
IsDirectory *bool `json:"is_directory,omitempty"`
|
||||
FsPath *string `json:"fs_path,omitempty"`
|
||||
}
|
||||
|
||||
// StorageUpdateRequest represents the request to update a storage
|
||||
type StorageUpdateRequest struct {
|
||||
// Required fields
|
||||
Type string `json:"type"` // "persistent" or "file"
|
||||
|
||||
// Identifier (uuid preferred, id deprecated)
|
||||
UUID *string `json:"uuid,omitempty"`
|
||||
ID *int `json:"id,omitempty"`
|
||||
|
||||
// Optional fields
|
||||
IsPreviewSuffixEnabled *bool `json:"is_preview_suffix_enabled,omitempty"`
|
||||
Name *string `json:"name,omitempty"`
|
||||
MountPath *string `json:"mount_path,omitempty"`
|
||||
HostPath *string `json:"host_path,omitempty"`
|
||||
Content *string `json:"content,omitempty"`
|
||||
}
|
||||
|
||||
// ApplicationCreatePublicRequest for POST /applications/public
|
||||
@@ -164,6 +392,7 @@ type ApplicationCreatePublicRequest struct {
|
||||
PortsMappings *string `json:"ports_mappings,omitempty"`
|
||||
CustomDockerRunOptions *string `json:"custom_docker_run_options,omitempty"`
|
||||
CustomLabels *string `json:"custom_labels,omitempty"`
|
||||
DockerfileTargetBuild *string `json:"dockerfile_target_build,omitempty"`
|
||||
|
||||
// Health checks
|
||||
HealthCheckEnabled *bool `json:"health_check_enabled,omitempty"`
|
||||
@@ -207,6 +436,7 @@ type ApplicationCreateGitHubAppRequest struct {
|
||||
PortsMappings *string `json:"ports_mappings,omitempty"`
|
||||
CustomDockerRunOptions *string `json:"custom_docker_run_options,omitempty"`
|
||||
CustomLabels *string `json:"custom_labels,omitempty"`
|
||||
DockerfileTargetBuild *string `json:"dockerfile_target_build,omitempty"`
|
||||
HealthCheckEnabled *bool `json:"health_check_enabled,omitempty"`
|
||||
HealthCheckPath *string `json:"health_check_path,omitempty"`
|
||||
HealthCheckPort *string `json:"health_check_port,omitempty"`
|
||||
@@ -246,6 +476,7 @@ type ApplicationCreateDeployKeyRequest struct {
|
||||
PortsMappings *string `json:"ports_mappings,omitempty"`
|
||||
CustomDockerRunOptions *string `json:"custom_docker_run_options,omitempty"`
|
||||
CustomLabels *string `json:"custom_labels,omitempty"`
|
||||
DockerfileTargetBuild *string `json:"dockerfile_target_build,omitempty"`
|
||||
HealthCheckEnabled *bool `json:"health_check_enabled,omitempty"`
|
||||
HealthCheckPath *string `json:"health_check_path,omitempty"`
|
||||
HealthCheckPort *string `json:"health_check_port,omitempty"`
|
||||
@@ -276,6 +507,7 @@ type ApplicationCreateDockerfileRequest struct {
|
||||
PortsMappings *string `json:"ports_mappings,omitempty"`
|
||||
CustomDockerRunOptions *string `json:"custom_docker_run_options,omitempty"`
|
||||
CustomLabels *string `json:"custom_labels,omitempty"`
|
||||
DockerfileTargetBuild *string `json:"dockerfile_target_build,omitempty"`
|
||||
HealthCheckEnabled *bool `json:"health_check_enabled,omitempty"`
|
||||
HealthCheckPath *string `json:"health_check_path,omitempty"`
|
||||
HealthCheckPort *string `json:"health_check_port,omitempty"`
|
||||
@@ -307,6 +539,7 @@ type ApplicationCreateDockerImageRequest struct {
|
||||
PortsMappings *string `json:"ports_mappings,omitempty"`
|
||||
CustomDockerRunOptions *string `json:"custom_docker_run_options,omitempty"`
|
||||
CustomLabels *string `json:"custom_labels,omitempty"`
|
||||
DockerfileTargetBuild *string `json:"dockerfile_target_build,omitempty"`
|
||||
HealthCheckEnabled *bool `json:"health_check_enabled,omitempty"`
|
||||
HealthCheckPath *string `json:"health_check_path,omitempty"`
|
||||
HealthCheckPort *string `json:"health_check_port,omitempty"`
|
||||
|
||||
@@ -164,6 +164,52 @@ type DatabaseLifecycleResponse struct {
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// DatabaseEnvironmentVariable represents an environment variable for a database
|
||||
type DatabaseEnvironmentVariable struct {
|
||||
ID int `json:"-" table:"-"`
|
||||
UUID string `json:"uuid"`
|
||||
Key string `json:"key"`
|
||||
Value string `json:"value" sensitive:"true"`
|
||||
IsBuildTime bool `json:"is_buildtime"`
|
||||
IsLiteralValue bool `json:"is_literal"`
|
||||
IsShownOnce bool `json:"is_shown_once"`
|
||||
IsRuntime bool `json:"is_runtime"`
|
||||
IsShared bool `json:"is_shared"`
|
||||
Comment *string `json:"comment,omitempty"`
|
||||
RealValue *string `json:"real_value,omitempty" sensitive:"true"`
|
||||
DatabaseID *int `json:"-" table:"-"`
|
||||
CreatedAt string `json:"-" table:"-"`
|
||||
UpdatedAt string `json:"-" table:"-"`
|
||||
}
|
||||
|
||||
// DatabaseEnvironmentVariableCreateRequest represents the request to create a database environment variable
|
||||
type DatabaseEnvironmentVariableCreateRequest struct {
|
||||
Key string `json:"key"`
|
||||
Value string `json:"value"`
|
||||
IsLiteral *bool `json:"is_literal,omitempty"`
|
||||
IsMultiline *bool `json:"is_multiline,omitempty"`
|
||||
IsShownOnce *bool `json:"is_shown_once,omitempty"`
|
||||
Comment *string `json:"comment,omitempty"`
|
||||
}
|
||||
|
||||
// DatabaseEnvironmentVariableUpdateRequest represents the request to update a database environment variable
|
||||
type DatabaseEnvironmentVariableUpdateRequest struct {
|
||||
Key *string `json:"key,omitempty"`
|
||||
Value *string `json:"value,omitempty"`
|
||||
IsLiteral *bool `json:"is_literal,omitempty"`
|
||||
IsMultiline *bool `json:"is_multiline,omitempty"`
|
||||
IsShownOnce *bool `json:"is_shown_once,omitempty"`
|
||||
Comment *string `json:"comment,omitempty"`
|
||||
}
|
||||
|
||||
// DatabaseEnvBulkUpdateRequest represents the request to bulk update database environment variables
|
||||
type DatabaseEnvBulkUpdateRequest struct {
|
||||
Data []DatabaseEnvironmentVariableCreateRequest `json:"data"`
|
||||
}
|
||||
|
||||
// DatabaseEnvBulkUpdateResponse represents the response from database bulk update
|
||||
type DatabaseEnvBulkUpdateResponse []DatabaseEnvironmentVariable
|
||||
|
||||
// DatabaseBackup represents a scheduled database backup configuration
|
||||
type DatabaseBackup struct {
|
||||
ID int `json:"-" table:"-"`
|
||||
|
||||
@@ -23,3 +23,11 @@ type DeployResponse struct {
|
||||
Message string `json:"message"`
|
||||
DeploymentUUID string `json:"deployment_uuid,omitempty"`
|
||||
}
|
||||
|
||||
// DeployRequest represents the request to trigger a deployment.
|
||||
type DeployRequest struct {
|
||||
UUID string `json:"uuid"`
|
||||
Force *bool `json:"force,omitempty"`
|
||||
PullRequestID *int `json:"pull_request_id,omitempty"`
|
||||
DockerTag *string `json:"docker_tag,omitempty"`
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
package models
|
||||
|
||||
// ContainerRow is a table-friendly row for `coolify firewall containers`.
|
||||
type ContainerRow struct {
|
||||
Host string `json:"host"`
|
||||
Namespace string `json:"namespace"`
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
IP string `json:"ip"`
|
||||
}
|
||||
|
||||
// AllowRuleRow is a table-friendly row for `coolify firewall list`.
|
||||
type AllowRuleRow struct {
|
||||
Host string `json:"host"`
|
||||
Namespace string `json:"namespace"`
|
||||
ID string `json:"id"`
|
||||
Src string `json:"src"`
|
||||
Dst string `json:"dst"`
|
||||
Proto string `json:"proto,omitempty"`
|
||||
Port int `json:"port,omitempty"`
|
||||
Comment string `json:"comment,omitempty"`
|
||||
}
|
||||
|
||||
// FirewallContainersOutput is the JSON output for `firewall containers`.
|
||||
type FirewallContainersOutput struct {
|
||||
Containers []ContainerRow `json:"containers"`
|
||||
Errors []string `json:"errors,omitempty"`
|
||||
}
|
||||
|
||||
// FirewallListOutput is the JSON output for `firewall list`.
|
||||
type FirewallListOutput struct {
|
||||
Rules []AllowRuleRow `json:"rules"`
|
||||
Errors []string `json:"errors,omitempty"`
|
||||
}
|
||||
|
||||
// FirewallAllowOutput is the JSON output for `firewall allow` / `revoke`.
|
||||
type FirewallAllowOutput struct {
|
||||
Rules []AllowRuleRow `json:"rules"`
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package models
|
||||
|
||||
// PlanActionRow is a table-friendly row for the plan output.
|
||||
type PlanActionRow struct {
|
||||
Server string `json:"server"`
|
||||
Action string `json:"action"`
|
||||
Detail string `json:"detail"`
|
||||
}
|
||||
|
||||
// PlanSkippedRow is a table-friendly row for actions the intent filter
|
||||
// suppressed (shown in the plan preview so operators can see what would have
|
||||
// run and why).
|
||||
type PlanSkippedRow struct {
|
||||
Server string `json:"server"`
|
||||
Action string `json:"action"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
// ApplyResultRow is a table-friendly row for the apply result output.
|
||||
type ApplyResultRow struct {
|
||||
Server string `json:"server"`
|
||||
Action string `json:"action"`
|
||||
Status string `json:"status"`
|
||||
Detail string `json:"detail,omitempty"`
|
||||
}
|
||||
|
||||
// VerifyResultRow is a table-friendly row for post-apply verification.
|
||||
type VerifyResultRow struct {
|
||||
Server string `json:"server"`
|
||||
WireGuardIP string `json:"wireguard_ip"`
|
||||
PeerCount int `json:"peer_count"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
// PlanOutput is the structured JSON output for the plan command.
|
||||
type PlanOutput struct {
|
||||
Servers []string `json:"servers"`
|
||||
Intent string `json:"intent,omitempty"`
|
||||
Actions []PlanActionRow `json:"actions"`
|
||||
Skipped []PlanSkippedRow `json:"skipped,omitempty"`
|
||||
Warnings []string `json:"warnings,omitempty"`
|
||||
}
|
||||
|
||||
// ApplyOutput is the structured JSON output for the apply command.
|
||||
type ApplyOutput struct {
|
||||
Results []ApplyResultRow `json:"results"`
|
||||
Verified []VerifyResultRow `json:"verified"`
|
||||
}
|
||||
@@ -99,6 +99,126 @@ func TestProject_UnmarshalFromFixture(t *testing.T) {
|
||||
assert.Equal(t, "running", project.Environments[0].Applications[0].Status)
|
||||
}
|
||||
|
||||
func TestApplication_MarshalUnmarshal(t *testing.T) {
|
||||
desc := "Test application"
|
||||
repo := "https://github.com/example/app"
|
||||
branch := "main"
|
||||
fqdn := "https://app.example.com"
|
||||
buildPack := "nixpacks"
|
||||
ports := "3000"
|
||||
installCmd := "npm install"
|
||||
buildCmd := "npm run build"
|
||||
startCmd := "npm start"
|
||||
limitsCPUs := "2"
|
||||
limitsMemory := "512M"
|
||||
healthPath := "/health"
|
||||
isAutoDeployEnabled := true
|
||||
|
||||
app := Application{
|
||||
ID: 1,
|
||||
UUID: "app-uuid",
|
||||
Name: "My App",
|
||||
Description: &desc,
|
||||
Status: "running",
|
||||
FQDN: &fqdn,
|
||||
GitRepository: &repo,
|
||||
GitBranch: &branch,
|
||||
BuildPack: &buildPack,
|
||||
PortsExposes: &ports,
|
||||
InstallCommand: &installCmd,
|
||||
BuildCommand: &buildCmd,
|
||||
StartCommand: &startCmd,
|
||||
LimitsCPUs: &limitsCPUs,
|
||||
LimitsMemory: &limitsMemory,
|
||||
HealthCheckPath: &healthPath,
|
||||
Settings: &ApplicationSettings{
|
||||
IsAutoDeployEnabled: &isAutoDeployEnabled,
|
||||
},
|
||||
CreatedAt: "2024-01-01T00:00:00Z",
|
||||
UpdatedAt: "2024-01-02T00:00:00Z",
|
||||
}
|
||||
|
||||
// Marshal
|
||||
data, err := json.Marshal(app)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Unmarshal
|
||||
var unmarshaled Application
|
||||
err = json.Unmarshal(data, &unmarshaled)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, app.UUID, unmarshaled.UUID)
|
||||
assert.Equal(t, app.Name, unmarshaled.Name)
|
||||
assert.Equal(t, *app.GitRepository, *unmarshaled.GitRepository)
|
||||
assert.Equal(t, *app.BuildPack, *unmarshaled.BuildPack)
|
||||
assert.Equal(t, *app.InstallCommand, *unmarshaled.InstallCommand)
|
||||
assert.Equal(t, *app.LimitsCPUs, *unmarshaled.LimitsCPUs)
|
||||
assert.NotNil(t, unmarshaled.Settings)
|
||||
assert.True(t, *unmarshaled.Settings.IsAutoDeployEnabled)
|
||||
}
|
||||
|
||||
func TestApplication_UnmarshalFromFixture(t *testing.T) {
|
||||
fixtureData, err := os.ReadFile(filepath.Join("..", "..", "test", "fixtures", "application.json"))
|
||||
require.NoError(t, err)
|
||||
|
||||
var app Application
|
||||
err = json.Unmarshal(fixtureData, &app)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, "app-fixture-uuid-123", app.UUID)
|
||||
assert.Equal(t, "My Web Application", app.Name)
|
||||
assert.Equal(t, "running", app.Status)
|
||||
assert.Equal(t, "https://app.example.com", *app.FQDN)
|
||||
assert.Equal(t, "https://github.com/example/app", *app.GitRepository)
|
||||
assert.Equal(t, "main", *app.GitBranch)
|
||||
assert.Equal(t, "nixpacks", *app.BuildPack)
|
||||
assert.Equal(t, "3000", *app.PortsExposes)
|
||||
assert.Equal(t, "npm install", *app.InstallCommand)
|
||||
assert.Equal(t, "npm run build", *app.BuildCommand)
|
||||
assert.Equal(t, "npm start", *app.StartCommand)
|
||||
assert.Equal(t, "/health", *app.HealthCheckPath)
|
||||
assert.Equal(t, 200, *app.HealthCheckReturnCode)
|
||||
assert.Equal(t, "2", *app.LimitsCPUs)
|
||||
assert.Equal(t, "512M", *app.LimitsMemory)
|
||||
assert.Equal(t, "npm run migrate", *app.PreDeploymentCommand)
|
||||
assert.Equal(t, 1, *app.SwarmReplicas)
|
||||
assert.Equal(t, "abc123", *app.ConfigHash)
|
||||
|
||||
// Nested settings
|
||||
require.NotNil(t, app.Settings)
|
||||
assert.NotNil(t, app.Settings.IsAutoDeployEnabled)
|
||||
assert.True(t, *app.Settings.IsAutoDeployEnabled)
|
||||
assert.True(t, *app.Settings.IsForceHTTPSEnabled)
|
||||
assert.False(t, *app.Settings.IsStatic)
|
||||
}
|
||||
|
||||
func TestApplication_JSONExcludesHiddenFields(t *testing.T) {
|
||||
app := Application{
|
||||
ID: 42,
|
||||
UUID: "app-uuid",
|
||||
Name: "Test App",
|
||||
Status: "running",
|
||||
EnvironmentID: intPtr(1),
|
||||
DestinationID: intPtr(2),
|
||||
CreatedAt: "2024-01-01T00:00:00Z",
|
||||
UpdatedAt: "2024-01-02T00:00:00Z",
|
||||
}
|
||||
|
||||
data, err := json.Marshal(app)
|
||||
require.NoError(t, err)
|
||||
|
||||
jsonStr := string(data)
|
||||
assert.NotContains(t, jsonStr, `"id"`)
|
||||
assert.NotContains(t, jsonStr, `"environment_id"`)
|
||||
assert.NotContains(t, jsonStr, `"destination_id"`)
|
||||
assert.NotContains(t, jsonStr, `"created_at"`)
|
||||
assert.NotContains(t, jsonStr, `"updated_at"`)
|
||||
assert.Contains(t, jsonStr, `"uuid"`)
|
||||
assert.Contains(t, jsonStr, `"name"`)
|
||||
}
|
||||
|
||||
func intPtr(v int) *int { return &v }
|
||||
|
||||
func TestResource_MarshalUnmarshal(t *testing.T) {
|
||||
resource := Resource{
|
||||
ID: 1,
|
||||
|
||||
@@ -82,6 +82,7 @@ type ServiceEnvironmentVariable struct {
|
||||
IsShownOnce bool `json:"is_shown_once"`
|
||||
IsRuntime bool `json:"is_runtime"`
|
||||
IsShared bool `json:"is_shared"`
|
||||
Comment *string `json:"comment,omitempty"`
|
||||
RealValue *string `json:"real_value,omitempty" sensitive:"true"`
|
||||
ServiceID *int `json:"-" table:"-"`
|
||||
CreatedAt string `json:"-" table:"-"`
|
||||
@@ -90,12 +91,13 @@ type ServiceEnvironmentVariable struct {
|
||||
|
||||
// ServiceEnvironmentVariableCreateRequest represents the request to create a service environment variable
|
||||
type ServiceEnvironmentVariableCreateRequest struct {
|
||||
Key string `json:"key"`
|
||||
Value string `json:"value"`
|
||||
IsBuildTime *bool `json:"is_build_time,omitempty"`
|
||||
IsLiteral *bool `json:"is_literal,omitempty"`
|
||||
IsMultiline *bool `json:"is_multiline,omitempty"`
|
||||
IsRuntime *bool `json:"is_runtime,omitempty"`
|
||||
Key string `json:"key"`
|
||||
Value string `json:"value"`
|
||||
IsBuildTime *bool `json:"is_build_time,omitempty"`
|
||||
IsLiteral *bool `json:"is_literal,omitempty"`
|
||||
IsMultiline *bool `json:"is_multiline,omitempty"`
|
||||
IsRuntime *bool `json:"is_runtime,omitempty"`
|
||||
Comment *string `json:"comment,omitempty"`
|
||||
}
|
||||
|
||||
// ServiceEnvironmentVariableUpdateRequest represents the request to update a service environment variable
|
||||
@@ -106,6 +108,7 @@ type ServiceEnvironmentVariableUpdateRequest struct {
|
||||
IsLiteral *bool `json:"is_literal,omitempty"`
|
||||
IsMultiline *bool `json:"is_multiline,omitempty"`
|
||||
IsRuntime *bool `json:"is_runtime,omitempty"`
|
||||
Comment *string `json:"comment,omitempty"`
|
||||
}
|
||||
|
||||
// ServiceEnvBulkUpdateRequest represents the request to bulk update service environment variables
|
||||
|
||||
@@ -210,12 +210,23 @@ func TestTableFormatter_BooleanValues(t *testing.T) {
|
||||
|
||||
output := buf.String()
|
||||
|
||||
// Check boolean formatting
|
||||
lines := strings.Split(output, "\n")
|
||||
assert.Contains(t, lines[1], "true")
|
||||
assert.Contains(t, lines[1], "false")
|
||||
assert.Contains(t, lines[2], "false")
|
||||
assert.Contains(t, lines[2], "true")
|
||||
// Check boolean formatting - verify both rows contain expected values
|
||||
// Row 1: test1, true, false
|
||||
assert.Contains(t, output, "test1")
|
||||
assert.Contains(t, output, "test2")
|
||||
|
||||
// Find lines containing test data (not headers/borders)
|
||||
var dataLines []string
|
||||
for _, line := range strings.Split(output, "\n") {
|
||||
if strings.Contains(line, "test1") || strings.Contains(line, "test2") {
|
||||
dataLines = append(dataLines, line)
|
||||
}
|
||||
}
|
||||
require.Len(t, dataLines, 2, "expected exactly 2 data rows")
|
||||
assert.Contains(t, dataLines[0], "true")
|
||||
assert.Contains(t, dataLines[0], "false")
|
||||
assert.Contains(t, dataLines[1], "true")
|
||||
assert.Contains(t, dataLines[1], "false")
|
||||
}
|
||||
|
||||
func TestTableFormatter_NilPointer(t *testing.T) {
|
||||
|
||||
+34
-39
@@ -4,7 +4,9 @@ import (
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
"text/tabwriter"
|
||||
|
||||
"github.com/olekukonko/tablewriter"
|
||||
"github.com/olekukonko/tablewriter/tw"
|
||||
)
|
||||
|
||||
// TableFormatter formats output as a table
|
||||
@@ -18,21 +20,6 @@ func NewTableFormatter(opts Options) *TableFormatter {
|
||||
}
|
||||
|
||||
func (f *TableFormatter) Format(data any) (err error) {
|
||||
w := tabwriter.NewWriter(f.opts.Writer, 0, 0, 2, ' ', tabwriter.Debug)
|
||||
defer func() {
|
||||
if flushErr := w.Flush(); flushErr != nil {
|
||||
if err == nil {
|
||||
err = fmt.Errorf("failed to flush table writer: %w", flushErr)
|
||||
}
|
||||
}
|
||||
// Add a final newline nach table output, but only if no error occurred
|
||||
if err == nil {
|
||||
if _, nlErr := fmt.Fprintln(f.opts.Writer); nlErr != nil {
|
||||
err = fmt.Errorf("failed to write trailing newline: %w", nlErr)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// Handle different data types
|
||||
val := reflect.ValueOf(data)
|
||||
|
||||
@@ -41,6 +28,24 @@ func (f *TableFormatter) Format(data any) (err error) {
|
||||
val = val.Elem()
|
||||
}
|
||||
|
||||
// Check for empty slice/array before creating the table writer
|
||||
if (val.Kind() == reflect.Slice || val.Kind() == reflect.Array) && val.Len() == 0 {
|
||||
if _, writeErr := fmt.Fprintln(f.opts.Writer, "No data"); writeErr != nil {
|
||||
return fmt.Errorf("failed to write no data message: %w", writeErr)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
w := tablewriter.NewWriter(f.opts.Writer)
|
||||
defer func() {
|
||||
if renderErr := w.Render(); renderErr != nil && err == nil {
|
||||
err = fmt.Errorf("failed to render table: %w", renderErr)
|
||||
}
|
||||
}()
|
||||
|
||||
// disable ALL CAPS for column headers
|
||||
w.Options(tablewriter.WithHeaderAutoFormat(tw.Off))
|
||||
|
||||
switch val.Kind() {
|
||||
case reflect.Slice, reflect.Array:
|
||||
return f.formatSlice(w, val)
|
||||
@@ -54,14 +59,7 @@ func (f *TableFormatter) Format(data any) (err error) {
|
||||
}
|
||||
|
||||
// formatSlice formats a slice of structs as a table
|
||||
func (f *TableFormatter) formatSlice(w *tabwriter.Writer, val reflect.Value) error {
|
||||
if val.Len() == 0 {
|
||||
if _, err := fmt.Fprintln(w, "No data"); err != nil {
|
||||
return fmt.Errorf("failed to write no data message: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *TableFormatter) formatSlice(w *tablewriter.Table, val reflect.Value) error {
|
||||
// Get the first element to determine columns
|
||||
firstElem := val.Index(0)
|
||||
if firstElem.Kind() == reflect.Ptr {
|
||||
@@ -71,7 +69,9 @@ func (f *TableFormatter) formatSlice(w *tabwriter.Writer, val reflect.Value) err
|
||||
if firstElem.Kind() != reflect.Struct {
|
||||
// Simple slice (e.g., []string)
|
||||
for i := 0; i < val.Len(); i++ {
|
||||
if _, err := fmt.Fprintf(w, "%v\n", val.Index(i).Interface()); err != nil {
|
||||
elem := val.Index(i)
|
||||
row := []string{f.formatValue(elem)}
|
||||
if err := w.Append(row); err != nil {
|
||||
return fmt.Errorf("failed to write slice element: %w", err)
|
||||
}
|
||||
}
|
||||
@@ -82,9 +82,7 @@ func (f *TableFormatter) formatSlice(w *tabwriter.Writer, val reflect.Value) err
|
||||
headers := f.getHeaders(firstElem.Type())
|
||||
// Add # as first column header
|
||||
headersWithNum := append([]string{"#"}, headers...)
|
||||
if _, err := fmt.Fprintln(w, strings.Join(headersWithNum, "\t")); err != nil {
|
||||
return fmt.Errorf("failed to write table headers: %w", err)
|
||||
}
|
||||
w.Header(headersWithNum)
|
||||
|
||||
// Print rows
|
||||
for i := 0; i < val.Len(); i++ {
|
||||
@@ -95,7 +93,7 @@ func (f *TableFormatter) formatSlice(w *tabwriter.Writer, val reflect.Value) err
|
||||
row := f.formatStructRow(elem)
|
||||
// Add row number (1-indexed) as first column
|
||||
rowWithNum := append([]string{fmt.Sprintf("%d", i+1)}, row...)
|
||||
if _, err := fmt.Fprintln(w, strings.Join(rowWithNum, "\t")); err != nil {
|
||||
if err := w.Append(rowWithNum); err != nil {
|
||||
return fmt.Errorf("failed to write table row: %w", err)
|
||||
}
|
||||
}
|
||||
@@ -104,16 +102,14 @@ func (f *TableFormatter) formatSlice(w *tabwriter.Writer, val reflect.Value) err
|
||||
}
|
||||
|
||||
// formatStruct formats a single struct as a table (horizontal layout with headers)
|
||||
func (f *TableFormatter) formatStruct(w *tabwriter.Writer, val reflect.Value) error {
|
||||
func (f *TableFormatter) formatStruct(w *tablewriter.Table, val reflect.Value) error {
|
||||
// Get headers
|
||||
headers := f.getHeaders(val.Type())
|
||||
if _, err := fmt.Fprintln(w, strings.Join(headers, "\t")); err != nil {
|
||||
return fmt.Errorf("failed to write struct headers: %w", err)
|
||||
}
|
||||
w.Header(headers)
|
||||
|
||||
// Get row data
|
||||
row := f.formatStructRow(val)
|
||||
if _, err := fmt.Fprintln(w, strings.Join(row, "\t")); err != nil {
|
||||
if err := w.Append(row); err != nil {
|
||||
return fmt.Errorf("failed to write struct row: %w", err)
|
||||
}
|
||||
|
||||
@@ -121,16 +117,15 @@ func (f *TableFormatter) formatStruct(w *tabwriter.Writer, val reflect.Value) er
|
||||
}
|
||||
|
||||
// formatMap formats a map as a table
|
||||
func (f *TableFormatter) formatMap(w *tabwriter.Writer, val reflect.Value) error {
|
||||
if _, err := fmt.Fprintln(w, "Key\tValue"); err != nil {
|
||||
return fmt.Errorf("failed to write map headers: %w", err)
|
||||
}
|
||||
func (f *TableFormatter) formatMap(w *tablewriter.Table, val reflect.Value) error {
|
||||
w.Header([]string{"Key", "Value"})
|
||||
|
||||
iter := val.MapRange()
|
||||
for iter.Next() {
|
||||
key := iter.Key()
|
||||
value := iter.Value()
|
||||
if _, err := fmt.Fprintf(w, "%v\t%v\n", key.Interface(), f.formatValue(value)); err != nil {
|
||||
row := []string{f.formatValue(key), f.formatValue(value)}
|
||||
if err := w.Append(row); err != nil {
|
||||
return fmt.Errorf("failed to write map entry: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,6 +59,15 @@ func (s *ApplicationService) Delete(ctx context.Context, uuid string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeletePreview deletes a preview deployment for an application
|
||||
func (s *ApplicationService) DeletePreview(ctx context.Context, appUUID, prID string) error {
|
||||
err := s.client.Delete(ctx, fmt.Sprintf("applications/%s/previews/%s", appUUID, prID))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete preview %s for application %s: %w", prID, appUUID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Start starts an application (initiates deployment)
|
||||
func (s *ApplicationService) Start(ctx context.Context, uuid string, force bool, instantDeploy bool) (*models.ApplicationLifecycleResponse, error) {
|
||||
var resp models.ApplicationLifecycleResponse
|
||||
@@ -184,9 +193,7 @@ type BulkUpdateEnvsRequest struct {
|
||||
}
|
||||
|
||||
// BulkUpdateEnvsResponse represents the response from bulk update
|
||||
type BulkUpdateEnvsResponse struct {
|
||||
Message string `json:"message"`
|
||||
}
|
||||
type BulkUpdateEnvsResponse []models.EnvironmentVariable
|
||||
|
||||
// BulkUpdateEnvs updates multiple environment variables in a single request
|
||||
func (s *ApplicationService) BulkUpdateEnvs(ctx context.Context, appUUID string, req *BulkUpdateEnvsRequest) (*BulkUpdateEnvsResponse, error) {
|
||||
@@ -198,6 +205,43 @@ func (s *ApplicationService) BulkUpdateEnvs(ctx context.Context, appUUID string,
|
||||
return &response, nil
|
||||
}
|
||||
|
||||
// ListStorages retrieves all storages for an application
|
||||
func (s *ApplicationService) ListStorages(ctx context.Context, uuid string) ([]models.StorageListItem, error) {
|
||||
var resp models.StoragesResponse
|
||||
err := s.client.Get(ctx, fmt.Sprintf("applications/%s/storages", uuid), &resp)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list storages for application %s: %w", uuid, err)
|
||||
}
|
||||
return models.MergeStorages(resp), nil
|
||||
}
|
||||
|
||||
// CreateStorage creates a new storage for an application
|
||||
func (s *ApplicationService) CreateStorage(ctx context.Context, uuid string, req *models.StorageCreateRequest) error {
|
||||
err := s.client.Post(ctx, fmt.Sprintf("applications/%s/storages", uuid), req, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create storage for application %s: %w", uuid, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateStorage updates a storage for an application
|
||||
func (s *ApplicationService) UpdateStorage(ctx context.Context, uuid string, req *models.StorageUpdateRequest) error {
|
||||
err := s.client.Patch(ctx, fmt.Sprintf("applications/%s/storages", uuid), req, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to update storage for application %s: %w", uuid, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteStorage deletes a storage from an application
|
||||
func (s *ApplicationService) DeleteStorage(ctx context.Context, appUUID, storageUUID string) error {
|
||||
err := s.client.Delete(ctx, fmt.Sprintf("applications/%s/storages/%s", appUUID, storageUUID))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete storage %s from application %s: %w", storageUUID, appUUID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CreatePublic creates an application from a public git repository
|
||||
func (s *ApplicationService) CreatePublic(ctx context.Context, req *models.ApplicationCreatePublicRequest) (*models.Application, error) {
|
||||
var app models.Application
|
||||
|
||||
@@ -110,17 +110,41 @@ func TestApplicationService_Get(t *testing.T) {
|
||||
desc := "Test Application"
|
||||
branch := "main"
|
||||
fqdn := "test.example.com"
|
||||
repo := "https://github.com/example/app"
|
||||
buildPack := "nixpacks"
|
||||
ports := "3000"
|
||||
installCmd := "npm install"
|
||||
buildCmd := "npm run build"
|
||||
startCmd := "npm start"
|
||||
healthPath := "/health"
|
||||
limitsCPUs := "2"
|
||||
limitsMemory := "512M"
|
||||
preDeployCmd := "npm run migrate"
|
||||
isAutoDeployEnabled := true
|
||||
|
||||
application := models.Application{
|
||||
ID: 1,
|
||||
UUID: "app-uuid-123",
|
||||
Name: "Test App",
|
||||
Description: &desc,
|
||||
Status: "running",
|
||||
GitBranch: &branch,
|
||||
FQDN: &fqdn,
|
||||
CreatedAt: "2024-01-01T00:00:00Z",
|
||||
UpdatedAt: "2024-01-02T00:00:00Z",
|
||||
ID: 1,
|
||||
UUID: "app-uuid-123",
|
||||
Name: "Test App",
|
||||
Description: &desc,
|
||||
Status: "running",
|
||||
GitBranch: &branch,
|
||||
FQDN: &fqdn,
|
||||
GitRepository: &repo,
|
||||
BuildPack: &buildPack,
|
||||
PortsExposes: &ports,
|
||||
InstallCommand: &installCmd,
|
||||
BuildCommand: &buildCmd,
|
||||
StartCommand: &startCmd,
|
||||
HealthCheckPath: &healthPath,
|
||||
LimitsCPUs: &limitsCPUs,
|
||||
LimitsMemory: &limitsMemory,
|
||||
PreDeploymentCommand: &preDeployCmd,
|
||||
Settings: &models.ApplicationSettings{
|
||||
IsAutoDeployEnabled: &isAutoDeployEnabled,
|
||||
},
|
||||
CreatedAt: "2024-01-01T00:00:00Z",
|
||||
UpdatedAt: "2024-01-02T00:00:00Z",
|
||||
}
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -144,6 +168,18 @@ func TestApplicationService_Get(t *testing.T) {
|
||||
assert.Equal(t, "running", result.Status)
|
||||
assert.Equal(t, "main", *result.GitBranch)
|
||||
assert.Equal(t, "test.example.com", *result.FQDN)
|
||||
assert.Equal(t, "https://github.com/example/app", *result.GitRepository)
|
||||
assert.Equal(t, "nixpacks", *result.BuildPack)
|
||||
assert.Equal(t, "3000", *result.PortsExposes)
|
||||
assert.Equal(t, "npm install", *result.InstallCommand)
|
||||
assert.Equal(t, "npm run build", *result.BuildCommand)
|
||||
assert.Equal(t, "npm start", *result.StartCommand)
|
||||
assert.Equal(t, "/health", *result.HealthCheckPath)
|
||||
assert.Equal(t, "2", *result.LimitsCPUs)
|
||||
assert.Equal(t, "512M", *result.LimitsMemory)
|
||||
assert.Equal(t, "npm run migrate", *result.PreDeploymentCommand)
|
||||
require.NotNil(t, result.Settings)
|
||||
assert.True(t, *result.Settings.IsAutoDeployEnabled)
|
||||
}
|
||||
|
||||
func TestApplicationService_Get_NotFound(t *testing.T) {
|
||||
@@ -366,6 +402,54 @@ func TestApplicationService_Delete_Error(t *testing.T) {
|
||||
assert.Contains(t, err.Error(), "failed to delete application")
|
||||
}
|
||||
|
||||
func TestApplicationService_DeletePreview_Success(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "/api/v1/applications/app-uuid-123/previews/42", r.URL.Path)
|
||||
assert.Equal(t, "DELETE", r.Method)
|
||||
assert.Equal(t, "Bearer test-token", r.Header.Get("Authorization"))
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(`{"message":"Preview deletion request queued."}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := api.NewClient(server.URL, "test-token")
|
||||
svc := NewApplicationService(client)
|
||||
|
||||
err := svc.DeletePreview(context.Background(), "app-uuid-123", "42")
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestApplicationService_DeletePreview_NotFound(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
_, _ = w.Write([]byte(`{"message":"Preview not found."}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := api.NewClient(server.URL, "test-token")
|
||||
svc := NewApplicationService(client)
|
||||
|
||||
err := svc.DeletePreview(context.Background(), "app-uuid-123", "999")
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "failed to delete preview")
|
||||
}
|
||||
|
||||
func TestApplicationService_DeletePreview_ServerError(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
_, _ = w.Write([]byte(`{"message":"internal server error"}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := api.NewClient(server.URL, "test-token")
|
||||
svc := NewApplicationService(client)
|
||||
|
||||
err := svc.DeletePreview(context.Background(), "app-uuid-123", "42")
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "failed to delete preview")
|
||||
}
|
||||
|
||||
func TestApplicationService_Start(t *testing.T) {
|
||||
deploymentUUID := "deploy-uuid-123"
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -724,10 +808,12 @@ func TestApplicationService_UpdateEnv(t *testing.T) {
|
||||
assert.Equal(t, "/api/v1/applications/app-uuid-123/envs", r.URL.Path)
|
||||
assert.Equal(t, "PATCH", r.Method)
|
||||
|
||||
comment := "API key for external service"
|
||||
env := models.EnvironmentVariable{
|
||||
UUID: "env-uuid-1",
|
||||
Key: "API_KEY",
|
||||
Value: "newsecret456",
|
||||
UUID: "env-uuid-1",
|
||||
Key: "API_KEY",
|
||||
Value: "newsecret456",
|
||||
Comment: &comment,
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(env)
|
||||
@@ -739,9 +825,11 @@ func TestApplicationService_UpdateEnv(t *testing.T) {
|
||||
|
||||
newKey := "API_KEY"
|
||||
newValue := "newsecret456"
|
||||
newComment := "API key for external service"
|
||||
req := &models.EnvironmentVariableUpdateRequest{
|
||||
Key: &newKey,
|
||||
Value: &newValue,
|
||||
Key: &newKey,
|
||||
Value: &newValue,
|
||||
Comment: &newComment,
|
||||
}
|
||||
|
||||
result, err := svc.UpdateEnv(context.Background(), "app-uuid-123", req)
|
||||
@@ -749,6 +837,7 @@ func TestApplicationService_UpdateEnv(t *testing.T) {
|
||||
assert.NotNil(t, result)
|
||||
assert.Equal(t, "API_KEY", result.Key)
|
||||
assert.Equal(t, "newsecret456", result.Value)
|
||||
assert.Equal(t, "API key for external service", *result.Comment)
|
||||
}
|
||||
|
||||
func TestApplicationService_UpdateEnv_Error(t *testing.T) {
|
||||
@@ -1196,3 +1285,328 @@ func TestApplicationService_CreateDockerImage(t *testing.T) {
|
||||
assert.NotNil(t, result)
|
||||
assert.Equal(t, "new-app-uuid", result.UUID)
|
||||
}
|
||||
|
||||
func TestApplicationService_BulkUpdateEnvs(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "/api/v1/applications/app-uuid-123/envs/bulk", r.URL.Path)
|
||||
assert.Equal(t, "PATCH", r.Method)
|
||||
assert.Equal(t, "Bearer test-token", r.Header.Get("Authorization"))
|
||||
|
||||
// Verify request body was correctly serialized
|
||||
var reqBody BulkUpdateEnvsRequest
|
||||
err := json.NewDecoder(r.Body).Decode(&reqBody)
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, reqBody.Data, 1)
|
||||
assert.Equal(t, "KEY1", reqBody.Data[0].Key)
|
||||
assert.Equal(t, "VAL1", reqBody.Data[0].Value)
|
||||
|
||||
// Return array response as per API
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
resp := []models.EnvironmentVariable{
|
||||
{
|
||||
Key: "KEY1",
|
||||
Value: "VAL1",
|
||||
},
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(resp)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := api.NewClient(server.URL, "test-token")
|
||||
svc := NewApplicationService(client)
|
||||
|
||||
req := &BulkUpdateEnvsRequest{
|
||||
Data: []models.EnvironmentVariableCreateRequest{
|
||||
{Key: "KEY1", Value: "VAL1"},
|
||||
},
|
||||
}
|
||||
|
||||
result, err := svc.BulkUpdateEnvs(context.Background(), "app-uuid-123", req)
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, result)
|
||||
assert.Len(t, *result, 1)
|
||||
assert.Equal(t, "KEY1", (*result)[0].Key)
|
||||
}
|
||||
|
||||
func TestApplicationService_BulkUpdateEnvs_APIError(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
_, _ = w.Write([]byte(`{"message":"internal server error"}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := api.NewClient(server.URL, "test-token")
|
||||
svc := NewApplicationService(client)
|
||||
|
||||
req := &BulkUpdateEnvsRequest{
|
||||
Data: []models.EnvironmentVariableCreateRequest{
|
||||
{Key: "KEY1", Value: "VAL1"},
|
||||
},
|
||||
}
|
||||
|
||||
result, err := svc.BulkUpdateEnvs(context.Background(), "app-uuid-123", req)
|
||||
require.Error(t, err)
|
||||
assert.Nil(t, result)
|
||||
assert.Contains(t, err.Error(), "failed to bulk update environment variables")
|
||||
}
|
||||
|
||||
func TestApplicationService_ListStorages(t *testing.T) {
|
||||
hostPath := "/var/data"
|
||||
content := "key: value"
|
||||
resp := models.StoragesResponse{
|
||||
PersistentStorages: []models.PersistentStorage{
|
||||
{
|
||||
ID: 1,
|
||||
UUID: "ps-uuid-1",
|
||||
Name: "data-volume",
|
||||
MountPath: "/data",
|
||||
HostPath: &hostPath,
|
||||
IsPreviewSuffixEnabled: false,
|
||||
IsReadOnly: false,
|
||||
ResourceType: "App\\Models\\Application",
|
||||
ResourceID: 10,
|
||||
},
|
||||
},
|
||||
FileStorages: []models.FileStorage{
|
||||
{
|
||||
ID: 2,
|
||||
UUID: "fs-uuid-1",
|
||||
FsPath: "/app/config.yml",
|
||||
MountPath: "/app/config.yml",
|
||||
Content: &content,
|
||||
IsDirectory: false,
|
||||
IsBasedOnGit: false,
|
||||
IsPreviewSuffixEnabled: true,
|
||||
ResourceType: "App\\Models\\Application",
|
||||
ResourceID: 10,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "/api/v1/applications/app-uuid-123/storages", r.URL.Path)
|
||||
assert.Equal(t, "GET", r.Method)
|
||||
assert.Equal(t, "Bearer test-token", r.Header.Get("Authorization"))
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(resp)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := api.NewClient(server.URL, "test-token")
|
||||
svc := NewApplicationService(client)
|
||||
|
||||
result, err := svc.ListStorages(context.Background(), "app-uuid-123")
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, result, 2)
|
||||
assert.Equal(t, "ps-uuid-1", result[0].UUID)
|
||||
assert.Equal(t, "persistent", result[0].Type)
|
||||
assert.Equal(t, "data-volume", result[0].Name)
|
||||
assert.Equal(t, "/data", result[0].MountPath)
|
||||
assert.Equal(t, "/var/data", result[0].HostPath)
|
||||
assert.False(t, result[0].IsPreviewSuffixEnabled)
|
||||
assert.Equal(t, "fs-uuid-1", result[1].UUID)
|
||||
assert.Equal(t, "file", result[1].Type)
|
||||
assert.Equal(t, "/app/config.yml", result[1].Name)
|
||||
assert.Equal(t, "key: value", result[1].Content)
|
||||
assert.True(t, result[1].IsPreviewSuffixEnabled)
|
||||
}
|
||||
|
||||
func TestApplicationService_ListStorages_Empty(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "/api/v1/applications/app-uuid-123/storages", r.URL.Path)
|
||||
assert.Equal(t, "GET", r.Method)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"persistent_storages":[],"file_storages":[]}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := api.NewClient(server.URL, "test-token")
|
||||
svc := NewApplicationService(client)
|
||||
|
||||
result, err := svc.ListStorages(context.Background(), "app-uuid-123")
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, result)
|
||||
}
|
||||
|
||||
func TestApplicationService_ListStorages_Error(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
_, _ = w.Write([]byte(`{"message":"application not found"}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := api.NewClient(server.URL, "test-token")
|
||||
svc := NewApplicationService(client)
|
||||
|
||||
result, err := svc.ListStorages(context.Background(), "app-uuid-123")
|
||||
require.Error(t, err)
|
||||
assert.Nil(t, result)
|
||||
assert.Contains(t, err.Error(), "failed to list storages")
|
||||
}
|
||||
|
||||
func TestApplicationService_UpdateStorage(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "/api/v1/applications/app-uuid-123/storages", r.URL.Path)
|
||||
assert.Equal(t, "PATCH", r.Method)
|
||||
assert.Equal(t, "Bearer test-token", r.Header.Get("Authorization"))
|
||||
|
||||
var req models.StorageUpdateRequest
|
||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||
assert.NotNil(t, req.UUID)
|
||||
assert.Equal(t, "storage-uuid-1", *req.UUID)
|
||||
assert.Equal(t, "persistent", req.Type)
|
||||
assert.NotNil(t, req.Name)
|
||||
assert.Equal(t, "new-name", *req.Name)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(`{"message":"Storage updated."}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := api.NewClient(server.URL, "test-token")
|
||||
svc := NewApplicationService(client)
|
||||
|
||||
name := "new-name"
|
||||
storageUUID := "storage-uuid-1"
|
||||
req := &models.StorageUpdateRequest{
|
||||
UUID: &storageUUID,
|
||||
Type: "persistent",
|
||||
Name: &name,
|
||||
}
|
||||
|
||||
err := svc.UpdateStorage(context.Background(), "app-uuid-123", req)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestApplicationService_UpdateStorage_NotFound(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
_, _ = w.Write([]byte(`{"message":"application not found"}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := api.NewClient(server.URL, "test-token")
|
||||
svc := NewApplicationService(client)
|
||||
|
||||
name := "new-name"
|
||||
storageUUID := "storage-uuid-1"
|
||||
req := &models.StorageUpdateRequest{
|
||||
UUID: &storageUUID,
|
||||
Type: "persistent",
|
||||
Name: &name,
|
||||
}
|
||||
|
||||
err := svc.UpdateStorage(context.Background(), "non-existent-uuid", req)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "failed to update storage")
|
||||
}
|
||||
|
||||
func TestApplicationService_UpdateStorage_Error(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
_, _ = w.Write([]byte(`{"message":"internal server error"}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := api.NewClient(server.URL, "test-token")
|
||||
svc := NewApplicationService(client)
|
||||
|
||||
name := "new-name"
|
||||
storageUUID := "storage-uuid-1"
|
||||
req := &models.StorageUpdateRequest{
|
||||
UUID: &storageUUID,
|
||||
Type: "persistent",
|
||||
Name: &name,
|
||||
}
|
||||
|
||||
err := svc.UpdateStorage(context.Background(), "app-uuid-123", req)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "failed to update storage")
|
||||
}
|
||||
|
||||
func TestApplicationService_CreateStorage(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "/api/v1/applications/app-uuid-123/storages", r.URL.Path)
|
||||
assert.Equal(t, "POST", r.Method)
|
||||
assert.Equal(t, "Bearer test-token", r.Header.Get("Authorization"))
|
||||
|
||||
var req models.StorageCreateRequest
|
||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||
assert.Equal(t, "persistent", req.Type)
|
||||
assert.Equal(t, "/data", req.MountPath)
|
||||
assert.NotNil(t, req.Name)
|
||||
assert.Equal(t, "my-volume", *req.Name)
|
||||
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := api.NewClient(server.URL, "test-token")
|
||||
svc := NewApplicationService(client)
|
||||
|
||||
name := "my-volume"
|
||||
req := &models.StorageCreateRequest{
|
||||
Type: "persistent",
|
||||
MountPath: "/data",
|
||||
Name: &name,
|
||||
}
|
||||
|
||||
err := svc.CreateStorage(context.Background(), "app-uuid-123", req)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestApplicationService_CreateStorage_Error(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
_, _ = w.Write([]byte(`{"message":"invalid request"}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := api.NewClient(server.URL, "test-token")
|
||||
svc := NewApplicationService(client)
|
||||
|
||||
req := &models.StorageCreateRequest{
|
||||
Type: "persistent",
|
||||
MountPath: "/data",
|
||||
}
|
||||
|
||||
err := svc.CreateStorage(context.Background(), "app-uuid-123", req)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "failed to create storage")
|
||||
}
|
||||
|
||||
func TestApplicationService_DeleteStorage(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "/api/v1/applications/app-uuid-123/storages/storage-uuid-1", r.URL.Path)
|
||||
assert.Equal(t, "DELETE", r.Method)
|
||||
assert.Equal(t, "Bearer test-token", r.Header.Get("Authorization"))
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"message":"Storage deleted."}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := api.NewClient(server.URL, "test-token")
|
||||
svc := NewApplicationService(client)
|
||||
|
||||
err := svc.DeleteStorage(context.Background(), "app-uuid-123", "storage-uuid-1")
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestApplicationService_DeleteStorage_NotFound(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
_, _ = w.Write([]byte(`{"message":"storage not found"}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := api.NewClient(server.URL, "test-token")
|
||||
svc := NewApplicationService(client)
|
||||
|
||||
err := svc.DeleteStorage(context.Background(), "app-uuid-123", "nonexistent-uuid")
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "failed to delete storage")
|
||||
}
|
||||
|
||||
@@ -173,6 +173,108 @@ func (s *DatabaseService) DeleteBackupExecution(ctx context.Context, dbUUID, bac
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListEnvs retrieves all environment variables for a database
|
||||
func (s *DatabaseService) ListEnvs(ctx context.Context, uuid string) ([]models.DatabaseEnvironmentVariable, error) {
|
||||
var envs []models.DatabaseEnvironmentVariable
|
||||
err := s.client.Get(ctx, fmt.Sprintf("databases/%s/envs", uuid), &envs)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list environment variables for database %s: %w", uuid, err)
|
||||
}
|
||||
return envs, nil
|
||||
}
|
||||
|
||||
// GetEnv retrieves a single environment variable by UUID or key
|
||||
func (s *DatabaseService) GetEnv(ctx context.Context, dbUUID, envIdentifier string) (*models.DatabaseEnvironmentVariable, error) {
|
||||
envs, err := s.ListEnvs(ctx, dbUUID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, env := range envs {
|
||||
if env.UUID == envIdentifier || env.Key == envIdentifier {
|
||||
return &env, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("environment variable '%s' not found in database %s", envIdentifier, dbUUID)
|
||||
}
|
||||
|
||||
// CreateEnv creates a new environment variable for a database
|
||||
func (s *DatabaseService) CreateEnv(ctx context.Context, uuid string, req *models.DatabaseEnvironmentVariableCreateRequest) (*models.DatabaseEnvironmentVariable, error) {
|
||||
var env models.DatabaseEnvironmentVariable
|
||||
err := s.client.Post(ctx, fmt.Sprintf("databases/%s/envs", uuid), req, &env)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create environment variable for database %s: %w", uuid, err)
|
||||
}
|
||||
return &env, nil
|
||||
}
|
||||
|
||||
// UpdateEnv updates an environment variable for a database
|
||||
func (s *DatabaseService) UpdateEnv(ctx context.Context, dbUUID string, req *models.DatabaseEnvironmentVariableUpdateRequest) (*models.DatabaseEnvironmentVariable, error) {
|
||||
var env models.DatabaseEnvironmentVariable
|
||||
err := s.client.Patch(ctx, fmt.Sprintf("databases/%s/envs", dbUUID), req, &env)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to update environment variable for database %s: %w", dbUUID, err)
|
||||
}
|
||||
return &env, nil
|
||||
}
|
||||
|
||||
// DeleteEnv deletes an environment variable from a database
|
||||
func (s *DatabaseService) DeleteEnv(ctx context.Context, dbUUID, envUUID string) error {
|
||||
err := s.client.Delete(ctx, fmt.Sprintf("databases/%s/envs/%s", dbUUID, envUUID))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete environment variable %s from database %s: %w", envUUID, dbUUID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// BulkUpdateEnvs updates multiple environment variables for a database in a single request
|
||||
func (s *DatabaseService) BulkUpdateEnvs(ctx context.Context, dbUUID string, req *models.DatabaseEnvBulkUpdateRequest) (models.DatabaseEnvBulkUpdateResponse, error) {
|
||||
var response models.DatabaseEnvBulkUpdateResponse
|
||||
err := s.client.Patch(ctx, fmt.Sprintf("databases/%s/envs/bulk", dbUUID), req, &response)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to bulk update environment variables for database %s: %w", dbUUID, err)
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
// ListStorages retrieves all storages for a database
|
||||
func (s *DatabaseService) ListStorages(ctx context.Context, uuid string) ([]models.StorageListItem, error) {
|
||||
var resp models.StoragesResponse
|
||||
err := s.client.Get(ctx, fmt.Sprintf("databases/%s/storages", uuid), &resp)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list storages for database %s: %w", uuid, err)
|
||||
}
|
||||
return models.MergeStorages(resp), nil
|
||||
}
|
||||
|
||||
// CreateStorage creates a new storage for a database
|
||||
func (s *DatabaseService) CreateStorage(ctx context.Context, uuid string, req *models.StorageCreateRequest) error {
|
||||
err := s.client.Post(ctx, fmt.Sprintf("databases/%s/storages", uuid), req, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create storage for database %s: %w", uuid, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateStorage updates a storage for a database
|
||||
func (s *DatabaseService) UpdateStorage(ctx context.Context, uuid string, req *models.StorageUpdateRequest) error {
|
||||
err := s.client.Patch(ctx, fmt.Sprintf("databases/%s/storages", uuid), req, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to update storage for database %s: %w", uuid, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteStorage deletes a storage from a database
|
||||
func (s *DatabaseService) DeleteStorage(ctx context.Context, dbUUID, storageUUID string) error {
|
||||
err := s.client.Delete(ctx, fmt.Sprintf("databases/%s/storages/%s", dbUUID, storageUUID))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete storage %s from database %s: %w", storageUUID, dbUUID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// inferDatabaseType determines the database type from available fields
|
||||
func inferDatabaseType(db *models.Database) string {
|
||||
// Check for PostgreSQL
|
||||
|
||||
@@ -848,6 +848,252 @@ func TestDatabaseService_DeleteBackupExecution(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// --- Environment Variable Tests ---
|
||||
|
||||
func TestDatabaseService_ListEnvs(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "/api/v1/databases/db-uuid-123/envs", r.URL.Path)
|
||||
assert.Equal(t, "GET", r.Method)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`[
|
||||
{"uuid": "env-1", "key": "DB_HOST", "value": "localhost", "is_literal": false},
|
||||
{"uuid": "env-2", "key": "DB_PORT", "value": "5432", "is_literal": true}
|
||||
]`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := api.NewClient(server.URL, "test-token")
|
||||
svc := NewDatabaseService(client)
|
||||
|
||||
envs, err := svc.ListEnvs(context.Background(), "db-uuid-123")
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, envs, 2)
|
||||
assert.Equal(t, "DB_HOST", envs[0].Key)
|
||||
assert.Equal(t, "DB_PORT", envs[1].Key)
|
||||
}
|
||||
|
||||
func TestDatabaseService_ListEnvs_Error(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
_, _ = w.Write([]byte(`{"message":"database not found"}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := api.NewClient(server.URL, "test-token")
|
||||
svc := NewDatabaseService(client)
|
||||
|
||||
envs, err := svc.ListEnvs(context.Background(), "db-uuid-123")
|
||||
require.Error(t, err)
|
||||
assert.Nil(t, envs)
|
||||
assert.Contains(t, err.Error(), "failed to list environment variables")
|
||||
}
|
||||
|
||||
func TestDatabaseService_GetEnv(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "/api/v1/databases/db-uuid-123/envs", r.URL.Path)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`[
|
||||
{"uuid": "env-1", "key": "DB_HOST", "value": "localhost"},
|
||||
{"uuid": "env-2", "key": "DB_PORT", "value": "5432"}
|
||||
]`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := api.NewClient(server.URL, "test-token")
|
||||
svc := NewDatabaseService(client)
|
||||
|
||||
// Find by UUID
|
||||
env, err := svc.GetEnv(context.Background(), "db-uuid-123", "env-2")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "DB_PORT", env.Key)
|
||||
|
||||
// Find by key
|
||||
env, err = svc.GetEnv(context.Background(), "db-uuid-123", "DB_HOST")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "env-1", env.UUID)
|
||||
|
||||
// Not found
|
||||
env, err = svc.GetEnv(context.Background(), "db-uuid-123", "NONEXISTENT")
|
||||
require.Error(t, err)
|
||||
assert.Nil(t, env)
|
||||
assert.Contains(t, err.Error(), "not found")
|
||||
}
|
||||
|
||||
func TestDatabaseService_CreateEnv(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "/api/v1/databases/db-uuid-123/envs", r.URL.Path)
|
||||
assert.Equal(t, "POST", r.Method)
|
||||
|
||||
var req models.DatabaseEnvironmentVariableCreateRequest
|
||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||
assert.Equal(t, "NEW_VAR", req.Key)
|
||||
assert.Equal(t, "new_value", req.Value)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{
|
||||
"uuid": "env-new",
|
||||
"key": "NEW_VAR",
|
||||
"value": "new_value",
|
||||
"comment": "test comment"
|
||||
}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := api.NewClient(server.URL, "test-token")
|
||||
svc := NewDatabaseService(client)
|
||||
|
||||
comment := "test comment"
|
||||
env, err := svc.CreateEnv(context.Background(), "db-uuid-123", &models.DatabaseEnvironmentVariableCreateRequest{
|
||||
Key: "NEW_VAR",
|
||||
Value: "new_value",
|
||||
Comment: &comment,
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "NEW_VAR", env.Key)
|
||||
assert.Equal(t, "new_value", env.Value)
|
||||
assert.Equal(t, "test comment", *env.Comment)
|
||||
}
|
||||
|
||||
func TestDatabaseService_CreateEnv_Error(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusUnprocessableEntity)
|
||||
_, _ = w.Write([]byte(`{"message":"validation failed"}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := api.NewClient(server.URL, "test-token")
|
||||
svc := NewDatabaseService(client)
|
||||
|
||||
env, err := svc.CreateEnv(context.Background(), "db-uuid-123", &models.DatabaseEnvironmentVariableCreateRequest{
|
||||
Key: "NEW_VAR",
|
||||
Value: "new_value",
|
||||
})
|
||||
|
||||
require.Error(t, err)
|
||||
assert.Nil(t, env)
|
||||
assert.Contains(t, err.Error(), "failed to create environment variable")
|
||||
}
|
||||
|
||||
func TestDatabaseService_UpdateEnv(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "/api/v1/databases/db-uuid-123/envs", r.URL.Path)
|
||||
assert.Equal(t, "PATCH", r.Method)
|
||||
|
||||
comment := "updated comment"
|
||||
env := models.DatabaseEnvironmentVariable{
|
||||
UUID: "env-1",
|
||||
Key: "DB_HOST",
|
||||
Value: "newhost",
|
||||
Comment: &comment,
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(env)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := api.NewClient(server.URL, "test-token")
|
||||
svc := NewDatabaseService(client)
|
||||
|
||||
newKey := "DB_HOST"
|
||||
newValue := "newhost"
|
||||
newComment := "updated comment"
|
||||
req := &models.DatabaseEnvironmentVariableUpdateRequest{
|
||||
Key: &newKey,
|
||||
Value: &newValue,
|
||||
Comment: &newComment,
|
||||
}
|
||||
|
||||
result, err := svc.UpdateEnv(context.Background(), "db-uuid-123", req)
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, result)
|
||||
assert.Equal(t, "DB_HOST", result.Key)
|
||||
assert.Equal(t, "newhost", result.Value)
|
||||
assert.Equal(t, "updated comment", *result.Comment)
|
||||
}
|
||||
|
||||
func TestDatabaseService_UpdateEnv_Error(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
_, _ = w.Write([]byte(`{"message":"environment variable not found"}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := api.NewClient(server.URL, "test-token")
|
||||
svc := NewDatabaseService(client)
|
||||
|
||||
newKey := "DB_HOST"
|
||||
newValue := "newhost"
|
||||
result, err := svc.UpdateEnv(context.Background(), "db-uuid-123", &models.DatabaseEnvironmentVariableUpdateRequest{
|
||||
Key: &newKey,
|
||||
Value: &newValue,
|
||||
})
|
||||
|
||||
require.Error(t, err)
|
||||
assert.Nil(t, result)
|
||||
assert.Contains(t, err.Error(), "failed to update environment variable")
|
||||
}
|
||||
|
||||
func TestDatabaseService_DeleteEnv(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "/api/v1/databases/db-uuid-123/envs/env-1", r.URL.Path)
|
||||
assert.Equal(t, "DELETE", r.Method)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := api.NewClient(server.URL, "test-token")
|
||||
svc := NewDatabaseService(client)
|
||||
|
||||
err := svc.DeleteEnv(context.Background(), "db-uuid-123", "env-1")
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestDatabaseService_DeleteEnv_Error(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
_, _ = w.Write([]byte(`{"message":"not found"}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := api.NewClient(server.URL, "test-token")
|
||||
svc := NewDatabaseService(client)
|
||||
|
||||
err := svc.DeleteEnv(context.Background(), "db-uuid-123", "env-1")
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "failed to delete environment variable")
|
||||
}
|
||||
|
||||
func TestDatabaseService_BulkUpdateEnvs(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "/api/v1/databases/db-uuid-123/envs/bulk", r.URL.Path)
|
||||
assert.Equal(t, "PATCH", r.Method)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`[
|
||||
{"uuid": "env-1", "key": "DB_HOST", "value": "localhost"},
|
||||
{"uuid": "env-2", "key": "DB_PORT", "value": "5432"}
|
||||
]`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := api.NewClient(server.URL, "test-token")
|
||||
svc := NewDatabaseService(client)
|
||||
|
||||
req := &models.DatabaseEnvBulkUpdateRequest{
|
||||
Data: []models.DatabaseEnvironmentVariableCreateRequest{
|
||||
{Key: "DB_HOST", Value: "localhost"},
|
||||
{Key: "DB_PORT", Value: "5432"},
|
||||
},
|
||||
}
|
||||
|
||||
result, err := svc.BulkUpdateEnvs(context.Background(), "db-uuid-123", req)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, result, 2)
|
||||
}
|
||||
|
||||
func stringPtr(s string) *string {
|
||||
return &s
|
||||
}
|
||||
@@ -855,3 +1101,124 @@ func stringPtr(s string) *string {
|
||||
func boolPtr(b bool) *bool {
|
||||
return &b
|
||||
}
|
||||
|
||||
func TestDatabaseService_ListStorages(t *testing.T) {
|
||||
hostPath := "/var/data"
|
||||
content := "key: value"
|
||||
resp := models.StoragesResponse{
|
||||
PersistentStorages: []models.PersistentStorage{
|
||||
{
|
||||
ID: 1,
|
||||
UUID: "ps-uuid-1",
|
||||
Name: "data-volume",
|
||||
MountPath: "/data",
|
||||
HostPath: &hostPath,
|
||||
IsPreviewSuffixEnabled: true,
|
||||
},
|
||||
},
|
||||
FileStorages: []models.FileStorage{
|
||||
{
|
||||
ID: 2,
|
||||
UUID: "fs-uuid-1",
|
||||
FsPath: "/app/config.yml",
|
||||
MountPath: "/app/config.yml",
|
||||
Content: &content,
|
||||
IsPreviewSuffixEnabled: false,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "/api/v1/databases/db-uuid-123/storages", r.URL.Path)
|
||||
assert.Equal(t, "GET", r.Method)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(resp)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := api.NewClient(server.URL, "test-token")
|
||||
svc := NewDatabaseService(client)
|
||||
|
||||
result, err := svc.ListStorages(context.Background(), "db-uuid-123")
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, result, 2)
|
||||
assert.Equal(t, "persistent", result[0].Type)
|
||||
assert.Equal(t, "data-volume", result[0].Name)
|
||||
assert.True(t, result[0].IsPreviewSuffixEnabled)
|
||||
assert.Equal(t, "file", result[1].Type)
|
||||
assert.Equal(t, "/app/config.yml", result[1].Name)
|
||||
}
|
||||
|
||||
func TestDatabaseService_CreateStorage(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "/api/v1/databases/db-uuid-123/storages", r.URL.Path)
|
||||
assert.Equal(t, "POST", r.Method)
|
||||
|
||||
var req models.StorageCreateRequest
|
||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||
assert.Equal(t, "persistent", req.Type)
|
||||
assert.Equal(t, "/data", req.MountPath)
|
||||
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := api.NewClient(server.URL, "test-token")
|
||||
svc := NewDatabaseService(client)
|
||||
|
||||
name := "my-volume"
|
||||
req := &models.StorageCreateRequest{
|
||||
Type: "persistent",
|
||||
MountPath: "/data",
|
||||
Name: &name,
|
||||
}
|
||||
|
||||
err := svc.CreateStorage(context.Background(), "db-uuid-123", req)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestDatabaseService_UpdateStorage(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "/api/v1/databases/db-uuid-123/storages", r.URL.Path)
|
||||
assert.Equal(t, "PATCH", r.Method)
|
||||
|
||||
var req models.StorageUpdateRequest
|
||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||
assert.Equal(t, "persistent", req.Type)
|
||||
assert.NotNil(t, req.UUID)
|
||||
assert.Equal(t, "storage-uuid-1", *req.UUID)
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := api.NewClient(server.URL, "test-token")
|
||||
svc := NewDatabaseService(client)
|
||||
|
||||
storageUUID := "storage-uuid-1"
|
||||
name := "new-name"
|
||||
req := &models.StorageUpdateRequest{
|
||||
UUID: &storageUUID,
|
||||
Type: "persistent",
|
||||
Name: &name,
|
||||
}
|
||||
|
||||
err := svc.UpdateStorage(context.Background(), "db-uuid-123", req)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestDatabaseService_DeleteStorage(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "/api/v1/databases/db-uuid-123/storages/storage-uuid-1", r.URL.Path)
|
||||
assert.Equal(t, "DELETE", r.Method)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"message":"Storage deleted."}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := api.NewClient(server.URL, "test-token")
|
||||
svc := NewDatabaseService(client)
|
||||
|
||||
err := svc.DeleteStorage(context.Background(), "db-uuid-123", "storage-uuid-1")
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user