96 Commits

Author SHA1 Message Date
Andras Bacsai 1bac524008 test: replace assert.Error with require.Error across test files
Follows same pattern as da3479c. require.Error halts test immediately
on failure, preventing nil dereference in subsequent assert.Contains calls.
2026-05-02 20:21:31 +02:00
Andras Bacsai da3479c65a test: replace assert.NoError with require.NoError across test files
Aligns remaining test files with the pattern established in 6e80c95.
Using require halts the test immediately on fatal errors instead of
continuing with invalid state.
2026-05-02 18:59:38 +02:00
Andras Bacsai 6e80c95183 test(firewall): use require.NoError to halt on fatal errors 2026-05-02 18:17:28 +02:00
Andras Bacsai a896d5f991 chore(lint): add revive config and fix exhaustive switch defaults
Add revive var-naming rule with skipPackageNameChecks to suppress
package-name lint violations. Add explicit default cases to switch
statements in wireguard/intent.go for exhaustiveness. Upgrade
assert.NoError to require.NoError in firewall tests to halt on error.
2026-05-02 18:14:04 +02:00
Andras Bacsai c6445f9c80 docs(init): update llms-full.txt for intent-scoped subcommands
Reflect bootstrap/extend/upgrade split (replacing apply) and new
--intent flag on plan. Fix trailing-whitespace alignment in intent
tests.
2026-05-02 18:08:31 +02:00
Andras Bacsai d3b6ebffd9 refactor(init): replace apply with intent-scoped bootstrap/extend/upgrade
Split the monolithic `apply` subcommand into three purpose-built commands:
- `bootstrap`: first-time mesh install, keeps interactive alpha gate
- `extend`: adds new hosts to an existing mesh, peer-refresh only on existing hosts
- `upgrade`: bumps agent binaries across fleet, leaves mesh config untouched

Intent filtering lives in `internal/wireguard/intent.go` (ValidateIntent +
filterByIntent). Suppressed actions surface on plan.Skipped so operators see
what would have fired and why.

Also renames broker → scheduler (service + tests) to match its actual role.
2026-04-30 19:57:50 +02:00
Andras Bacsai 483fa075f7 refactor(broker): replace Redis with HTTP-over-UDS transport
Drop Redis as a broker dependency. Broker now exposes an HTTP listener
on a Unix domain socket at /run/coolify/broker.sock instead of reading
from Redis streams.

- Remove RedisInstallCommand and redis.go entirely
- Remove ActionInstallRedis from plan and apply phases
- Drop redisURL param from BrokerServiceUnit; add BrokerUnixSocketPath
  constant; systemd unit gains RuntimeDirectory=coolify (creates socket dir)
- e2e smoke tests switch from redis-cli XADD/LPOP to curl --unix-socket
  against /v1/build/dispatch, /v1/build/result/:id, /v1/build/:id/cancel
2026-04-22 21:10:44 +02:00
Andras Bacsai 92a45c6b0d test(e2e): move live builder tests to coold repo
These tests exercise coold/broker/builder internals over Redis+SSH and
don't touch any coolify-cli code. Moving them to the coold workspace
keeps the test code next to the binaries it validates; coolify-cli's
responsibility stays on provisioning.

Replacement lives under coold/e2e-tests/ as a Rust integration test
crate gated by #[ignore] so default cargo test skips it.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-22 13:42:30 +02:00
Andras Bacsai dea323aa5e test(e2e): Go-native live builder test suite against real servers
Replaces the ad-hoc smoke additions in scripts/e2e-mesh.sh with a
proper Go test harness under ./test/e2e, gated behind the `e2e` build
tag so normal `go test ./...` skips it.

Covers the routing and survival guarantees exercised by hand so far:

  TestPinToBuilderHost                 - pinned dispatch to a
                                         builder-capable host
  TestPinToCooldOnlyHostReturns503     - cap-missing negative
  TestUnknownHostIdReturns503          - unknown host negative
  TestLoadBalancePicksBuilderHost      - host_id=none picks the
                                         builder-capable host
  TestBuildCancelEmitsStageCancel      - cancel via build:cmd delivers
                                         code=499 stage=cancel
  TestCooldRestartAdoptsInFlightBuild  - systemctl restart coold
                                         mid-build; unit survives;
                                         new coold adopts; cancel
                                         flows through the adopted
                                         stream; workdir cleaned

Tests drive Redis via ssh + redis-cli on the central host and assert
on-host state via `buildah images` and `systemctl is-active`. No
broker/coold code is imported — the harness exercises the black-box
contract.

Run:

  BUILDER_HOST=... COOLD_ONLY_HOST=... \
  BUILDER_MGMT=100.64.0.1 COOLD_ONLY_MGMT=100.64.0.2 \
  CENTRAL_HOST=... SSH_KEY=... \
  go test -tags e2e -v -timeout 15m ./test/e2e/...

Live run: 6 tests pass in ~32s against 78.47.80.33 + 159.69.186.231.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-22 12:49:40 +02:00
Andras Bacsai c71f5ef491 Merge pull request #72 from coollabsio/coolify-init-wireguard-mesh
feat(init,firewall): add WireGuard mesh bootstrap and coold firewall client (alpha, v5)
2026-04-22 12:29:51 +02:00
Andras Bacsai eb854da7c8 feat(init): per-host builder enrollment via --builder-hosts
Replace the cluster-wide --enable-builder bool with a per-host subset
controlled by --builder-hosts=<ip>,<ip>. Semantics:
  * --builder-hosts empty + --enable-builder=true: every host in
    --servers gets the builder capability (previous behavior)
  * --builder-hosts non-empty: only listed hosts get the capability;
    --enable-builder is ignored
  * --builder-hosts entries not in --servers are dropped

DesiredMesh.BuilderHostSet() + HasBuilderCap(host) compute the final
set and are used by:
  * phase 3 (install-builder): only on builder-capable hosts
  * phase 5 (JWT caps + coold BuilderConfig): per-host caps claim,
    COOLD_BUILDER_* env only when enabled
  * plan.go (ActionInstallBuilder): planned only for enrolled hosts

Adds 6 unit tests for BuilderHostSet covering empty/all/subset cases
and regenerates llms-full.txt for the new flag.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-22 11:44:52 +02:00
Andras Bacsai 286917cd95 feat(init): pass mgmt + container pools to coold as builder deny nets
When --enable-builder is set, populate BuilderConfig.DenyNets with
the mesh management pool (default 100.64.0.0/16) and the container
pool (default 10.210.0.0/16). coold emits these as
COOLD_BUILDER_DENY_NETS, which the builder adapter expands into
systemd IPAddressDeny entries for every build subprocess.

This keeps the policy in sync with the operator's actual --wg-mgmt-pool
and --container-pool choices without hard-coding RFC1918 defaults.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-22 10:56:35 +02:00
Andras Bacsai 93e3e626e3 feat(init): collapse builder into coold capability, drop phase 6
Mirrors the coold-side refactor that merges builder traffic onto coold's
gRPC stream. The provisioner no longer installs a separate builder
systemd unit, mints a builder JWT, or exposes a second broker listener:

- --install-builder → --enable-builder (capability toggle, not a daemon
  install). --builder-version removed; the builder binary tracks the
  coold release.
- Phase 6 (builder service + builder JWT) deleted. Phase 5 now mints
  the host JWT with a `caps` claim ("coold" always; "builder" when
  enabled) and rewrites the coold unit with COOLD_BUILDER_* env.
- Phase 3 picks up a single extra step when EnableBuilder is true:
  install buildah/git and drop the builder binary at
  /usr/local/bin/builder (short-lived subprocess, no unit file).
- internal/services: BrokerServiceUnit drops the builder bind arg;
  CooldServiceUnit gains an optional *BuilderConfig; builder.go keeps
  only install + workdir constants; jwt.go has a single MintHostJWT.
- e2e-mesh.sh adds steps 9+10 — push build:cmd through Redis and
  assert the resulting image, then dispatch and cancel a slow build
  and assert the scope is killed with stage=cancel.
- llms-full.txt regenerated to reflect the flag rename.

Breaking: pairs with the coold commit that deletes :6444 and
builder.proto. Deploy in lockstep.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-22 09:58:24 +02:00
Andras Bacsai bfb07a5f04 test: strengthen assertions and drop redundant nil check
Switch error-path assertions in resolve_test.go from assert to require
so test stops immediately on unexpected error/success. Remove nil check
in config_test.go — cobra never returns nil from NewConfigCommand.
2026-04-21 22:27:34 +02:00
Andras Bacsai c9b6df3171 refactor(firewall): rename FirewallFlags→Flags, drop error from discoverNamespacesOnHosts
Rename `FirewallFlags` to `Flags` and `bindFirewallFlags` to `bindFlags`
within the firewall package — the `Firewall` prefix is redundant inside
the `firewall` package.

Drop the unused error return from `discoverNamespacesOnHosts`; the
function accumulates per-host errors into `ServerResult` slices and has
no package-level error path, so the third return value was always nil.

Also switches test assertions from `assert.Error/NoError` to
`require.Error/NoError` where the test cannot continue meaningfully on
failure, and adds broker service tests.
2026-04-21 22:01:58 +02:00
Andras Bacsai 346320504c style: align struct literals and promote deps to direct
Promote golang-jwt/jwt/v5, mattn/go-isatty, golang.org/x/crypto, and
golang.org/x/term from indirect to direct dependencies in go.mod.

Fix data races in firewall test fakes by guarding calls slice with sync.Mutex.

Reformat struct literals and map literals across cmd, internal/wireguard,
and internal/firewall for consistent column alignment.
2026-04-21 21:24:21 +02:00
Andras Bacsai 8341802c88 fix(init): wire central host through phase 5 too
Phase 5 was filtering central out via hostsExcluding(), leaving the
coold instance on the central VM without broker env vars and without a
host-jwt. That breaks single-server deploys (only one host, which is
also central) and leaves central's own coold as a standalone API-only
process in fleet mode.

Run phase 5 on desired.Hosts directly so central also receives a JWT
and gets COOLD_BROKER_URL/COOLD_HOST_JWT_PATH injected. Drop
hostsExcluding() since it has no other callers.

Verified end-to-end on a single-server bed: `coolify init apply
--servers X --central X` now produces a working broker <-> coold dispatch
path on the same box.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-21 19:30:32 +02:00
Andras Bacsai 298bd28cd1 feat(init): add --central flag for broker + Redis setup on a host
Adds phases 4 + 5 to `coolify init apply` for bootstrapping the v5 central
transport plane without Laravel:

- Phase 4 (central-only): apt-install Redis, download coolify-broker from
  GitHub releases, generate an EC P-256 JWT keypair under /etc/coolify/, and
  enable coolify-broker.service bound to the wg0 mgmt IP:6443.
- Phase 5 (per non-central host): read jwt.priv from central, mint a 1-year
  ES256 JWT (sub = host wg0 IP), write it to /etc/coolify/host-jwt, rewrite
  coold.service with COOLD_BROKER_URL + COOLD_HOST_JWT_PATH, restart coold.

New service generators under internal/services:
- broker.go — unit, install command, JWT keypair setup
- redis.go  — apt install + enable
- jwt.go    — golang-jwt/jwt/v5 ES256 minting

coold.go gains CooldServiceUnitWithBroker + BrokerConfig so the unit can
carry broker env vars on non-central hosts. DesiredMesh gains CentralHost +
BrokerVersion; empty CentralHost skips phases 4+5 (existing behavior).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-21 17:35:47 +02:00
Andras Bacsai 0380aac05b fix(firewall): key bridge dispatch on ip saddr/daddr, not iifname
Bridge interface names (e.g. "coolify-default-mesh") exceed Linux
IFNAMSIZ=16, so iifname/oifname matching silently fails at the kernel
level. Switch renderBridgeScaffold to accept []*net.IPNet and emit
`ip saddr`/`ip daddr` set rules instead.

Also fix nft chain-declaration order: coolify_intra must be declared
before the forward chain's jump rules reference it, as nft validates
jump targets at add-rule time.

Add `mkdir -p /etc/coolify` before bridge scaffold write so `cat >.tmp`
doesn't ENOENT on fresh hosts where coold hasn't run yet.
2026-04-21 13:27:43 +02:00
Andras Bacsai 8d30292fb6 test(e2e): extend mesh e2e script with intra-host nft bridge checks
Add 3 new test steps that exercise the nft bridge-family deny plane
introduced alongside the iptables FORWARD scaffold:

- Step 4: assert intra-host same-bridge traffic is blocked by default
  (client2-default on host A cannot reach web-default on same bridge).
  Uses raw IP to isolate from DNS-path — DNS to bridge gateway also
  crosses the nft hook.
- Step 5: assert `nft list table bridge coolify_bridge` succeeds on
  both hosts after init apply.
- Step 7: assert intra-host flow opens after coold dual-write (both
  iptables and nft coolify_allow planes receive the rule).
- Step 8: re-run init apply and assert exit 0 — catches
  "chain already exists" regression from non-idempotent nft scaffold.

Also add intra-host allow rule in step 6 (client2-default → web-default
:80 via coold REST), and spawn client2-default on host A in step 2.

Refactor: parameterize assert_blocked/assert_flows with host as first
arg (was hardcoded to SERVER_B); update all existing callsites.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 12:59:22 +02:00
Andras Bacsai 0980f1e363 test(wireguard): add nft bridge scaffold tests and golden fixture
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 12:49:26 +02:00
Andras Bacsai 3a500014b2 feat(init): add nft/bridge-table probes and nft precondition check
Probe 10a/10b detect nft binary availability and the coolify_bridge nft
table on each host. DefaultDenyActive is now gated on BridgeTableExists
so a host with only the iptables chain (no bridge table) triggers
reinstall. BuildPlan validates NftAvailable per host before computing
actions, surfacing a clear error instead of a silent nft shell failure.
Update --skip-default-deny help text to reflect the full scope of the
default-deny scaffold (cross-host and intra-host).
2026-04-21 12:42:52 +02:00
Andras Bacsai 6dc6e0770a fix(firewall): fix nft idempotency, move table delete, use constants, pre-alloc slice
- Pre-delete forward + coolify_intra chains before nft -f to prevent
  "chain already exists" error on second apply (Fix 1)
- Move nft delete table before blanket iptables ACCEPT in mode-A to
  close the window where bridge traffic could be dropped (Fix 2)
- Replace hardcoded nft path/table strings with BridgeTableName,
  BridgeScaffoldPath, BridgeAllowRulesPath constants (Fix 3)
- Pre-allocate ifNames slice with make([]string, 0, len(sorted)) (Fix 4)
2026-04-21 12:40:50 +02:00
Andras Bacsai f70d779d0b feat(firewall): add nft bridge-family scaffold for intra-namespace default-deny
- Add BridgeTableName, BridgeAllowRulesPath, BridgeScaffoldPath consts
- Add namespaces []string param to FirewallServiceUnit and InstallFirewallCommand
- Emit nft bridge table/chain scaffold in default-deny mode; tear it down in permissive mode
- Write /etc/coolify/bridge-fw.nft atomically on apply (delete it in permissive mode)
- Add BridgeTableExists and NftAvailable fields to ServerState
- Order coold after coolify-mesh-fw.service so the bridge scaffold is in place before coold starts
2026-04-21 12:37:09 +02:00
Andras Bacsai 901097e541 feat(init): replace binary upload with GitHub release download
Switch coold/corrosion installation from uploading local binaries via
SSH to downloading from GitHub releases on each remote host.

- Remove --coold-binary / --corrosion-binary flags and elfcheck
- Add --coold-version / --corrosion-version flags (default: nightly)
- Add CooldInstallCommand / CorrosionInstallCommand with arch detection
- nightly tag always re-downloads; pinned tags skip if already installed
- Drop FileSha256 pre-flight checks (no longer needed)
- Add tests for version substitution and arch detection in install cmds
2026-04-21 12:05:51 +02:00
Andras Bacsai ef8e740476 feat(mesh): add multi-namespace support to WireGuard overlay
Introduce per-namespace Podman bridges (`coolify-<ns>-mesh`) and
subnet allocation so a single mesh cluster can carry multiple isolated
container networks carved from a shared `--container-pool`.

- Add `cmd/common/meshnet.go`: shared `MeshNetFlags`, `PodmanNetworkFor`,
  `ValidateNamespace`, and flag-binding helpers used by both `init` and
  `firewall` sub-commands.
- Replace flat `PodmanNetworkName` field on `FirewallFlags` with
  `Namespace` + `AllNamespaces`; `--all-namespaces` fans out discovery
  across every `io.coolify.managed=true` bridge on each host.
- Thread `Namespace` into `AllowRule`, `ComputeID`, and coold REST
  payloads so rules are scoped per namespace.
- Extend WireGuard planner (`internal/wireguard/plan.go`,
  `subnet.go`) to allocate one deterministically-ordered subnet per
  host per namespace; `AllowedIPs` now lists every peer's namespace
  subnets, keeping `wg0.conf` stable across re-runs.
- Pass `COOLD_NAMESPACES=<ns>:<network>:<gw>,...` env to coold so it
  can bind DNS and track rules per namespace.
- Add `scripts/e2e-mesh.sh` for end-to-end multi-namespace smoke test.
- Update CLAUDE.md architecture docs to reflect namespace layout.
2026-04-21 09:15:49 +02:00
Andras Bacsai 95250d32a0 feat(firewall): add --wg-interface flag and thread iface through coold client
Add WGInterface field to FirewallFlags with --wg-interface flag (default
from DefaultWGInterface). Thread iface parameter through CooldApply,
CooldRevoke, CooldList, and CooldListAll so the WireGuard interface name
is configurable instead of hardcoded to wg0.

Also replace hardcoded "coolify-mesh" strings with PodmanNetworkName
where applicable.
2026-04-20 21:14:36 +02:00
Andras Bacsai 7c89c3a6c8 refactor(init): make podman/coold/firewall unconditional, add --skip-default-deny
Remove --podman, --default-deny, --install-coold flags. Podman stack,
coold/corrosion agents, and default-deny iptables scaffold now always
install. --skip-default-deny opts out of firewall scaffold for testing.
2026-04-20 20:51:51 +02:00
Andras Bacsai e5e33b46ae refactor(firewall): replace SSH/iptables with coold REST client
Drop direct iptables manipulation over SSH. Firewall allow/revoke/list
now POST/DELETE/GET against coold's REST API via SSH-bounced curl.

- Add internal/firewall/coold_client.go with CooldApply, CooldRevoke,
  CooldList and per-host bearer-token resolution (reads
  /etc/coolify/api-token over SSH when no override given)
- Delete apply.go, list.go, persist.go — coold owns kernel rules,
  persistence (allow.rules snapshot), and the systemd unit
- Add --coold-port (default 8443) and --coold-token persistent flags
- Update CLAUDE.md and CONTROL_PLANE.md to reflect coold-owned surface
  and outbound WSS/gRPC dial architecture
2026-04-20 17:35:27 +02:00
Andras Bacsai 8f358b3115 docs(firewall): document coolify firewall subcommand and control plane split
Add comprehensive docs for the `coolify firewall` cross-host allow-rule
test harness (alpha, v5) in CLAUDE.md: subcommands, flags, rule lifecycle,
reboot persistence via coolify-mesh-allow.service, and testing patterns.

Update CONTROL_PLANE.md to clarify the three-layer ownership model
(central DB for metadata/audit, coold/CLI for raw kernel rules, init for
chain scaffold), document /etc/coolify/allow.rules file format and the
pre→post-coold handoff strategy (same file format, coold takes over as
writer with no migration step).
2026-04-20 14:26:02 +02:00
Andras Bacsai 84fec60a60 feat(firewall): add cross-host container allow-rule command
Add `coolify firewall` command tree (alpha) for managing iptables
COOLIFY-ALLOW rules across SSH-reachable servers in the coolify-mesh
Podman network.

New subcommands:
- containers: discover running containers across all servers
- list: show installed allow rules
- allow: add src→dst:port allow rule
- revoke: remove an allow rule

Extract shared SSH-mesh flags (--servers, --ssh-key, --ssh-user,
--ssh-port, --concurrency, --ssh-timeout) into cmd/common.SSHMeshFlags
so both `init` and `firewall` reuse the same flag set. Trim duplicated
flag definitions from cmd/init/flags.go accordingly.

Internal packages added:
- internal/firewall/rule.go: AllowRule model + iptables rule rendering
- internal/firewall/discover.go: fan-out container discovery via podman ps
- internal/firewall/list.go: fan-out rule listing via iptables-save
- internal/firewall/apply.go: apply/revoke rules over SSH
- internal/firewall/persist.go: rule persistence helpers
- internal/models/firewall.go: ContainerRow / AllowRuleRow display models

Full unit-test coverage added for all new packages.
2026-04-20 13:53:36 +02:00
Andras Bacsai 0df8f401e1 Merge remote-tracking branch 'origin/v4.x' into coolify-init-wireguard-mesh 2026-04-20 13:36:31 +02:00
github-actions[bot] 594e274b6b chore: bump version to v1.6.2 2026-04-20 10:54:34 +00:00
Andras Bacsai b126ed52c4 docs: update llms-full.txt with preview delete command and alias 2026-04-20 12:50:14 +02:00
Andras Bacsai 4d9b21a662 feat(application): add preview deployment delete command
Add `application previews delete` subcommand to delete PR preview
deployments. Includes service method, CLI command with confirmation
prompt and --force flag, and full test coverage.
2026-04-19 14:55:42 +02:00
Andras Bacsai 6f1b38cf84 feat(dns): bind coold DNS to bridge gateway, disable aardvark-dns
Add --disable-dns to podman network create so netavark never starts
aardvark-dns on the bridge gateway IP:53 — coold owns that socket for
cluster-wide service discovery (CONTROL_PLANE.md §5).

- CooldServiceUnit takes bridgeGatewayIP param; injects
  COOLD_BRIDGE_GATEWAY_IP and COOLD_DNS_ZONE env vars into systemd unit
- podmanNetRecreateCmd drops and recreates network to fix pre-alpha
  drift where dns_enabled=true; phase2 detects via PodmanDNSEnabled
- Add namespace column to service_endpoints schema (reserved for
  per-app isolation / multi-tenant scoping)
- Pass containerAssignments to phase3Server
- Document port 53 conflict handling layers in CONTROL_PLANE.md
2026-04-17 16:24:57 +02:00
Andras Bacsai 1e67e5e3f5 feat(corrosion): detect schema drift and auto-reset DB on schema change
Replace boolean `healthy` column with `state` (liveness) and `health`
(readiness) columns in the CR-SQLite schema.

Add sha256-based schema drift detection: Probe reads the remote schema
file hash into CorrosionSchemaSha256; BuildPlan triggers
ActionWriteCorrosionSchema when hash mismatches; phase3Server stops
corrosion and wipes the DB before writing the new schema so CR-SQLite
can re-bootstrap cleanly.

Fix systemd activation: use `enable` + `restart` instead of
`enable --now` so already-active services pick up new config without a
separate reload step.
2026-04-17 15:07:26 +02:00
Andras Bacsai 67e53195bb feat(init): add corrosion + coold install support
Add --install-coold flag to `coolify init` that uploads and installs the
corrosion (gossip/CRSQLite) and coold (host agent) binaries on each node.

- New internal/services package: pure config generators for corrosion
  TOML, CoolifySchemaSQL, and coold systemd unit; ELF64/aarch64 validator
- wireguard.DesiredMesh gains InstallCoold, binary paths/shas, and port fields
- apply/plan wire pre-flight checks: ELF arch validation + SHA-256 hashing
  before any SSH connection is opened
- SSH client gains helpers used by the wireguard apply layer
2026-04-17 13:56:36 +02:00
Andras Bacsai 76ce28e65f docs(init): document WireGuard mesh bootstrap in CLAUDE.md
Add comprehensive developer reference for `coolify init` — covers
architecture, flags, code layout, key invariants, firewall modes,
cross-host vs intra-host isolation, and future coold boundary.
2026-04-17 12:07:35 +02:00
Andras Bacsai ab44a5a107 docs(control-plane): design embedded DNS service discovery via Corrosion
Replace env-injection placeholder with full DNS-via-coold design.
Covers Corrosion schema, embedded DNS server pseudocode, resolution
flow, backend movement, health/staleness, failure modes, and REST API
surface for the service discovery subsystem.
2026-04-16 19:34:29 +02:00
Andras Bacsai b38f6178b5 docs(control-plane): clarify coold as sole allow-rule owner and persistence model
Replace per-rule systemd dropin approach with coold-owned DB + batch apply.
Adds division of labour table, updated API surface (/v1), nftables set optimization
note, and scale comparison (iptables-restore vs nft vs per-rule).
2026-04-16 15:44:05 +02:00
Andras Bacsai f4d8049867 docs(control-plane): introduce coold as per-host agent boundary
Document coold as the security/audit layer between Coolify control
plane and the podman socket. Add architecture diagram showing the
communication flow. Update all references from direct podman socket
access to coold REST API over wg0.

Also add comment to enablePodmanSocketCmd clarifying the socket stays
Unix-only and is never exposed on TCP.
2026-04-16 15:31:50 +02:00
Andras Bacsai 1dfbc8cb7b feat(init): add WireGuard mesh bootstrap command
Add `coolify init plan` and `coolify init apply` commands for
bootstrapping a WireGuard full-mesh overlay between servers.

- SSH fanout to reconstruct current WireGuard state per host
- Plan engine diffs desired vs actual mesh (peers, IPs, firewall)
- Apply executes plan idempotently over SSH with concurrency control
- Podman install + coolify-mesh bridge network setup
- iptables firewall rules with optional default-deny container policy
- Subnet allocators for mgmt pool (100.64.0.0/16) and container pool (10.210.0.0/16)
- CONTROL_PLANE.md spec for v5 control plane responsibilities
2026-04-16 15:25:44 +02:00
github-actions[bot] ab951a561c chore: bump version to v1.6.1 2026-04-16 10:01:33 +00:00
Andras Bacsai bc36a44f2c docs: add Homebrew tap details to release guide
Document the GoReleaser-managed Homebrew tap, update verification
steps to be non-destructive, and tighten the post-release checklist.
2026-04-16 11:49:42 +02:00
Andras Bacsai d3489a49ce chore: remove conductor setup script and config
Deletes conductor-setup.sh and conductor.json, which were used for
workspace bootstrapping and hot-reload tooling via the conductor runner.
2026-04-16 11:44:58 +02:00
Andras Bacsai e2f0b47579 docs: update release guide for automated version bumping
Document that version is injected at build time via ldflags and that
the post-release update-version CI job handles committing the bump to
internal/version/checker.go. Remove the manual version bump step from
the pre-release checklist.
2026-04-16 11:39:27 +02:00
Andras Bacsai 8e35e61aa0 Merge pull request #70 from YaRissi/fix/format-flag
fix: json format for service commands
2026-04-16 11:34:52 +02:00
YaRissi 0197333e41 fix: json format for service commands 2026-04-12 22:54:12 +02:00
Andras Bacsai 5e2b3d08db feat(docs): generate quick llms.txt and full llms-full.txt
Refactor `coolify docs llms` to emit two AI-oriented artifacts:
- `llms.txt` as a concise operating guide
- `llms-full.txt` as the exhaustive command and flag catalog

Update tests to cover quick/full generation, document both files in README,
and adjust CI to fail when either generated file is out of date.
2026-03-31 17:44:24 +02:00
github-actions[bot] b0eb8dbd15 chore: bump version to v1.6.0 2026-03-30 13:06:16 +00:00
Andras Bacsai c292ba8b42 test(deployment): use assert.NoError in deploy service test
Replace `require.NoError` with `assert.NoError` when reading request body in `deployment_test` to keep assertion style consistent in this test block.
2026-03-30 14:10:34 +02:00
Andras Bacsai 4ae6065ecf Merge pull request #67 from toanalien/feature/add-llms-config
feat(docs): enhance llms.txt with overview, data models, short flags, and defaults
2026-03-30 14:03:36 +02:00
Andras Bacsai 80bc511fd8 feat(docs): derive llms aliases from command tree
Refactor `docs` generation to split intro/body content and build the
`Command Aliases` section dynamically from Cobra commands instead of a
hardcoded list.

Add `cmd/docs_test.go` coverage for:
- alias extraction from nested command trees
- flag rendering with shorthand and default values

Regenerate `llms.txt` to reflect the new alias output and updated docs.
2026-03-30 13:58:54 +02:00
Andras Bacsai b2da3013d2 Merge remote-tracking branch 'origin/v4.x' into feature/add-llms-config 2026-03-30 13:54:32 +02:00
Andras Bacsai 28d54b0df9 feat(deployment): support preview deploy fields via request payload
Add shared deploy flags (`--force`, `--pull-request-id`, `--docker-tag`) to
`deploy uuid|name|batch`, and validate `--docker-tag` against minimum
Coolify version `4.0.0-beta.471`.

Refactor deployment triggering to send a structured `DeployRequest` via
`POST /deploy` instead of GET query parameters, enabling optional payload
fields for preview/tagged deployments.

Update deployment service tests to assert POST method and JSON body, and
document the new flags and example usage in the README.
2026-03-30 13:50:52 +02:00
Andras Bacsai c6378a8280 Merge pull request #68 from coollabsio/66-investigate-json-output-breakage
fix(version): write update notices to stderr for JSON-safe output
2026-03-30 13:36:03 +02:00
Andras Bacsai ce0e8fe9cd fix(version): write update notice to stderr to preserve JSON stdout
Redirect the version update message from stdout to stderr so command JSON output stays machine-readable.

Expand checker tests with shared stdout/stderr capture helpers and assertions to verify:
- update notices are emitted on stderr only
- stdout remains clean during checks and errors
- JSON output to stdout is unaffected when an update is available
2026-03-30 13:35:01 +02:00
toanalien 528b1359aa feat(docs): enhance llms.txt with overview, data models, short flags, and defaults
Add comprehensive header to llms.txt including installation, authentication,
configuration, command aliases, usage examples, API notes, and data model
documentation. Update docs generator to output short flags and default values.
2026-03-27 13:48:40 +07:00
Andras Bacsai eabce9a8e1 feat(homebrew): add Homebrew tap support for CLI distribution
Add Homebrew formula configuration to goreleaser for distributing
coolify-cli via the homebrew-coolify-cli tap. This enables macOS/Linux
users to install the CLI with:

  brew install coollabsio/coolify-cli/coolify-cli

Changes include goreleaser formula config, the HOMEBREW_TAP_GITHUB_TOKEN
in the release workflow, and updated installation instructions in README.
2026-03-23 15:54:17 +01:00
Andras Bacsai f43cd16f6f Merge pull request #64 from coollabsio/coolify-cli-storage-endpoints
feat(storage): add CRUD operations for persistent and file storages
2026-03-23 14:44:30 +01:00
Andras Bacsai e49daeea95 Merge pull request #65 from coollabsio/62-app-env-update
feat(env): allow updating vars by UUID or key identifier
2026-03-23 14:44:19 +01:00
Andras Bacsai ccf578e537 docs(env): reorder parameters in env update commands
Reorganize parameter ordering for consistency across app, database, and
service env update commands. Move string parameters (--key, --value) after
boolean parameters. Simplify --key parameter description.
2026-03-23 14:41:03 +01:00
Andras Bacsai cad379eefb test(service): use assert.True/False helpers for boolean assertions
Replace assert.Equal with more specific boolean assertion helpers
(assert.True and assert.False) for improved readability and
idiomatic Go testing practices.
2026-03-23 14:40:55 +01:00
Andras Bacsai 53ab7b315c feat(storage): require minimum API version 4.0.0-beta.470
Add version check validation across all storage CRUD operations in application,
database, and service commands. This ensures the API client meets the minimum
version requirement before executing storage operations.

Also includes:
- Documentation updates for dockerfile-target-build parameter in llms.txt
- Field alignment formatting fixes in ApplicationCreateRequest structs
2026-03-23 14:38:19 +01:00
Andras Bacsai 8a7d2c20af Merge remote-tracking branch 'origin/v4.x' into coolify-cli-storage-endpoints 2026-03-23 14:32:49 +01:00
Andras Bacsai fcd1a01fb7 Merge remote-tracking branch 'origin/v4.x' into 62-app-env-update 2026-03-23 14:32:48 +01:00
Andras Bacsai f67411de2c feat(storage): add CRUD operations for persistent and file storages
Add comprehensive storage management system for applications, databases, and services:

- Implement storage subcommands (list, create, update, delete) with full API integration
- Add support for both persistent volumes and file-based storage management
- Create Storage model with comprehensive validation for type-specific operations
- Implement ApplicationService, DatabaseService, and ServiceService storage methods
- Add extensive unit tests covering CRUD operations and edge cases
- Integrate storage subcommand into application, database, and service CLI commands
- Add dockerfile-target-build flag support to application creation and update commands

Storage operations support:
- Persistent volumes: create with optional host paths, update mount paths and names
- File storages: create/update with content or file system paths, support directories
- Common features: mount path management, read-only detection, preview suffix toggling
2026-03-23 14:27:21 +01:00
Andras Bacsai 146ce7a7b0 feat(env): allow updating vars by UUID or key identifier
Add support for identifying environment variables by UUID or key name in
update commands, eliminating the requirement to use --key for lookups.
Commands now accept <env_uuid_or_key> as a positional argument to identify
the variable, with --key becoming optional for renaming only. Applied to
app, database, and service env update commands and updated documentation.
2026-03-23 11:16:47 +01:00
Andras Bacsai b661576fc1 Merge pull request #63 from coollabsio/61-expand-application-struct
feat(models): expand Application with extended configuration
2026-03-23 11:07:57 +01:00
Andras Bacsai 0872e48283 feat(models): add ApplicationSettings and expand Application with extended configuration
- Add ApplicationSettings struct for application-level feature flags
- Expand Application model with git, build, health check, and resource limit fields
- Add comprehensive unit tests for Application marshaling/unmarshaling
- Add application.json fixture for testing
2026-03-23 11:03:01 +01:00
Andras Bacsai 303fad333b Merge pull request #61 from Dagnan/fix_format_json_for_database_get_list
fix(database): respect --format flag in database list and get commands
2026-03-23 10:55:28 +01:00
Andras Bacsai 8ee7ec4c0d refactor(docs): use fmt.Fprintf and restrict file permissions to 0600
- Replace sb.WriteString(fmt.Sprintf(...)) with fmt.Fprintf for more idiomatic
  and efficient string formatting
- Tighten output file permissions from 0644 to 0600
- Move pflag to direct dependencies as it's now explicitly used
2026-03-23 10:46:11 +01:00
Andras Bacsai 98f40f03dc Merge pull request #52 from toanalien/feature/add-llms-config
feat: Add llms.txt for AI agent command specification
2026-03-23 10:31:48 +01:00
Andras Bacsai 28521a2ca0 ci: add llms.txt validation workflow job
Adds a new 'llms-txt' job to the test workflow that regenerates llms.txt
and validates it hasn't changed, ensuring the documentation stays in sync
with the CLI implementation.
2026-03-23 10:31:22 +01:00
Andras Bacsai dd4b271faf feat(docs): add llms command to generate machine-readable CLI spec
Add new `docs llms` subcommand that generates a machine-readable llms.txt
file defining all CLI commands and parameters. This enables AI agents to
understand and interact with the CLI programmatically.

- Implement writeLLMsCommand() to recursively document command hierarchy
- Regenerate llms.txt using the new generator for consistency
- Add --output flag to customize the output file path
2026-03-23 10:29:38 +01:00
Andras Bacsai cdc5a1e732 Merge pull request #50 from YaRissi/fix/app-env-sync
fix: app env bulk
2026-03-23 10:28:15 +01:00
Andras Bacsai 7e3639b41a Merge remote-tracking branch 'origin/v4.x' into feature/add-llms-config 2026-03-23 10:24:12 +01:00
Andras Bacsai 6bd783dc8a Merge remote-tracking branch 'origin/v4.x' into v4.x 2026-03-23 10:21:46 +01:00
Andras Bacsai 2ac1d0f869 chore: bump version to v1.5.0 2026-03-23 10:21:28 +01:00
Michel Pigassou f4c4c962ff fix(database): respect --format flag in list and get commands
The database list and get commands were hardcoded to use table format,
ignoring the global --format flag. This prevented users from using
--format=json or --format=pretty output formats.

Changes:
- cmd/database/list.go: read format flag from command
- cmd/database/get.go: read format flag from command
- Both commands now follow the same pattern as other list/get commands
2026-03-22 13:54:34 +01:00
Andras Bacsai 801c2e0b3c Merge pull request #55 from baer95/tablewriter
refactor: improve table output format
2026-03-20 18:14:18 +01:00
Andras Bacsai ea4bec7492 refactor(output): reorganize table formatter and improve test robustness
- Move empty slice check before table writer creation for early exit
- Fix error message typo: "ascii w" → "table"
- Enhance boolean test to dynamically locate data rows instead of hardcoded indices
2026-03-20 18:14:04 +01:00
Andras Bacsai 7e59cd76c3 feat(env): add database environment variable management
Add complete environment variable management for databases with full CRUD
operations, including a new sync command to load variables from .env files.
Support comment field across application, service, and database entities.

- Implement database env service layer with create, read, update, delete, list
- Add sync command for bulk loading and updating from .env files
- Add --comment flag to application and service env commands
- Include comprehensive test coverage for database service operations
- Update to Go 1.24.13
2026-03-20 17:31:42 +01:00
Andras Bacsai 81b9e9cdd0 test(application): fix linter warnings in BulkUpdateEnvs tests
Address unused function parameters and unhandled error return values.
2026-03-19 22:26:15 +01:00
Andras Bacsai fe01e8f9b8 test(application): verify BulkUpdateEnvs request serialization and error handling
- Add request body assertions to validate correct serialization in BulkUpdateEnvs test
- Add new test case for API error handling (500 response)
2026-03-19 22:08:26 +01:00
Andras Bacsai daa2a4cdcb Merge remote-tracking branch 'origin/v4.x' into fix/app-env-sync 2026-03-19 22:06:32 +01:00
Andras Bacsai 1703fd2e52 Merge pull request #60 from coollabsio/next
Next
2026-03-19 22:04:08 +01:00
Andras Bacsai bd65345df8 Merge pull request #51 from YaRissi/fix/service-update-env
fix: update service env command
2026-03-19 22:03:20 +01:00
Andras Bacsai c94e147639 feat(env): enforce minimum version requirement for updates 2026-03-19 22:00:40 +01:00
Andras Bacsai 0ea34284ef fix(env): require key and value flags for updating variables
- Changed app and service env update commands to accept only the resource UUID instead of separate env UUID argument
- Made --key and --value required flags for identifying and updating environment variables
- Removed UUID field from EnvironmentVariableUpdateRequest model
- Updated validation to explicitly require --key and --value instead of "at least one field"
- Changed ServiceEnvBulkUpdateResponse from struct with message to slice of ServiceEnvironmentVariable
- Updated BulkUpdateEnvs return type from pointer to non-pointer
- Updated tests and documentation to reflect new command interface
2026-03-19 21:57:11 +01:00
Bernhard Frick 333ff3c504 refactor: improve table output format
replace text/tabwriter with olekukonko/tablewriter for nicer rendering of output formatted as tables
2026-01-26 21:52:03 +01:00
toanalien 0daae657fb feat: Add llms.txt for AI agent command specification
Introduces a machine-readable llms.txt file that defines all CLI commands and their parameters. This file is generated by analyzing the cobra command definitions and is intended to enable AI agents to understand and interact with the CLI.
2025-12-26 16:17:00 +07:00
YaRissi a93872ee16 fix lint 2025-12-19 18:49:13 +01:00
YaRissi 1bc1a601a8 fix update env command 2025-12-19 18:42:42 +01:00
YaRissi ea3236672b fixing app env sync 2025-12-19 18:09:25 +01:00
142 changed files with 18093 additions and 336 deletions
+1
View File
@@ -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]
+21 -1
View File
@@ -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."
+8
View File
@@ -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
View File
@@ -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"
+327
View File
@@ -9,6 +9,7 @@ This is a CLI tool for interacting with the Coolify API, built with Go using the
### API Specification
This CLI is a client for the Coolify API. The API specification is defined in the OpenAPI schema:
- **Source**: https://github.com/coollabsio/coolify/blob/v4.x/openapi.json
- **Raw JSON**: https://raw.githubusercontent.com/coollabsio/coolify/refs/heads/v4.x/openapi.json
- **Base Path**: `/api/v1/`
- **Authentication**: Bearer token (API tokens from Coolify dashboard at `/security/api-tokens`)
@@ -173,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.**
+759
View File
@@ -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 35**, 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 1030s. 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 (T0T10 — 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
View File
@@ -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
+40 -2
View File
@@ -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,7 +165,14 @@ 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> <env_uuid>` - Update an environment variable
- `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)
- `--is-multiline` - Value is multiline
- `--runtime` - Available at runtime
- `coolify app env delete <app_uuid> <env_uuid>` - Delete an environment variable
- `coolify app env sync <app_uuid>` - Sync environment variables from a .env file
- `--file <path>` - Path to .env file (required)
@@ -232,7 +255,13 @@ 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> <env_uuid>` - Update an environment variable
- `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
- `--runtime` - Available at runtime
- `coolify service env delete <service_uuid> <env_uuid>` - Delete an environment variable
- `coolify service env sync <service_uuid>` - Sync environment variables from a .env file
- `--file <path>` - Path to .env file (required)
@@ -244,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
@@ -408,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-...
+25
View File
@@ -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
}
+2
View File
@@ -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
}
+2
View File
@@ -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
}
+2
View File
@@ -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
}
+2
View File
@@ -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
}
+2
View File
@@ -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
}
+6 -1
View File
@@ -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
}
+31 -12
View File
@@ -12,28 +12,43 @@ import (
func NewUpdateEnvCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "update <app_uuid> <env_uuid>",
Use: "update <app_uuid> <env_uuid_or_key>",
Short: "Update an environment variable",
Long: `Update an existing environment variable. First UUID is the application, second is the specific environment variable to update.`,
Args: cli.ExactArgs(2, "<uuid1> <uuid2>"),
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]
envUUID := args[1]
envIdentifier := args[1]
client, err := cli.GetAPIClient(cmd)
if err != nil {
return fmt.Errorf("failed to get API client: %w", err)
}
req := &models.EnvironmentVariableUpdateRequest{
UUID: envUUID,
// Check minimum version requirement
if err := cli.CheckMinimumVersion(ctx, client, "4.0.0-beta.469"); err != nil {
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
@@ -58,12 +73,15 @@ func NewUpdateEnvCommand() *cobra.Command {
isRuntime, _ := cmd.Flags().GetBool("runtime")
req.IsRuntime = &isRuntime
}
if req.Key == nil && req.Value == nil && req.IsBuildTime == nil && req.IsPreview == nil && req.IsLiteral == nil && req.IsMultiline == nil && req.IsRuntime == nil {
return fmt.Errorf("at least one field must be provided to update")
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)
@@ -74,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
}
+72
View File
@@ -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
}
+96
View File
@@ -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
}
+43
View File
@@ -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
},
}
}
+51
View File
@@ -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)
},
}
}
+117
View File
@@ -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
}
+6
View File
@@ -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")
+105
View File
@@ -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
}
+95
View File
@@ -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
}
+57
View File
@@ -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)
}
-4
View File
@@ -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)
}
+28
View File
@@ -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
}
+79
View File
@@ -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
}
+56
View File
@@ -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
}
+57
View File
@@ -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)
},
}
}
+58
View File
@@ -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)
},
}
}
+145
View File
@@ -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
}
+95
View File
@@ -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
View File
@@ -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
View File
@@ -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
},
}
}
+94
View File
@@ -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
}
+43
View File
@@ -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
},
}
}
+51
View File
@@ -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)
},
}
}
+114
View File
@@ -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
}
+5 -3
View File
@@ -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
}
+46 -1
View File
@@ -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
}
+5 -3
View File
@@ -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
}
+5 -3
View File
@@ -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
View File
@@ -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
}
+118
View File
@@ -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)
}
}
}
+255
View File
@@ -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
}
+247
View File
@@ -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")
}
+105
View File
@@ -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)
}
+106
View File
@@ -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
}
+39
View File
@@ -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
}
+50
View File
@@ -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)
}
}
+82
View File
@@ -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)
}
+129
View File
@@ -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
}
}
+97
View File
@@ -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)
}
+65
View File
@@ -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")
}
+113
View File
@@ -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
}
+76
View File
@@ -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)
}
+200
View File
@@ -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
}
+125
View File
@@ -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")
}
+29
View File
@@ -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...",
})
},
}
}
+51
View File
@@ -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
}
+65
View File
@@ -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
}
+116
View File
@@ -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).")
}
+51
View File
@@ -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
}
+175
View File
@@ -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
}
+68
View File
@@ -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{}))
}
+38
View File
@@ -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
}
+4
View File
@@ -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())
+7 -2
View File
@@ -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
}
+31 -14
View File
@@ -12,29 +12,43 @@ import (
func NewUpdateCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "update <service_uuid> <env_uuid>",
Use: "update <service_uuid> <env_uuid_or_key>",
Short: "Update an environment variable",
Long: `Update an existing environment variable. First UUID is the service, second is the specific environment variable to update.`,
Args: cli.ExactArgs(2, "<uuid1> <uuid2>"),
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]
envUUID := args[1]
envIdentifier := args[1]
client, err := cli.GetAPIClient(cmd)
if err != nil {
return fmt.Errorf("failed to get API client: %w", err)
}
req := &models.ServiceEnvironmentVariableUpdateRequest{
UUID: envUUID,
// Check minimum version requirement
if err := cli.CheckMinimumVersion(ctx, client, "4.0.0-beta.469"); err != nil {
return err
}
// Only set fields that were provided
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{}
// 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
@@ -55,13 +69,15 @@ func NewUpdateCommand() *cobra.Command {
isRuntime, _ := cmd.Flags().GetBool("runtime")
req.IsRuntime = &isRuntime
}
// Check if at least one field is being updated
if req.Key == nil && req.Value == nil && req.IsBuildTime == nil && req.IsLiteral == nil && req.IsMultiline == nil && req.IsRuntime == nil {
return fmt.Errorf("at least one field must be provided to update (--key, --value, --build-time, --is-literal, --is-multiline, or --runtime)")
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)
@@ -72,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
View File
@@ -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
View File
@@ -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)
}
+14
View File
@@ -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
}
+103
View File
@@ -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
}
+43
View File
@@ -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
},
}
}
+51
View File
@@ -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)
},
}
}
+114
View File
@@ -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
}
-57
View File
@@ -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"
-7
View File
@@ -1,7 +0,0 @@
{
"scripts": {
"setup": "./conductor-setup.sh",
"run": "~/go/bin/air"
},
"runScriptMode": "nonconcurrent"
}
+18 -5
View File
@@ -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
+29 -8
View File
@@ -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=
+308
View File
@@ -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)
}
+200
View File
@@ -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) }
+173
View File
@@ -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
}
+129
View File
@@ -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)
}
+56
View File
@@ -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]
}
+45
View File
@@ -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 -17
View File
@@ -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,18 +216,18 @@ 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
type EnvironmentVariableUpdateRequest struct {
UUID string `json:"uuid"`
Key *string `json:"key,omitempty"`
Value *string `json:"value,omitempty"`
IsBuildTime *bool `json:"is_build_time,omitempty"`
@@ -133,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
@@ -165,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"`
@@ -208,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"`
@@ -247,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"`
@@ -277,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"`
@@ -308,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"`
+46
View File
@@ -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:"-"`
+8
View File
@@ -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"`
}
+39
View File
@@ -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"`
}
+48
View File
@@ -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"`
}
+120
View File
@@ -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,
+10 -10
View File
@@ -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,23 +91,24 @@ 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
type ServiceEnvironmentVariableUpdateRequest struct {
UUID string `json:"uuid"`
Key *string `json:"key,omitempty"`
Value *string `json:"value,omitempty"`
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"`
}
// ServiceEnvBulkUpdateRequest represents the request to bulk update service environment variables
@@ -115,6 +117,4 @@ type ServiceEnvBulkUpdateRequest struct {
}
// ServiceEnvBulkUpdateResponse represents the response from service bulk update
type ServiceEnvBulkUpdateResponse struct {
Message string `json:"message,omitempty"`
}
type ServiceEnvBulkUpdateResponse []ServiceEnvironmentVariable
+17 -6
View File
@@ -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
View File
@@ -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)
}
}
+47 -3
View File
@@ -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
+431 -15
View File
@@ -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)
@@ -737,10 +823,13 @@ func TestApplicationService_UpdateEnv(t *testing.T) {
client := api.NewClient(server.URL, "test-token")
svc := NewApplicationService(client)
newKey := "API_KEY"
newValue := "newsecret456"
newComment := "API key for external service"
req := &models.EnvironmentVariableUpdateRequest{
UUID: "env-uuid-1",
Value: &newValue,
Key: &newKey,
Value: &newValue,
Comment: &newComment,
}
result, err := svc.UpdateEnv(context.Background(), "app-uuid-123", req)
@@ -748,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) {
@@ -760,9 +850,10 @@ func TestApplicationService_UpdateEnv_Error(t *testing.T) {
client := api.NewClient(server.URL, "test-token")
svc := NewApplicationService(client)
newKey := "API_KEY"
newValue := "newsecret456"
req := &models.EnvironmentVariableUpdateRequest{
UUID: "env-uuid-1",
Key: &newKey,
Value: &newValue,
}
@@ -1194,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")
}
+102
View File
@@ -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
+367
View File
@@ -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