mirror of
https://github.com/coollabsio/coolify-cli.git
synced 2026-06-20 16:15:04 +00:00
Compare commits
219 Commits
chore/refactor
...
v4.x
| Author | SHA1 | Date | |
|---|---|---|---|
| 1bac524008 | |||
| da3479c65a | |||
| 6e80c95183 | |||
| a896d5f991 | |||
| c6445f9c80 | |||
| d3b6ebffd9 | |||
| 483fa075f7 | |||
| 92a45c6b0d | |||
| dea323aa5e | |||
| c71f5ef491 | |||
| eb854da7c8 | |||
| 286917cd95 | |||
| 93e3e626e3 | |||
| bfb07a5f04 | |||
| c9b6df3171 | |||
| 346320504c | |||
| 8341802c88 | |||
| 298bd28cd1 | |||
| 0380aac05b | |||
| 8d30292fb6 | |||
| 0980f1e363 | |||
| 3a500014b2 | |||
| 6dc6e0770a | |||
| f70d779d0b | |||
| 901097e541 | |||
| ef8e740476 | |||
| 95250d32a0 | |||
| 7c89c3a6c8 | |||
| e5e33b46ae | |||
| 8f358b3115 | |||
| 84fec60a60 | |||
| 0df8f401e1 | |||
| 594e274b6b | |||
| b126ed52c4 | |||
| 4d9b21a662 | |||
| 6f1b38cf84 | |||
| 1e67e5e3f5 | |||
| 67e53195bb | |||
| 76ce28e65f | |||
| ab44a5a107 | |||
| b38f6178b5 | |||
| f4d8049867 | |||
| 1dfbc8cb7b | |||
| ab951a561c | |||
| bc36a44f2c | |||
| d3489a49ce | |||
| e2f0b47579 | |||
| 8e35e61aa0 | |||
| 0197333e41 | |||
| 5e2b3d08db | |||
| b0eb8dbd15 | |||
| c292ba8b42 | |||
| 4ae6065ecf | |||
| 80bc511fd8 | |||
| b2da3013d2 | |||
| 28d54b0df9 | |||
| c6378a8280 | |||
| ce0e8fe9cd | |||
| 528b1359aa | |||
| eabce9a8e1 | |||
| f43cd16f6f | |||
| e49daeea95 | |||
| ccf578e537 | |||
| cad379eefb | |||
| 53ab7b315c | |||
| 8a7d2c20af | |||
| fcd1a01fb7 | |||
| f67411de2c | |||
| 146ce7a7b0 | |||
| b661576fc1 | |||
| 0872e48283 | |||
| 303fad333b | |||
| 8ee7ec4c0d | |||
| 98f40f03dc | |||
| 28521a2ca0 | |||
| dd4b271faf | |||
| cdc5a1e732 | |||
| 7e3639b41a | |||
| 6bd783dc8a | |||
| 2ac1d0f869 | |||
| f4c4c962ff | |||
| 801c2e0b3c | |||
| ea4bec7492 | |||
| 7e59cd76c3 | |||
| 81b9e9cdd0 | |||
| fe01e8f9b8 | |||
| daa2a4cdcb | |||
| 1703fd2e52 | |||
| bd65345df8 | |||
| c94e147639 | |||
| 0ea34284ef | |||
| 333ff3c504 | |||
| 0daae657fb | |||
| a93872ee16 | |||
| 1bc1a601a8 | |||
| ea3236672b | |||
| 4ad94e2d65 | |||
| faa8186301 | |||
| 1eba511544 | |||
| 541f633edc | |||
| 0f23b029f0 | |||
| 780b3674c7 | |||
| 77adbfaebc | |||
| 9215fd537e | |||
| 26c0925854 | |||
| 1f1b187ed2 | |||
| 4af598c213 | |||
| 6ca3b700ce | |||
| 8cf0b71ebf | |||
| 99a40bfa1d | |||
| 188834fd6d | |||
| f9c3b9869a | |||
| 6044a2107e | |||
| 2f9dc6e8d7 | |||
| 1e741309cb | |||
| 51f759c38f | |||
| 51e9ec5ec8 | |||
| e071fd81d4 | |||
| cb0bbfc5cb | |||
| 87b6b8fdf7 | |||
| 3dbe2507f4 | |||
| 1e82217a50 | |||
| 6f33fa00f1 | |||
| d7841b3b5a | |||
| 234f6e9ed6 | |||
| fa86ceb5cc | |||
| 646bf9de36 | |||
| be29a6e05d | |||
| 63a882107a | |||
| 08cd3b8ac7 | |||
| 06f191e9ba | |||
| 7a19a02c02 | |||
| 806a6b9716 | |||
| a18d751ad4 | |||
| 9283717821 | |||
| a44c712163 | |||
| 7c42af6203 | |||
| ed2dbd4947 | |||
| f1301c1dbd | |||
| 8a58cdd72f | |||
| 5ca4c3a8e3 | |||
| 89cd744696 | |||
| 694b4f8e32 | |||
| ef15d013da | |||
| 9f5a44be04 | |||
| 1078a9d3ca | |||
| 9cef9ebee7 | |||
| 51f33cfc5e | |||
| 559d4e2709 | |||
| 94e237e2b1 | |||
| 3dd0dfdce1 | |||
| aac44a0ddb | |||
| a4c96d2803 | |||
| dc9670992a | |||
| c369689131 | |||
| dcf0b39c1b | |||
| 14a3f00c57 | |||
| 86f77716ee | |||
| ef91ed987e | |||
| d8cf7a5986 | |||
| d30b0b90de | |||
| 4a8a659090 | |||
| aa1bda4063 | |||
| 69f2a7ac1f | |||
| ad185a42ee | |||
| 22e34fb72e | |||
| c5b0ad4218 | |||
| 1a6fa9e397 | |||
| b767468b29 | |||
| 6b742d37dd | |||
| 0b2d277e41 | |||
| ac9a486d46 | |||
| ba8f05769f | |||
| 5c799410e5 | |||
| d5dd1b5bdf | |||
| 0d6a2bb1e9 | |||
| 9eba5b97f7 | |||
| 76c1434711 | |||
| f35d299f6e | |||
| 680264ab3f | |||
| 794025aee1 | |||
| 8d6da93aa1 | |||
| 22516fe51e | |||
| fe63c3a3b6 | |||
| 6cad3ba0f7 | |||
| 1bc4625ef8 | |||
| d9494301f6 | |||
| 85cf8f6981 | |||
| c98a8330f8 | |||
| 49e5870c86 | |||
| 705fed3560 | |||
| 88d0b25537 | |||
| 3470b04235 | |||
| eb876e4bda | |||
| 7210b8df76 | |||
| 0546fd1932 | |||
| e2c1c86194 | |||
| 84e7e4921b | |||
| 0acb1fc512 | |||
| eaf9614bcc | |||
| 8ec750ecc6 | |||
| 76396c3c06 | |||
| 3286229a06 | |||
| 884b687947 | |||
| 11f9baafc6 | |||
| f4f628fdae | |||
| ae086bbbbd | |||
| 401f4bf317 | |||
| 095b5a5bc5 | |||
| 002e206c54 | |||
| c865aa7512 | |||
| 3788d6b812 | |||
| 7d23eac444 | |||
| 6aa77a4840 | |||
| 17f1435ce1 | |||
| 485b4c5f6b | |||
| d930e6cf9f | |||
| 519fe69ebf | |||
| 4e0575a156 |
@@ -0,0 +1,46 @@
|
||||
root = "."
|
||||
testdata_dir = "testdata"
|
||||
tmp_dir = "tmp"
|
||||
|
||||
[build]
|
||||
args_bin = []
|
||||
bin = "./coolify"
|
||||
cmd = "go build -o ./coolify ./coolify"
|
||||
delay = 1000
|
||||
exclude_dir = ["assets", "tmp", "vendor", "testdata", ".git", ".conductor"]
|
||||
exclude_file = []
|
||||
exclude_regex = ["_test.go"]
|
||||
exclude_unchanged = false
|
||||
follow_symlink = false
|
||||
full_bin = "echo 'Build complete. Binary: ./coolify/coolify'"
|
||||
include_dir = []
|
||||
include_ext = ["go", "tpl", "tmpl", "html"]
|
||||
include_file = []
|
||||
kill_delay = "0s"
|
||||
log = "build-errors.log"
|
||||
poll = false
|
||||
poll_interval = 0
|
||||
post_cmd = []
|
||||
pre_cmd = []
|
||||
rerun = false
|
||||
rerun_delay = 500
|
||||
send_interrupt = false
|
||||
stop_on_error = true
|
||||
|
||||
[color]
|
||||
app = ""
|
||||
build = "yellow"
|
||||
main = "magenta"
|
||||
runner = "green"
|
||||
watcher = "cyan"
|
||||
|
||||
[log]
|
||||
main_only = false
|
||||
time = false
|
||||
|
||||
[misc]
|
||||
clean_on_exit = false
|
||||
|
||||
[screen]
|
||||
clear_on_rebuild = true
|
||||
keep_scroll = true
|
||||
@@ -0,0 +1,7 @@
|
||||
## Changes
|
||||
|
||||
-
|
||||
|
||||
## Issues & Discussions
|
||||
|
||||
- fix #
|
||||
@@ -10,18 +10,48 @@ jobs:
|
||||
release-cli:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: stable
|
||||
-
|
||||
name: Run GoReleaser
|
||||
uses: goreleaser/goreleaser-action@v5
|
||||
|
||||
- name: Run GoReleaser
|
||||
uses: goreleaser/goreleaser-action@v6
|
||||
with:
|
||||
distribution: goreleaser
|
||||
version: ${{ env.GITHUB_REF_NAME }}
|
||||
args: release --clean
|
||||
workdir: ./
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
HOMEBREW_TAP_GITHUB_TOKEN: ${{ secrets.HOMEBREW_TAP_GITHUB_TOKEN }}
|
||||
|
||||
update-version:
|
||||
needs: [release-cli]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
ref: v4.x
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Update version file
|
||||
run: |
|
||||
TAG=${GITHUB_REF#refs/tags/}
|
||||
echo "Updating version to $TAG"
|
||||
sed -i "s/^\tversion = \".*\"/\tversion = \"$TAG\"/" internal/version/checker.go
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git add internal/version/checker.go
|
||||
git commit -m "chore: bump version to $TAG"
|
||||
git push origin v4.x
|
||||
|
||||
# Move the tag to point to the new commit with updated version
|
||||
git tag -d "$TAG" || true
|
||||
git tag "$TAG"
|
||||
git push origin "refs/tags/$TAG" --force
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
name: Testing CLI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["v4.x"]
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Run gofmt
|
||||
run: diff -u <(echo -n) <(gofmt -d -s .)
|
||||
|
||||
- name: Run golangci-lint
|
||||
uses: golangci/golangci-lint-action@v8
|
||||
with:
|
||||
version: v2.5.0 # pin version for consistency
|
||||
|
||||
test:
|
||||
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: 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:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: 'go.mod'
|
||||
|
||||
- name: Run go mod tidy
|
||||
run: go mod tidy
|
||||
|
||||
- name: Check uncommitted changes
|
||||
run: git diff --exit-code
|
||||
|
||||
- if: failure()
|
||||
run: echo "::error::Check failed, please run 'go mod tidy' and commit the changes."
|
||||
+11
-3
@@ -1,4 +1,12 @@
|
||||
coolify-cli
|
||||
coolify
|
||||
cli
|
||||
config.json
|
||||
/coolify
|
||||
config.json
|
||||
.claude
|
||||
|
||||
# Generated documentation (can be regenerated)
|
||||
man/
|
||||
docs/cli/
|
||||
dist/
|
||||
|
||||
# Test coverage
|
||||
coverage.out
|
||||
@@ -0,0 +1,83 @@
|
||||
version: "2"
|
||||
|
||||
run:
|
||||
timeout: 5m
|
||||
|
||||
linters:
|
||||
enable:
|
||||
- asasalint
|
||||
- asciicheck
|
||||
- bidichk
|
||||
- bodyclose
|
||||
- contextcheck
|
||||
- durationcheck
|
||||
- errchkjson
|
||||
- errorlint
|
||||
- exhaustive
|
||||
- gocheckcompilerdirectives
|
||||
- gochecksumtype
|
||||
- gocritic
|
||||
- gomoddirectives
|
||||
- gomodguard
|
||||
- gosec
|
||||
- gosmopolitan
|
||||
- loggercheck
|
||||
- makezero
|
||||
- musttag
|
||||
- nilerr
|
||||
- nilnesserr
|
||||
- noctx
|
||||
- protogetter
|
||||
- reassign
|
||||
- recvcheck
|
||||
- revive
|
||||
- rowserrcheck
|
||||
- spancheck
|
||||
- sqlclosecheck
|
||||
- testifylint
|
||||
- unparam
|
||||
- zerologlint
|
||||
|
||||
settings:
|
||||
exhaustive:
|
||||
default-signifies-exhaustive: true
|
||||
|
||||
revive:
|
||||
rules:
|
||||
- name: var-naming
|
||||
arguments:
|
||||
- []
|
||||
- []
|
||||
- - skipPackageNameChecks: true
|
||||
|
||||
staticcheck:
|
||||
checks: ["all", "-ST1005", "-S1016"]
|
||||
|
||||
gosec:
|
||||
excludes:
|
||||
- G115
|
||||
|
||||
gosmopolitan:
|
||||
allow-time-local: true
|
||||
|
||||
exclusions:
|
||||
generated: lax
|
||||
presets:
|
||||
- comments
|
||||
- common-false-positives
|
||||
- std-error-handling
|
||||
|
||||
formatters:
|
||||
enable:
|
||||
- gci
|
||||
- goimports
|
||||
|
||||
settings:
|
||||
gci:
|
||||
sections:
|
||||
- standard
|
||||
- default
|
||||
- prefix(github.com/coollabsio)
|
||||
|
||||
exclusions:
|
||||
generated: lax
|
||||
+42
-2
@@ -1,8 +1,19 @@
|
||||
version: 2
|
||||
|
||||
before:
|
||||
hooks:
|
||||
- go mod tidy
|
||||
|
||||
builds:
|
||||
- binary: coolify
|
||||
- id: coolify
|
||||
binary: coolify
|
||||
flags:
|
||||
- -trimpath
|
||||
ldflags:
|
||||
- -s
|
||||
- -w
|
||||
- -X github.com/coollabsio/coolify-cli/internal/version.version={{ .Version }}
|
||||
main: ./coolify/main.go
|
||||
goos:
|
||||
- darwin
|
||||
- linux
|
||||
@@ -11,4 +22,33 @@ builds:
|
||||
- amd64
|
||||
- arm64
|
||||
env:
|
||||
- CGO_ENABLED=0
|
||||
- CGO_ENABLED=0
|
||||
|
||||
checksum:
|
||||
name_template: checksums.txt
|
||||
algorithm: sha256
|
||||
|
||||
archives:
|
||||
- id: coolify-archive
|
||||
ids:
|
||||
- coolify
|
||||
name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}{{ if .Arm }}v{{ .Arm }}{{ end }}"
|
||||
|
||||
format_overrides:
|
||||
- goos: windows
|
||||
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"
|
||||
+587
@@ -0,0 +1,587 @@
|
||||
# Coolify CLI Architecture
|
||||
|
||||
This document describes the architecture and design principles of the Coolify CLI.
|
||||
|
||||
## Overview
|
||||
|
||||
The Coolify CLI is a command-line interface for managing Coolify instances, servers, projects, and deployments. It follows a layered architecture pattern that separates concerns and promotes maintainability.
|
||||
|
||||
## Architecture Layers
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ User Interface │
|
||||
│ (Terminal/Shell) │
|
||||
└────────────────────────┬────────────────────────────────┘
|
||||
│
|
||||
┌────────────────────────▼────────────────────────────────┐
|
||||
│ Command Layer (cmd/) │
|
||||
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
|
||||
│ │ servers │ │ deploy │ │ projects │ ... │
|
||||
│ └──────────┘ └──────────┘ └──────────┘ │
|
||||
│ • CLI parsing & validation │
|
||||
│ • Flag handling │
|
||||
│ • Output formatting │
|
||||
└────────────────────────┬────────────────────────────────┘
|
||||
│
|
||||
┌────────────────────────▼────────────────────────────────┐
|
||||
│ Service Layer (internal/service/) │
|
||||
│ ┌─────────────┐ ┌──────────────┐ ┌──────────────┐ │
|
||||
│ │ServerService│ │DeployService │ │ProjectService│ │
|
||||
│ └─────────────┘ └──────────────┘ └──────────────┘ │
|
||||
│ • Business logic │
|
||||
│ • Request validation │
|
||||
│ • Response transformation │
|
||||
└────────────────────────┬────────────────────────────────┘
|
||||
│
|
||||
┌────────────────────────▼────────────────────────────────┐
|
||||
│ API Client Layer (internal/api/) │
|
||||
│ ┌───────────────────────────────────────────────────┐ │
|
||||
│ │ HTTP Client (api.Client) │ │
|
||||
│ └───────────────────────────────────────────────────┘ │
|
||||
│ • HTTP requests/responses │
|
||||
│ • Authentication (Bearer tokens) │
|
||||
│ • Retry logic │
|
||||
│ • Error handling │
|
||||
└────────────────────────┬────────────────────────────────┘
|
||||
│
|
||||
┌────────────────────────▼────────────────────────────────┐
|
||||
│ Coolify API (External) │
|
||||
│ https://instance.coolify.io/api/v1/ │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Supporting Components
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ Configuration (internal/config/) │
|
||||
│ • Multi-instance management │
|
||||
│ • Default instance selection │
|
||||
│ • Token storage │
|
||||
│ • ~/.config/coolify/config.json │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ Output Formatters (internal/output/) │
|
||||
│ ┌─────────┐ ┌────────┐ ┌─────────┐ │
|
||||
│ │ Table │ │ JSON │ │ Pretty │ │
|
||||
│ └─────────┘ └────────┘ └─────────┘ │
|
||||
│ • Flexible output formats │
|
||||
│ • Sensitive data masking │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ Data Models (internal/models/) │
|
||||
│ • Server, Project, Resource, Deployment │
|
||||
│ • Request/Response structures │
|
||||
│ • JSON marshaling/unmarshaling │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Layer Responsibilities
|
||||
|
||||
### 1. Command Layer (`cmd/`)
|
||||
|
||||
**Purpose**: Handle CLI user interface and interaction
|
||||
|
||||
**Responsibilities**:
|
||||
- Parse command-line arguments and flags
|
||||
- Validate user input
|
||||
- Coordinate with service layer
|
||||
- Format and display output
|
||||
- Handle errors gracefully
|
||||
|
||||
**Key Files**:
|
||||
- `root.go` - Root command, global flags, initialization
|
||||
- `servers.go` - Server management commands
|
||||
- `deploy.go` - Deployment commands
|
||||
- `context.go` - Context (instance) configuration commands
|
||||
- `projects.go` - Project listing and inspection
|
||||
- etc.
|
||||
|
||||
**Example**:
|
||||
```go
|
||||
var serversListCmd = &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List all servers",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
// Get API client
|
||||
client, err := getAPIClient(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Use service layer
|
||||
service := service.NewServerService(client)
|
||||
servers, err := service.List(cmd.Context())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Format and display output
|
||||
formatter, _ := getFormatter(cmd)
|
||||
return formatter.Format(servers)
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Service Layer (`internal/service/`)
|
||||
|
||||
**Purpose**: Implement business logic and coordinate API calls
|
||||
|
||||
**Responsibilities**:
|
||||
- Validate business rules
|
||||
- Coordinate multiple API calls if needed
|
||||
- Transform API responses to CLI-friendly format
|
||||
- Handle service-specific error cases
|
||||
|
||||
**Key Files**:
|
||||
- `server.go` - Server operations
|
||||
- `deployment.go` - Deployment operations
|
||||
- `project.go` - Project operations
|
||||
- `resource.go` - Resource operations
|
||||
- `privatekey.go` - SSH key operations
|
||||
- `domain.go` - Domain operations
|
||||
|
||||
**Example**:
|
||||
```go
|
||||
type ServerService struct {
|
||||
client *api.Client
|
||||
}
|
||||
|
||||
func (s *ServerService) List(ctx context.Context) ([]models.Server, error) {
|
||||
var servers []models.Server
|
||||
err := s.client.Get(ctx, "servers", &servers)
|
||||
return servers, err
|
||||
}
|
||||
```
|
||||
|
||||
### 3. API Client Layer (`internal/api/`)
|
||||
|
||||
**Purpose**: Handle all HTTP communication with Coolify API
|
||||
|
||||
**Responsibilities**:
|
||||
- Construct HTTP requests
|
||||
- Add authentication headers
|
||||
- Retry failed requests with exponential backoff
|
||||
- Parse HTTP responses
|
||||
- Convert HTTP errors to meaningful error messages
|
||||
|
||||
**Key Files**:
|
||||
- `client.go` - HTTP client implementation
|
||||
- `error.go` - API error handling
|
||||
- `options.go` - Client configuration options
|
||||
|
||||
**Example**:
|
||||
```go
|
||||
type Client struct {
|
||||
baseURL string
|
||||
token string
|
||||
httpClient *http.Client
|
||||
retries int
|
||||
timeout time.Duration
|
||||
}
|
||||
|
||||
func (c *Client) Get(ctx context.Context, path string, result interface{}) error {
|
||||
return c.doRequest(ctx, "GET", path, nil, result)
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Configuration Layer (`internal/config/`)
|
||||
|
||||
**Purpose**: Manage CLI configuration and multiple instances
|
||||
|
||||
**Responsibilities**:
|
||||
- Load/save configuration from disk
|
||||
- Manage multiple Coolify instances
|
||||
- Select default instance
|
||||
- Store API tokens securely (file permissions)
|
||||
|
||||
**Key Files**:
|
||||
- `config.go` - Configuration structure and methods
|
||||
- `instance.go` - Instance definition
|
||||
- `loader.go` - File I/O operations
|
||||
|
||||
**Configuration File** (`~/.config/coolify/config.json`):
|
||||
```json
|
||||
{
|
||||
"instances": [
|
||||
{
|
||||
"name": "prod",
|
||||
"fqdn": "https://coolify.example.com",
|
||||
"token": "your-api-token",
|
||||
"default": true
|
||||
},
|
||||
{
|
||||
"name": "staging",
|
||||
"fqdn": "https://staging.coolify.example.com",
|
||||
"token": "staging-token"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 5. Output Layer (`internal/output/`)
|
||||
|
||||
**Purpose**: Format data for display to users
|
||||
|
||||
**Responsibilities**:
|
||||
- Format data as tables, JSON, or pretty-printed JSON
|
||||
- Hide sensitive information unless `--show-sensitive` is used
|
||||
- Handle different data types (slices, structs, primitives)
|
||||
|
||||
**Key Files**:
|
||||
- `formatter.go` - Formatter interface
|
||||
- `table.go` - Table formatting
|
||||
- `json.go` - JSON formatting
|
||||
- `pretty.go` - Pretty JSON formatting
|
||||
|
||||
**Supported Formats**:
|
||||
- `table` - Default, human-readable tables
|
||||
- `json` - Compact JSON for scripting
|
||||
- `pretty` - Indented JSON for debugging
|
||||
|
||||
### 6. Models Layer (`internal/models/`)
|
||||
|
||||
**Purpose**: Define data structures
|
||||
|
||||
**Responsibilities**:
|
||||
- Define API request/response structures
|
||||
- JSON tags for marshaling
|
||||
- Common types and timestamps
|
||||
|
||||
**Key Files**:
|
||||
- `server.go` - Server-related types
|
||||
- `project.go` - Project-related types
|
||||
- `resource.go` - Resource types
|
||||
- `deployment.go` - Deployment types
|
||||
- `common.go` - Shared types
|
||||
|
||||
## Data Flow
|
||||
|
||||
### Example: Listing Servers
|
||||
|
||||
1. **User Input**: `coolify servers list --format=table`
|
||||
|
||||
2. **Command Layer** (`cmd/servers.go`):
|
||||
- Cobra parses the command
|
||||
- `serversListCmd.RunE` is executed
|
||||
- Gets API client using `getAPIClient()`
|
||||
- Creates ServerService instance
|
||||
|
||||
3. **Service Layer** (`internal/service/server.go`):
|
||||
- `ServerService.List()` is called
|
||||
- Validates context (if needed)
|
||||
- Calls API client
|
||||
|
||||
4. **API Client Layer** (`internal/api/client.go`):
|
||||
- Constructs GET request to `/api/v1/servers`
|
||||
- Adds Bearer token authentication
|
||||
- Sends HTTP request
|
||||
- Retries on failure (with backoff)
|
||||
- Parses JSON response
|
||||
|
||||
5. **Response Processing**:
|
||||
- JSON unmarshaled to `[]models.Server`
|
||||
- Returns to service layer
|
||||
- Returns to command layer
|
||||
|
||||
6. **Output Layer** (`internal/output/table.go`):
|
||||
- Command layer creates table formatter
|
||||
- Formatter processes server data
|
||||
- Formats as table with columns
|
||||
- Writes to stdout
|
||||
|
||||
7. **User Output**: Table displayed in terminal
|
||||
|
||||
## Design Patterns
|
||||
|
||||
### 1. Dependency Injection
|
||||
|
||||
Services receive the API client as a constructor parameter:
|
||||
|
||||
```go
|
||||
func NewServerService(client *api.Client) *ServerService {
|
||||
return &ServerService{client: client}
|
||||
}
|
||||
```
|
||||
|
||||
**Benefits**:
|
||||
- Easy to test (can inject mock client)
|
||||
- Clear dependencies
|
||||
- Flexible configuration
|
||||
|
||||
### 2. Strategy Pattern (Output Formatters)
|
||||
|
||||
Different formatters implement the same interface:
|
||||
|
||||
```go
|
||||
type Formatter interface {
|
||||
Format(data interface{}) error
|
||||
}
|
||||
```
|
||||
|
||||
**Benefits**:
|
||||
- Easy to add new formats
|
||||
- Consistent API
|
||||
- Runtime format selection
|
||||
|
||||
### 3. Options Pattern (API Client)
|
||||
|
||||
Client configuration uses functional options:
|
||||
|
||||
```go
|
||||
client := api.NewClient(url, token,
|
||||
api.WithDebug(true),
|
||||
api.WithRetries(5),
|
||||
api.WithTimeout(60 * time.Second),
|
||||
)
|
||||
```
|
||||
|
||||
**Benefits**:
|
||||
- Optional parameters
|
||||
- Clear intent
|
||||
- Backward compatible
|
||||
|
||||
### 4. Error Wrapping
|
||||
|
||||
Errors are wrapped with context at each layer:
|
||||
|
||||
```go
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to list servers: %w", err)
|
||||
}
|
||||
```
|
||||
|
||||
**Benefits**:
|
||||
- Error context preserved
|
||||
- Stack trace maintained
|
||||
- Better debugging
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
### Unit Tests
|
||||
|
||||
Each layer has comprehensive unit tests:
|
||||
|
||||
- **Commands**: Mock services, test flag parsing
|
||||
- **Services**: Mock API client, test business logic
|
||||
- **API Client**: Use `httptest.Server`, test HTTP handling
|
||||
- **Config**: Test file I/O with temp directories
|
||||
- **Output**: Test formatting with buffers
|
||||
|
||||
### Integration Tests
|
||||
|
||||
Test multiple layers together:
|
||||
|
||||
- Commands + Services + Mock API
|
||||
- Config + File System
|
||||
- End-to-end workflows
|
||||
|
||||
### Coverage Goals
|
||||
|
||||
- Overall: 70%+
|
||||
- New features: 80%+
|
||||
- Critical paths: 90%+
|
||||
|
||||
## Configuration Files
|
||||
|
||||
### CLI Configuration
|
||||
|
||||
**Location**: `~/.config/coolify/config.json` (Linux/macOS)
|
||||
**Location**: `%APPDATA%\coolify\config.json` (Windows)
|
||||
|
||||
**Structure**:
|
||||
```json
|
||||
{
|
||||
"instances": [
|
||||
{
|
||||
"name": "prod",
|
||||
"fqdn": "https://coolify.example.com",
|
||||
"token": "your-token",
|
||||
"default": true
|
||||
}
|
||||
],
|
||||
"lastUpdateCheckTime": "2025-01-15T10:30:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
## API Communication
|
||||
|
||||
### Base URL
|
||||
|
||||
All API calls use: `{fqdn}/api/v1/{endpoint}`
|
||||
|
||||
Example: `https://coolify.example.com/api/v1/servers`
|
||||
|
||||
### Authentication
|
||||
|
||||
Bearer token authentication:
|
||||
|
||||
```
|
||||
Authorization: Bearer {token}
|
||||
```
|
||||
|
||||
### Request/Response
|
||||
|
||||
**Content-Type**: `application/json`
|
||||
|
||||
**Request Body** (POST):
|
||||
```json
|
||||
{
|
||||
"name": "my-server",
|
||||
"ip": "192.168.1.100"
|
||||
}
|
||||
```
|
||||
|
||||
**Response Body**:
|
||||
```json
|
||||
{
|
||||
"uuid": "abc123",
|
||||
"name": "my-server",
|
||||
"ip": "192.168.1.100"
|
||||
}
|
||||
```
|
||||
|
||||
### Error Handling
|
||||
|
||||
HTTP errors are converted to CLI-friendly messages:
|
||||
|
||||
- `401` → "Unauthenticated. Check your API token."
|
||||
- `404` → "Resource not found."
|
||||
- `500` → "Server error. Please try again."
|
||||
|
||||
### Retry Logic
|
||||
|
||||
Failed requests are retried with exponential backoff:
|
||||
|
||||
- Attempt 1: Immediate
|
||||
- Attempt 2: Wait 1s
|
||||
- Attempt 3: Wait 2s
|
||||
- Attempt 4: Wait 4s
|
||||
|
||||
Does not retry on 4xx errors (except 429 rate limit).
|
||||
|
||||
## Security Considerations
|
||||
|
||||
### API Token Storage
|
||||
|
||||
- Stored in config file with restricted permissions (0600)
|
||||
- Never logged (even in debug mode)
|
||||
- Masked in output by default (use `-s` to show)
|
||||
|
||||
### Sensitive Data Handling
|
||||
|
||||
- Tokens masked as `********` in output
|
||||
- Use `--show-sensitive` flag to reveal
|
||||
- Debug logs sanitize sensitive data
|
||||
|
||||
### HTTPS
|
||||
|
||||
- All API communication uses HTTPS
|
||||
- Certificate validation enabled
|
||||
|
||||
## Performance Optimizations
|
||||
|
||||
### Concurrent Operations
|
||||
|
||||
Batch deployments run in parallel:
|
||||
|
||||
```go
|
||||
// Deploy multiple resources concurrently
|
||||
var wg sync.WaitGroup
|
||||
for _, name := range names {
|
||||
wg.Add(1)
|
||||
go func(n string) {
|
||||
defer wg.Done()
|
||||
deployResource(n)
|
||||
}(name)
|
||||
}
|
||||
wg.Wait()
|
||||
```
|
||||
|
||||
### Connection Reuse
|
||||
|
||||
HTTP client reuses connections:
|
||||
|
||||
```go
|
||||
c.httpClient = &http.Client{
|
||||
Transport: &http.Transport{
|
||||
MaxIdleConns: 10,
|
||||
IdleConnTimeout: 90 * time.Second,
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### Minimal Dependencies
|
||||
|
||||
- Use Go standard library when possible
|
||||
- Only essential external dependencies
|
||||
- Keep binary size small
|
||||
|
||||
## Extensibility
|
||||
|
||||
### Adding a New Command
|
||||
|
||||
1. Create `cmd/newfeature.go`
|
||||
2. Define Cobra command
|
||||
3. Create service if needed (`internal/service/newfeature.go`)
|
||||
4. Add models if needed (`internal/models/newfeature.go`)
|
||||
5. Register command in `init()`
|
||||
6. Write tests
|
||||
|
||||
### Adding a New Output Format
|
||||
|
||||
1. Create `internal/output/newformat.go`
|
||||
2. Implement `Formatter` interface
|
||||
3. Add format constant
|
||||
4. Update `NewFormatter()` switch
|
||||
|
||||
### Adding API Client Features
|
||||
|
||||
1. Add method to `internal/api/client.go`
|
||||
2. Add tests in `internal/api/client_test.go`
|
||||
3. Use in service layer
|
||||
|
||||
## Build & Release
|
||||
|
||||
### Build Process
|
||||
|
||||
```bash
|
||||
# Local build
|
||||
go build -o coolify ./coolify
|
||||
|
||||
# Install locally
|
||||
go install ./coolify
|
||||
|
||||
# Multi-platform release
|
||||
goreleaser release --clean
|
||||
```
|
||||
|
||||
### Release Artifacts
|
||||
|
||||
- Linux: amd64, arm64
|
||||
- macOS: amd64, arm64 (Apple Silicon)
|
||||
- Windows: amd64
|
||||
|
||||
### Distribution
|
||||
|
||||
- GitHub Releases
|
||||
- Install script: `scripts/install.sh`
|
||||
- Package managers (planned)
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
- [ ] Shell completion improvements
|
||||
- [ ] Interactive mode
|
||||
- [ ] Configuration wizard
|
||||
- [ ] Plugin system
|
||||
- [ ] Telemetry (opt-in)
|
||||
- [ ] Cache layer for frequent queries
|
||||
|
||||
## References
|
||||
|
||||
- [Cobra Documentation](https://cobra.dev/)
|
||||
- [Coolify API Specification](https://github.com/coollabsio/coolify/blob/v4.x/openapi.json)
|
||||
- [Go Project Layout](https://github.com/golang-standards/project-layout)
|
||||
@@ -0,0 +1,670 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
This is a CLI tool for interacting with the Coolify API, built with Go using the Cobra framework. The CLI allows users to manage Coolify instances (both cloud and self-hosted), servers, projects, resources, deployments, domains, and private keys.
|
||||
|
||||
### 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`)
|
||||
|
||||
All commands in this CLI are wrappers around API endpoints defined in the OpenAPI specification. When adding new features or endpoints:
|
||||
1. Check the OpenAPI spec for available endpoints and their request/response schemas
|
||||
2. Ensure the CLI command structure follows the API resource hierarchy
|
||||
3. Match the API's data types and validation rules
|
||||
|
||||
## Architecture
|
||||
|
||||
### Command Structure
|
||||
The codebase follows Cobra's command pattern with a root command and subcommands:
|
||||
- Entry point: `coolify/main.go` calls `cmd.Execute()`
|
||||
- Root command: `cmd/root.go` - contains core utilities (HTTP client, authentication, version checking, config management)
|
||||
- Subcommands: Each command is in its own file in `cmd/`:
|
||||
- `context.go` - manage Coolify context (add, remove, list, set default/token)
|
||||
- `servers.go` - list and get server information
|
||||
- `projects.go` - list projects with environments and applications
|
||||
- `resources.go` - list resources
|
||||
- `deploy.go` - deploy resources
|
||||
- `domains.go` - manage domains
|
||||
- `privatekeys.go` - manage SSH keys
|
||||
- `update.go` - self-update CLI
|
||||
- `version.go` - show CLI version
|
||||
|
||||
### Configuration Management
|
||||
- Uses Viper for configuration management
|
||||
- Config file location: `~/.config/coolify/config.json` (via xdg package)
|
||||
- Config stores multiple instances with tokens, default instance selection
|
||||
- Global flags available: `--token`, `--host`, `--format`, `--show-sensitive`, `--force`, `--debug`
|
||||
|
||||
### API Communication
|
||||
Core API functions in `cmd/root.go`:
|
||||
- `Fetch(url string)` - GET requests
|
||||
- `Post(url, input)` - POST requests
|
||||
- `Delete(url)` - DELETE requests
|
||||
All API calls use `Fqdn + "/api/v1/" + url` pattern with Bearer token authentication
|
||||
|
||||
### Version Management
|
||||
- CLI version tracking with auto-update check (10 minute interval)
|
||||
- API version checking and minimum version enforcement via `CheckMinimumVersion()`
|
||||
- Self-update capability using `go-selfupdate` library
|
||||
|
||||
### Output Formatting
|
||||
Three output modes supported via `--format` flag:
|
||||
- `table` (default) - tabwriter formatted output
|
||||
- `json` - compact JSON
|
||||
- `pretty` - indented JSON
|
||||
|
||||
## Development Commands
|
||||
|
||||
### Build
|
||||
```bash
|
||||
go build -o coolify ./coolify
|
||||
```
|
||||
|
||||
### Run locally
|
||||
```bash
|
||||
go run ./coolify [command]
|
||||
```
|
||||
|
||||
### Test a command
|
||||
```bash
|
||||
go run ./coolify context list
|
||||
go run ./coolify servers list --debug
|
||||
```
|
||||
|
||||
### Install locally
|
||||
```bash
|
||||
go install ./coolify
|
||||
```
|
||||
|
||||
### Run tests
|
||||
```bash
|
||||
# Run all tests (tests are in internal/ directory)
|
||||
go test ./internal/...
|
||||
|
||||
# Run with coverage
|
||||
go test ./internal/... -cover
|
||||
|
||||
# Run with verbose output
|
||||
go test ./internal/... -v
|
||||
|
||||
# Run specific package
|
||||
go test ./internal/api/... -v
|
||||
go test ./internal/service/... -v
|
||||
|
||||
# Run specific test
|
||||
go test ./internal/api -run TestClient_Get_Success -v
|
||||
```
|
||||
|
||||
### Before committing
|
||||
```bash
|
||||
# 1. Run tests
|
||||
go test ./internal/...
|
||||
|
||||
# 2. Check coverage
|
||||
go test ./internal/... -cover
|
||||
|
||||
# 3. Run linter (if available)
|
||||
golangci-lint run
|
||||
|
||||
# 4. Format code
|
||||
go fmt ./...
|
||||
```
|
||||
|
||||
## Release Process
|
||||
|
||||
- Uses GoReleaser for multi-platform builds (Linux, Darwin, Windows on amd64/arm64)
|
||||
- Release workflow: `.github/workflows/release-cli.yml` triggers on GitHub releases
|
||||
- GoReleaser config: `.goreleaser.yml`
|
||||
- Install script: `scripts/install.sh` downloads from GitHub releases
|
||||
|
||||
## Key Patterns
|
||||
|
||||
### Adding a New Command
|
||||
1. Create new file in `cmd/` (e.g., `cmd/newfeature.go`)
|
||||
2. Define command struct with cobra.Command
|
||||
3. Implement Run function with:
|
||||
- Call `CheckDefaultThings(nil)` to validate version and format
|
||||
- Use `Fetch()`, `Post()`, or `Delete()` helpers
|
||||
- Handle JSON unmarshaling into typed structs
|
||||
- Support all three output formats
|
||||
4. Register command in `init()` function: `rootCmd.AddCommand(yourCmd)`
|
||||
|
||||
### API Version Requirements
|
||||
If a command requires a specific Coolify API version, pass it to `CheckDefaultThings()`:
|
||||
```go
|
||||
minimumVersion := "4.0.0"
|
||||
CheckDefaultThings(&minimumVersion)
|
||||
```
|
||||
|
||||
### Handling Sensitive Data
|
||||
- Use `ShowSensitive` flag to control display of tokens/secrets
|
||||
- Default overlay: `SensitiveInformationOverlay = "********"`
|
||||
|
||||
### UUID vs ID Pattern
|
||||
**CRITICAL: Always use UUIDs for user-facing interactions, never internal database IDs.**
|
||||
|
||||
When adding new commands or models:
|
||||
1. **Command Arguments**: Always accept UUIDs as string arguments (e.g., `<resource_uuid>`), never integer IDs
|
||||
2. **API Endpoints**: Construct API paths using UUIDs (e.g., `resources/{uuid}`), not IDs
|
||||
3. **Service Layer**: Methods should accept `uuid string` parameters, not `id int`
|
||||
4. **Table Output**: Hide internal IDs from table output using `table:"-"` struct tags
|
||||
5. **Model Fields**:
|
||||
- Keep `ID int` field with `json:"id" table:"-"` (for API responses, hidden from users)
|
||||
- Always include `UUID string` field with `json:"uuid"` (visible to users)
|
||||
|
||||
**Example model:**
|
||||
```go
|
||||
type Resource struct {
|
||||
ID int `json:"id" table:"-"` // Hidden from table output
|
||||
UUID string `json:"uuid"` // Shown in table output
|
||||
Name string `json:"name"`
|
||||
// ... other fields
|
||||
}
|
||||
```
|
||||
|
||||
**Why UUIDs?**
|
||||
- UUIDs are stable across environments (dev, staging, prod)
|
||||
- IDs are internal implementation details that can change
|
||||
- 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.**
|
||||
|
||||
### Test Coverage Requirements
|
||||
- **Minimum coverage**: 70% for all packages
|
||||
- **New features**: Must have 80%+ coverage
|
||||
- **Bug fixes**: Must include regression tests
|
||||
- **Refactoring**: Must maintain or improve existing coverage
|
||||
|
||||
### Testing Structure
|
||||
```
|
||||
test/
|
||||
├── fixtures/ # Test data, mock API responses
|
||||
├── mocks/ # Mock implementations of interfaces
|
||||
└── integration/ # Integration tests with test server
|
||||
```
|
||||
|
||||
### Test Requirements by Package Type
|
||||
|
||||
#### 1. Command Tests (`cmd/*_test.go`)
|
||||
- Test command parsing and flag handling
|
||||
- Test output formatting (table, json, pretty)
|
||||
- Use mock API client to avoid real API calls
|
||||
- Test error handling and validation
|
||||
- Example:
|
||||
```go
|
||||
func TestServersListCmd(t *testing.T) {
|
||||
// Test with mock client
|
||||
// Verify output format
|
||||
// Test error cases
|
||||
}
|
||||
```
|
||||
|
||||
#### 2. API Client Tests (`internal/api/*_test.go`)
|
||||
- Test request building
|
||||
- Test response parsing
|
||||
- Test error handling (4xx, 5xx status codes)
|
||||
- Test retry logic
|
||||
- Test timeout behavior
|
||||
- **IMPORTANT**: Use `httptest.NewServer()` for mock HTTP responses (NOT real APIs)
|
||||
- All API tests must use local mock servers, never call real Coolify cloud or external APIs
|
||||
|
||||
#### 3. Service Tests (`internal/service/*_test.go`)
|
||||
- Test business logic
|
||||
- Mock API client
|
||||
- Test complex workflows
|
||||
- Test error propagation
|
||||
|
||||
#### 4. Model Tests (`internal/models/*_test.go`)
|
||||
- Test JSON marshaling/unmarshaling
|
||||
- Test validation logic
|
||||
- Test helper methods
|
||||
|
||||
#### 5. Integration Tests (`test/integration/*_test.go`)
|
||||
- Test full command execution
|
||||
- Test with real HTTP server (httptest)
|
||||
- Test config file operations
|
||||
- Test version checking
|
||||
- Can be run with `-short` flag to skip
|
||||
|
||||
### Running Tests
|
||||
|
||||
```bash
|
||||
# Run all tests (tests are in internal/ directory)
|
||||
go test ./internal/...
|
||||
|
||||
# Run with coverage
|
||||
go test ./internal/... -cover
|
||||
|
||||
# Generate coverage report
|
||||
go test ./internal/... -coverprofile=coverage.out
|
||||
go tool cover -html=coverage.out
|
||||
|
||||
# Run with verbose output
|
||||
go test ./internal/... -v
|
||||
|
||||
# Run only unit tests (skip integration)
|
||||
go test ./internal/... -short
|
||||
|
||||
# Run specific package
|
||||
go test ./internal/api/... -v
|
||||
go test ./internal/service/... -v
|
||||
```
|
||||
|
||||
### Test Guidelines
|
||||
|
||||
1. **Table-driven tests**: Use for testing multiple scenarios
|
||||
2. **Test naming**: `TestFunctionName_Scenario_ExpectedBehavior`
|
||||
3. **Subtests**: Use `t.Run()` for related test cases
|
||||
4. **Setup/Teardown**: Use `TestMain()` for package-level setup
|
||||
5. **Parallel tests**: Use `t.Parallel()` when tests are independent
|
||||
6. **Mock dependencies**: Never call real APIs in unit tests
|
||||
7. **Test fixtures**: Store mock API responses in `test/fixtures/`
|
||||
|
||||
### Example Test Structure
|
||||
|
||||
```go
|
||||
func TestServersList(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
response string
|
||||
wantErr bool
|
||||
wantCount int
|
||||
}{
|
||||
{
|
||||
name: "successful list",
|
||||
response: readFixture("servers_list.json"),
|
||||
wantErr: false,
|
||||
wantCount: 3,
|
||||
},
|
||||
{
|
||||
name: "empty list",
|
||||
response: "[]",
|
||||
wantErr: false,
|
||||
wantCount: 0,
|
||||
},
|
||||
{
|
||||
name: "api error",
|
||||
response: `{"error":"unauthorized"}`,
|
||||
wantErr: true,
|
||||
wantCount: 0,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Test implementation
|
||||
})
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### When Adding a New Command
|
||||
|
||||
**CHECKLIST** (must complete ALL items):
|
||||
- [ ] Create command implementation in `cmd/`
|
||||
- [ ] Create corresponding test file in `internal/service/*_test.go` or `internal/api/*_test.go`
|
||||
- [ ] Test all flags and arguments
|
||||
- [ ] Test all output formats (table, json, pretty)
|
||||
- [ ] Test error cases (missing args, API errors, invalid input)
|
||||
- [ ] Add integration test if command has complex workflow
|
||||
- [ ] Update README.md with command documentation
|
||||
- [ ] Run `go test ./internal/...` and ensure all tests pass
|
||||
- [ ] Verify coverage: `go test ./internal/... -cover`
|
||||
|
||||
### CI/CD Integration
|
||||
|
||||
Tests run automatically on:
|
||||
- Every pull request
|
||||
- Every commit to main branch
|
||||
- Before releases
|
||||
|
||||
**Pull requests will be blocked if:**
|
||||
- Any test fails
|
||||
- Coverage drops below 70%
|
||||
- New code has no tests
|
||||
|
||||
## .cursorrules Context
|
||||
|
||||
The project follows Go 1.22+ idioms with standard library preference:
|
||||
- Use `net/http` standard library (no external HTTP frameworks)
|
||||
- Leverage Go 1.22 ServeMux features for any routing needs
|
||||
- Follow RESTful patterns for API interactions
|
||||
- Implement proper error handling with custom types when needed
|
||||
- Use Go's concurrency features appropriately
|
||||
- Write secure, efficient, and maintainable code
|
||||
- **ALWAYS write tests** - see Testing Requirements section above
|
||||
+620
@@ -0,0 +1,620 @@
|
||||
# Contributing to Coolify CLI
|
||||
|
||||
Thank you for your interest in contributing to the Coolify CLI! This document provides guidelines and instructions for contributing to the project.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Getting Started](#getting-started)
|
||||
- [Development Setup](#development-setup)
|
||||
- [Project Architecture](#project-architecture)
|
||||
- [Adding a New Command](#adding-a-new-command)
|
||||
- [Testing Requirements](#testing-requirements)
|
||||
- [Code Style & Conventions](#code-style--conventions)
|
||||
- [Submitting Changes](#submitting-changes)
|
||||
|
||||
## Getting Started
|
||||
|
||||
Before you start contributing:
|
||||
|
||||
1. **Read the [ARCHITECTURE.md](ARCHITECTURE.md)** for detailed architectural guidance
|
||||
2. **Review the [OpenAPI specification](https://github.com/coollabsio/coolify/blob/v4.x/openapi.json)** to understand available API endpoints
|
||||
3. **Check existing issues** to see if your feature/bug is already being worked on
|
||||
4. **Open an issue** to discuss your proposed changes (for large features)
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Go 1.24 or higher
|
||||
- Git
|
||||
|
||||
## Development Setup
|
||||
|
||||
### Clone and Build
|
||||
|
||||
```bash
|
||||
# Fork the repository on GitHub
|
||||
# Clone your fork
|
||||
git clone https://github.com/YOUR_USERNAME/coolify-cli.git
|
||||
cd coolify-cli
|
||||
|
||||
# Build the CLI
|
||||
go build -o coolify ./coolify
|
||||
|
||||
# Install locally
|
||||
go install
|
||||
```
|
||||
|
||||
### Running the CLI
|
||||
|
||||
```bash
|
||||
# Run without installing
|
||||
go run ./coolify [command]
|
||||
|
||||
# Example commands
|
||||
go run ./coolify context list
|
||||
go run ./coolify server list --debug
|
||||
|
||||
# With flags
|
||||
go run ./coolify server list --format json --debug
|
||||
```
|
||||
|
||||
### Project Structure
|
||||
|
||||
```
|
||||
cmd/ # CLI commands (organized by feature)
|
||||
├── root.go # Root command and global flags
|
||||
├── application/ # Application management commands
|
||||
├── context/ # Manage Coolify instances
|
||||
├── server/ # Server management
|
||||
├── project/ # Project management
|
||||
├── database/ # Database management
|
||||
├── deployment/ # Deployment operations
|
||||
├── service/ # Service management
|
||||
└── ...
|
||||
|
||||
internal/ # Internal packages
|
||||
├── api/ # API client (HTTP communication)
|
||||
├── cli/ # CLI utilities (GetAPIClient helper)
|
||||
├── config/ # Configuration management
|
||||
├── models/ # Data models and structs
|
||||
├── output/ # Output formatters (table, json, pretty)
|
||||
├── parser/ # Input parsing utilities
|
||||
├── service/ # Business logic layer
|
||||
└── version/ # Version management
|
||||
|
||||
test/ # Test utilities and fixtures
|
||||
└── fixtures/ # Mock API response data
|
||||
```
|
||||
|
||||
## Project Architecture
|
||||
|
||||
The Coolify CLI follows a **layered architecture**:
|
||||
|
||||
```
|
||||
User → Commands (cmd/) → Services (internal/service/) → API Client (internal/api/) → Coolify API
|
||||
```
|
||||
|
||||
### Layer Responsibilities
|
||||
|
||||
1. **Command Layer** (`cmd/`)
|
||||
- Parse CLI arguments and flags
|
||||
- Call service layer methods
|
||||
- Format output using output formatters
|
||||
|
||||
2. **Service Layer** (`internal/service/`)
|
||||
- Business logic
|
||||
- Coordinate API calls
|
||||
- Transform data
|
||||
|
||||
3. **API Client Layer** (`internal/api/`)
|
||||
- HTTP communication
|
||||
- Retry logic with exponential backoff
|
||||
- Authentication (Bearer tokens)
|
||||
- Error handling
|
||||
|
||||
### Key Dependencies
|
||||
|
||||
- **cobra**: CLI framework
|
||||
- **viper**: Configuration management
|
||||
- **stretchr/testify**: Testing assertions
|
||||
|
||||
## Adding a New Command
|
||||
|
||||
Follow these steps to add a new command:
|
||||
|
||||
### 1. Create Command Directory Structure
|
||||
|
||||
```bash
|
||||
# Create directory for your command
|
||||
mkdir -p cmd/myfeature
|
||||
```
|
||||
|
||||
### 2. Create Parent Command
|
||||
|
||||
Create `cmd/myfeature/myfeature.go`:
|
||||
|
||||
```go
|
||||
package myfeature
|
||||
|
||||
import "github.com/spf13/cobra"
|
||||
|
||||
// NewMyFeatureCommand creates the myfeature parent command
|
||||
func NewMyFeatureCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "myfeature",
|
||||
Aliases: []string{"mf"},
|
||||
Short: "MyFeature related commands",
|
||||
Long: `Manage MyFeature resources.`,
|
||||
}
|
||||
|
||||
// Add subcommands
|
||||
cmd.AddCommand(NewListCommand())
|
||||
cmd.AddCommand(NewGetCommand())
|
||||
// ... more subcommands
|
||||
|
||||
return cmd
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Create Subcommand
|
||||
|
||||
Create `cmd/myfeature/list.go`:
|
||||
|
||||
```go
|
||||
package myfeature
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/coollabsio/coolify-cli/internal/cli"
|
||||
"github.com/coollabsio/coolify-cli/internal/output"
|
||||
"github.com/coollabsio/coolify-cli/internal/service"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func NewListCommand() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List all myfeature resources",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := cmd.Context()
|
||||
|
||||
// Get API client
|
||||
client, err := cli.GetAPIClient(cmd)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get API client: %w", err)
|
||||
}
|
||||
|
||||
// Use service layer
|
||||
svc := service.NewMyFeatureService(client)
|
||||
items, err := svc.List(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to list items: %w", err)
|
||||
}
|
||||
|
||||
// Format output
|
||||
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(items)
|
||||
},
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Create Service Layer
|
||||
|
||||
Create `internal/service/myfeature.go`:
|
||||
|
||||
```go
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/coollabsio/coolify-cli/internal/api"
|
||||
"github.com/coollabsio/coolify-cli/internal/models"
|
||||
)
|
||||
|
||||
type MyFeatureService struct {
|
||||
client *api.Client
|
||||
}
|
||||
|
||||
func NewMyFeatureService(client *api.Client) *MyFeatureService {
|
||||
return &MyFeatureService{client: client}
|
||||
}
|
||||
|
||||
func (s *MyFeatureService) List(ctx context.Context) ([]models.MyFeature, error) {
|
||||
var items []models.MyFeature
|
||||
err := s.client.Get(ctx, "myfeature", &items)
|
||||
return items, err
|
||||
}
|
||||
|
||||
func (s *MyFeatureService) Get(ctx context.Context, uuid string) (*models.MyFeature, error) {
|
||||
var item models.MyFeature
|
||||
err := s.client.Get(ctx, "myfeature/"+uuid, &item)
|
||||
return &item, err
|
||||
}
|
||||
|
||||
func (s *MyFeatureService) Create(ctx context.Context, req models.MyFeatureCreateRequest) (*models.Response, error) {
|
||||
var response models.Response
|
||||
err := s.client.Post(ctx, "myfeature", req, &response)
|
||||
return &response, err
|
||||
}
|
||||
|
||||
func (s *MyFeatureService) Delete(ctx context.Context, uuid string) error {
|
||||
return s.client.Delete(ctx, "myfeature/"+uuid)
|
||||
}
|
||||
```
|
||||
|
||||
### 5. Create Models
|
||||
|
||||
Create `internal/models/myfeature.go`:
|
||||
|
||||
```go
|
||||
package models
|
||||
|
||||
type MyFeature struct {
|
||||
ID int `json:"id" table:"-"` // Hidden from table output
|
||||
UUID string `json:"uuid"` // Shown to users
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Status string `json:"status"`
|
||||
// Add more fields...
|
||||
}
|
||||
|
||||
type MyFeatureCreateRequest struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
```
|
||||
|
||||
**Important**: Always use `UUID` for user-facing identifiers, not database `ID`. Hide `ID` field from table output using `table:"-"` tag.
|
||||
|
||||
### 6. Register Command
|
||||
|
||||
Add your command to `cmd/root.go`:
|
||||
|
||||
```go
|
||||
import (
|
||||
// ... existing imports
|
||||
"github.com/coollabsio/coolify-cli/cmd/myfeature"
|
||||
)
|
||||
|
||||
func init() {
|
||||
// ... existing code
|
||||
rootCmd.AddCommand(myfeature.NewMyFeatureCommand())
|
||||
}
|
||||
```
|
||||
|
||||
### 7. Create Tests
|
||||
|
||||
Create `internal/service/myfeature_test.go`:
|
||||
|
||||
```go
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/coollabsio/coolify-cli/internal/api"
|
||||
"github.com/coollabsio/coolify-cli/internal/models"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestMyFeatureService_List(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "/api/v1/myfeature", r.URL.Path)
|
||||
assert.Equal(t, "GET", r.Method)
|
||||
|
||||
items := []models.MyFeature{
|
||||
{UUID: "uuid-1", Name: "item-1"},
|
||||
{UUID: "uuid-2", Name: "item-2"},
|
||||
}
|
||||
json.NewEncoder(w).Encode(items)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := api.NewClient(server.URL, "test-token")
|
||||
svc := NewMyFeatureService(client)
|
||||
|
||||
items, err := svc.List(cmd.Context())
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, items, 2)
|
||||
assert.Equal(t, "uuid-1", items[0].UUID)
|
||||
}
|
||||
```
|
||||
|
||||
### 8. Update Documentation
|
||||
|
||||
- Add command documentation to `README.md`
|
||||
- Include usage examples and flag descriptions
|
||||
|
||||
## Testing Requirements
|
||||
|
||||
**All code changes MUST include tests.** This is non-negotiable.
|
||||
|
||||
### Coverage Requirements
|
||||
|
||||
- **Minimum coverage**: 70% for all packages
|
||||
- **New features**: 80%+ coverage required
|
||||
- **Bug fixes**: Must include regression tests
|
||||
- **Refactoring**: Must maintain or improve existing coverage
|
||||
|
||||
### Running Tests
|
||||
|
||||
```bash
|
||||
# Run all tests
|
||||
go test ./internal/...
|
||||
|
||||
# Run with coverage
|
||||
go test ./internal/... -cover
|
||||
|
||||
# Run specific package
|
||||
go test ./internal/service/... -v
|
||||
|
||||
# Run specific test
|
||||
go test ./internal/service -run TestServerService_List -v
|
||||
|
||||
# Generate coverage report
|
||||
go test ./internal/... -coverprofile=coverage.out
|
||||
go tool cover -html=coverage.out
|
||||
```
|
||||
|
||||
### Writing Tests
|
||||
|
||||
#### Use Table-Driven Tests
|
||||
|
||||
```go
|
||||
func TestMyFunction(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
want string
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "successful case",
|
||||
input: "test",
|
||||
want: "expected",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "error case",
|
||||
input: "",
|
||||
want: "",
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := MyFunction(tt.input)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("MyFunction() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
if got != tt.want {
|
||||
t.Errorf("MyFunction() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Mock HTTP Requests
|
||||
|
||||
**IMPORTANT**: Never call real APIs in tests. Use `httptest.NewServer()`:
|
||||
|
||||
```go
|
||||
func TestServiceMethod(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Verify request
|
||||
assert.Equal(t, "/api/v1/endpoint", r.URL.Path)
|
||||
assert.Equal(t, "GET", r.Method)
|
||||
|
||||
// Return mock response
|
||||
response := models.MyResponse{Data: "test"}
|
||||
json.NewEncoder(w).Encode(response)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := api.NewClient(server.URL, "test-token")
|
||||
// ... test your service
|
||||
}
|
||||
```
|
||||
|
||||
### Test Guidelines
|
||||
|
||||
- **Test naming**: `TestFunctionName_Scenario_ExpectedBehavior`
|
||||
- **Use subtests**: `t.Run()` for related test cases
|
||||
- **Use testify**: `require.NoError()` for must-pass assertions, `assert.Equal()` for comparisons
|
||||
- **Mock HTTP**: Use `httptest.NewServer()` for all API tests
|
||||
- **Test contexts**: Always pass `context.Background()` in tests
|
||||
- **Test errors**: Verify error messages and types
|
||||
|
||||
## Code Style & Conventions
|
||||
|
||||
### Go Standards
|
||||
|
||||
- Follow standard Go idioms and conventions
|
||||
- Use `gofmt` for code formatting
|
||||
- Run `go vet` to catch common issues
|
||||
- Prefer standard library over external dependencies
|
||||
|
||||
### Project Conventions
|
||||
|
||||
#### API Client Usage
|
||||
|
||||
```go
|
||||
// Create client (usually done via cli.GetAPIClient())
|
||||
client := api.NewClient(baseURL, token, api.WithDebug(true))
|
||||
|
||||
// GET request
|
||||
var result MyStruct
|
||||
err := client.Get(ctx, "endpoint", &result)
|
||||
|
||||
// POST request
|
||||
err := client.Post(ctx, "endpoint", requestBody, &result)
|
||||
|
||||
// DELETE request
|
||||
err := client.Delete(ctx, "endpoint")
|
||||
|
||||
// PATCH request
|
||||
err := client.Patch(ctx, "endpoint", requestBody, &result)
|
||||
```
|
||||
|
||||
#### Service Layer Pattern
|
||||
|
||||
```go
|
||||
type MyService struct {
|
||||
client *api.Client
|
||||
}
|
||||
|
||||
func NewMyService(client *api.Client) *MyService {
|
||||
return &MyService{client: client}
|
||||
}
|
||||
|
||||
func (s *MyService) List(ctx context.Context) ([]models.Item, error) {
|
||||
var items []models.Item
|
||||
err := s.client.Get(ctx, "items", &items)
|
||||
return items, err
|
||||
}
|
||||
```
|
||||
|
||||
#### Error Handling
|
||||
|
||||
```go
|
||||
// Wrap errors with context
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to fetch data: %w", err)
|
||||
}
|
||||
|
||||
// Check and handle specific error types
|
||||
if apiErr, ok := err.(*api.Error); ok {
|
||||
if apiErr.StatusCode == 404 {
|
||||
return fmt.Errorf("resource not found")
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Global Flags
|
||||
|
||||
All commands automatically inherit these global flags:
|
||||
|
||||
- `--format` (table|json|pretty) - Output format
|
||||
- `--show-sensitive` - Show sensitive information
|
||||
- `--debug` - Enable debug mode
|
||||
- `--context` - Use specific context by name
|
||||
- `--token` - Override context token
|
||||
|
||||
Access flags in commands:
|
||||
|
||||
```go
|
||||
format, _ := cmd.Flags().GetString("format")
|
||||
showSensitive, _ := cmd.Flags().GetBool("show-sensitive")
|
||||
debug, _ := cmd.Flags().GetBool("debug")
|
||||
```
|
||||
|
||||
## Submitting Changes
|
||||
|
||||
### Before Committing
|
||||
|
||||
```bash
|
||||
# 1. Format code
|
||||
go fmt ./...
|
||||
|
||||
# 2. Run tests
|
||||
go test ./internal/...
|
||||
|
||||
# 3. Check coverage
|
||||
go test ./internal/... -cover
|
||||
|
||||
# 4. Run vet
|
||||
go vet ./...
|
||||
```
|
||||
|
||||
### Commit Messages
|
||||
|
||||
Write clear, descriptive commit messages following conventional commits format:
|
||||
|
||||
```
|
||||
<type>: <short summary>
|
||||
|
||||
<detailed description>
|
||||
|
||||
<footer>
|
||||
```
|
||||
|
||||
Types: `feat`, `fix`, `docs`, `refactor`, `test`, `chore`
|
||||
|
||||
Example:
|
||||
|
||||
```
|
||||
feat: add server domains list command
|
||||
|
||||
- Implement GET /servers/{uuid}/domains endpoint
|
||||
- Add server domains subcommand
|
||||
- Include tests for domain listing
|
||||
- Update README with new command documentation
|
||||
```
|
||||
|
||||
### Pull Requests
|
||||
|
||||
1. **Fork** the repository
|
||||
2. **Create a branch** from `v4.x`: `git checkout -b feature/my-feature v4.x`
|
||||
3. **Make your changes** with tests
|
||||
4. **Push** to your fork: `git push origin feature/my-feature`
|
||||
5. **Open a pull request** against the `v4.x` branch
|
||||
6. **Describe your changes** clearly in the PR description
|
||||
7. **Link related issues** using "Fixes #123" or "Closes #123"
|
||||
|
||||
### PR Checklist
|
||||
|
||||
- [ ] Tests pass locally (`go test ./internal/...`)
|
||||
- [ ] Code coverage meets requirements (70%+ minimum)
|
||||
- [ ] Code is formatted (`go fmt ./...`)
|
||||
- [ ] README.md updated (if adding new commands)
|
||||
- [ ] CLAUDE.md updated (if changing architecture)
|
||||
- [ ] Commit messages are descriptive
|
||||
- [ ] PR description explains the changes
|
||||
- [ ] All global flags are supported (format, show-sensitive, debug)
|
||||
- [ ] Used UUIDs (not IDs) for resource identifiers
|
||||
|
||||
## Release Process (not for contributors :) )
|
||||
|
||||
Releases are automated using GoReleaser:
|
||||
|
||||
1. Tag a new version: `git tag v1.2.3`
|
||||
2. Push the tag: `git push origin v1.2.3`
|
||||
3. Create a GitHub release
|
||||
4. GoReleaser builds binaries for all platforms automatically
|
||||
|
||||
## Getting Help
|
||||
|
||||
- **Discord**: https://coolify.io/discord
|
||||
- **Issues**: [Open an issue](https://github.com/coollabsio/coolify-cli/issues) for bugs or feature requests
|
||||
- **Architecture**: Read [ARCHITECTURE.md](ARCHITECTURE.md) for detailed design documentation
|
||||
- **API Reference**: See the [OpenAPI specification](https://github.com/coollabsio/coolify/blob/v4.x/openapi.json)
|
||||
- **Code Guidance**: See [CLAUDE.md](CLAUDE.md) for AI assistant guidance
|
||||
|
||||
## License
|
||||
|
||||
By contributing, you agree that your contributions will be licensed under the same license as the project.
|
||||
|
||||
---
|
||||
|
||||
Thank you for contributing to Coolify CLI! 🚀
|
||||
@@ -0,0 +1,759 @@
|
||||
# Coolify v5 Control Plane — Server Management Spec
|
||||
|
||||
This document lists everything the Coolify v5 control plane must implement on top of the host provisioning performed by the `coolify init` subcommand tree (`bootstrap` for first install, `extend` for adding hosts, `upgrade` for bumping agent versions) to fully manage a fleet of mesh-connected hosts.
|
||||
|
||||
## Architecture overview
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
│ Coolify central UI / API │
|
||||
│ - Multi-tenant (cloud) or 1-tenant │
|
||||
│ (self-hosted); same binary │
|
||||
│ - WSS / gRPC bidi stream listener │
|
||||
│ on :443 (public) │
|
||||
│ - Routes commands by host_id │
|
||||
└────────────────────▲────────────────┘
|
||||
│ outbound TLS :443 (WSS / gRPC bidi)
|
||||
│ long-lived, resumable, jittered reconnect
|
||||
│ per-host JWT (issued at enroll)
|
||||
│
|
||||
┌─────────────────┴──────────────────┐
|
||||
│ (per-customer gateway, │
|
||||
│ OPTIONAL — one mesh host │
|
||||
│ proxies N coolds → 1 stream) │
|
||||
└─────────────────▲──────────────────┘
|
||||
│ same stream protocol, over wg0
|
||||
│
|
||||
┌────────────────────┴────────────────┐ ┌─────────────────────────┐
|
||||
│ coold (per-host agent) │ │ /run/podman/podman.sock│
|
||||
│ - Dials central (or gateway) out │──┤ bind-mount, host-only │
|
||||
│ - Local REST on wg0 :8443 │ │ (NEVER on network) │
|
||||
│ (intra-mesh callers: CLI, peers) │ └─────────────┬───────────┘
|
||||
│ - Bearer-token authn (both paths) │ │
|
||||
│ - Talks ONLY to local podman sock │ ▼
|
||||
└─────────────────────────────────────┘ ┌─────────────────────────────┐
|
||||
│ podmand (containers, nets) │
|
||||
└─────────────────────────────┘
|
||||
```
|
||||
|
||||
**Key principles**:
|
||||
|
||||
1. **`/run/podman/podman.sock` is never exposed on TCP.** coold bind-mounts it and proxies a curated API. Central Coolify never touches the raw podman socket directly.
|
||||
2. **coold always dials outbound — never accepts inbound from central or public internet.** One topology for self-hosted and cloud SaaS. Works through any NAT/corp firewall, scales to thousands of hosts per central region (10k+ idle streams are cheap). No "add central to every customer's wg0" — central never joins any mesh.
|
||||
3. **coold still exposes a local REST API on wg0 mgmt IP** for intra-mesh callers only (the `coolify firewall` CLI via SSH-bounce, other coolds in the same mesh, a per-customer gateway if deployed). Never reachable from public internet; wg0 is the only L3 boundary that can hit it.
|
||||
4. **Per-customer gateway (optional)**: for large customers, one host in the mesh runs a stream aggregator that dials central once and proxies commands to the other coolds over wg0. Reduces stream fan-out at central from N-per-customer to 1-per-customer; adds one hop of latency. Transparent to both ends — same protocol each side.
|
||||
|
||||
## What `coolify init bootstrap` already provides
|
||||
|
||||
| Layer | Component | State |
|
||||
|---|---|---|
|
||||
| L3 mesh | WireGuard `wg0` per host with mgmt `/32` from `--wg-mgmt-pool` (default `100.64.0.0/16`) | Installed, configured, active |
|
||||
| L3 mesh | Peer `AllowedIPs = <peer-mgmt>/32, <peer-container>/24` | Configured |
|
||||
| Container runtime | Podman (distro apt) | Installed |
|
||||
| Container runtime | `podman.socket` (rootful, `/run/podman/podman.sock`) | Enabled, active |
|
||||
| Container network | `coolify-mesh` bridge per host with `/24` from `--container-pool` (default `10.210.0.0/16`), gateway `.1` | Created |
|
||||
| Routing | `net.ipv4.ip_forward=1` (persisted via `/etc/sysctl.d/99-coolify-mesh.conf`) | Enabled |
|
||||
| Firewall (mode A — `--podman` only) | `coolify-mesh-fw.service` with FORWARD ACCEPT for container subnet + POSTROUTING RETURN to skip podman MASQUERADE on wg0 | Active |
|
||||
| Firewall (mode B — `--default-deny`) | `COOLIFY-INTRA` chain (ESTABLISHED/RELATED accept → COOLIFY-ALLOW → DROP), FORWARD jumps for `-s/-d <container-subnet>`, blanket ACCEPT removed | Active when set |
|
||||
| Allow chain | `COOLIFY-ALLOW` (empty filter chain) | Created, ready for runtime rules |
|
||||
|
||||
Each host has a stable `(mgmt-ip, container-subnet)` pair. The bootstrap is idempotent — re-running `apply` only changes what drifted.
|
||||
|
||||
---
|
||||
|
||||
## What v5 control plane MUST implement
|
||||
|
||||
### 1. Inventory & state sync
|
||||
|
||||
- **Discovery**: query each host's `podman.socket` (over wg0 mgmt IP) for: containers, networks, volumes, images, system stats.
|
||||
- **Drift detection**: periodically reconcile desired state (Coolify DB) against actual (podman API). Re-converge or alert.
|
||||
- **Mesh join/leave**: when a host is added or removed from the cluster:
|
||||
- Add → invoke `coolify init extend --servers <full list> --new-hosts <new host>` (installs the new host end-to-end, regenerates wg0 config on every existing peer with the new mgmt IP + namespace `/24`s, leaves agent binaries on existing hosts untouched).
|
||||
- Remove → not supported by a first-class subcommand today. Documented workaround for alpha: tear the host out-of-band (stop services, drop it from DNS) and re-run `coolify init bootstrap` with the reduced `--servers` list on a maintenance window; a dedicated `remove-host` flow is a follow-up.
|
||||
|
||||
### 2. Container lifecycle
|
||||
|
||||
Every container op is a command sent over coold's outbound stream (central → coold) or a local REST call on coold's wg0 listener (intra-mesh → coold). coold executes the command against the local `/run/podman/podman.sock` Unix socket and streams results back.
|
||||
|
||||
- Create container with `--network coolify-mesh` and explicit `--ip` from the host's `/24`.
|
||||
- Reserve container IPs in the control plane DB. Allocator skips `.1` (bridge gateway), reserves `.2` for coold itself, `.3-.254` for app containers.
|
||||
- Start, stop, restart, remove.
|
||||
- Stream logs via `/containers/{id}/logs?follow=true` (coold relays podman API frames over the open control stream).
|
||||
- Health checks via `/containers/{id}/healthcheck/run`.
|
||||
- Resource limits, env vars, mounts, volumes, secrets — all standard podman API surfaced through coold.
|
||||
|
||||
#### coold is a primitive proxy, not an app brain
|
||||
|
||||
coold follows the **kubelet analogue**: it knows containers, images, volumes, networks, iptables, and Corrosion writes. It does **not** know apps, compose, Dockerfiles, buildpacks, or Nixpacks. Central Coolify is the apiserver+controllers: it parses app-level config and compiles it into a sequence of primitive ops streamed to coold.
|
||||
|
||||
Test for "should this live in coold?": could a second orchestrator (a Nomad-style competitor) reuse this coold with a different app model? If yes → coold. If no → central.
|
||||
|
||||
#### Wire surface (enumerable)
|
||||
|
||||
Same endpoint set on both transports (outbound stream from central, local REST on wg0 for intra-mesh callers). New verbs require a coold release — there is no `/podman/raw` passthrough.
|
||||
|
||||
```
|
||||
# Images
|
||||
POST /api/v1/images/pull {ref, auth?} -> {digest}
|
||||
GET /api/v1/images -> [{ref, digest, size}]
|
||||
DELETE /api/v1/images/{ref}
|
||||
|
||||
# Containers (filtered podman surface)
|
||||
POST /api/v1/containers <create spec> -> {id}
|
||||
POST /api/v1/containers/{id}/start
|
||||
POST /api/v1/containers/{id}/stop {timeout?}
|
||||
POST /api/v1/containers/{id}/restart
|
||||
DELETE /api/v1/containers/{id} {force?}
|
||||
GET /api/v1/containers/{id} (inspect)
|
||||
GET /api/v1/containers/{id}/logs?follow=true (streamed)
|
||||
POST /api/v1/containers/{id}/exec {cmd, tty?} (streamed)
|
||||
POST /api/v1/containers/{id}/healthcheck/run
|
||||
|
||||
# Volumes
|
||||
POST /api/v1/volumes {name, driver, labels}
|
||||
DELETE /api/v1/volumes/{name}
|
||||
GET /api/v1/volumes/{name}
|
||||
|
||||
# Networks (bootstrap creates coolify-mesh; extra per-app nets created here)
|
||||
POST /api/v1/networks {name, driver, options, labels}
|
||||
DELETE /api/v1/networks/{name}
|
||||
GET /api/v1/networks
|
||||
|
||||
# Firewall (coold = sole writer)
|
||||
POST /api/v1/firewall/allow {src, dst, proto?, port?} -> {id}
|
||||
DELETE /api/v1/firewall/allow/{id}
|
||||
GET /api/v1/firewall/allow
|
||||
|
||||
# Service endpoints (Corrosion writer; used by central to register deploys)
|
||||
POST /api/v1/services/register
|
||||
DELETE /api/v1/services/{id}/endpoints/{container_id}
|
||||
GET /api/v1/services/{id}/endpoints
|
||||
|
||||
# DNS (diagnostics)
|
||||
GET /api/v1/dns/lookup/{name}
|
||||
GET /api/v1/dns/stats
|
||||
|
||||
# Host facts (read-only; central scrapes these for observability + scheduling)
|
||||
GET /api/v1/host/info (podman info, kernel, wg state, load)
|
||||
GET /api/v1/host/containers (podman ps -a)
|
||||
GET /api/v1/host/stats (podman stats snapshot)
|
||||
```
|
||||
|
||||
**Deny filter on `POST /containers`** (defense-in-depth even though central is trusted):
|
||||
- Block `--privileged`, `--cap-add=SYS_ADMIN/NET_ADMIN` unless host is marked `allow_privileged=true`.
|
||||
- Block host-path bind mounts outside a configurable allowlist (default: none).
|
||||
- Block host netns (`--net=host`) unless the container is coold itself.
|
||||
|
||||
Anything not above is not coold's job. No `/apps`, `/deployments`, `/compose`, `/build`, `/podman/raw`. coold does not parse compose, Dockerfiles, buildpacks, or any app-level config — central compiles these into sequences of the primitive ops above and streams them down.
|
||||
|
||||
#### Networks
|
||||
|
||||
Default = shared `coolify-mesh` bridge. Containers get `.coolify.internal` DNS + flat L3 across the mesh. Users may define extra podman networks per app (docker-compose `networks:` style) via `POST /networks` + container attach on create. Central compiles compose into network-create + container-attach primitives.
|
||||
|
||||
#### coold deployment
|
||||
|
||||
coold runs as a privileged container on each host (or as a host systemd service). `coolify init bootstrap` puts it in place at install time (and `coolify init upgrade` bumps its version later): binary, systemd unit with `COOLD_API_BIND=<wg0-mgmt-ip>:8443`, random per-host bearer token at `/etc/coolify/api-token` (mode 0600), outbound stream config written atomically to `/etc/coolify/coold.env`.
|
||||
|
||||
Reference container spec (equivalent to systemd-service deployment):
|
||||
```bash
|
||||
podman run -d --name coold --restart=always \
|
||||
--network coolify-mesh --ip 10.210.X.2 \
|
||||
-v /run/podman/podman.sock:/run/podman/podman.sock \
|
||||
-v /etc/coolify/coold:/etc/coolify/coold:ro \
|
||||
--security-opt label=disable \
|
||||
-p 100.64.0.X:8443:8443 \
|
||||
ghcr.io/coollabs/coold:latest
|
||||
```
|
||||
|
||||
- **Outbound stream**: coold dials `wss://<central-host>/v1/agent` (or gRPC bidi) on start, presenting its per-host JWT. Central routes commands to it by host id over the open stream. Stream is the primary control channel for both self-hosted and cloud SaaS — same code path, same binary.
|
||||
- **Local REST on wg0 mgmt IP (`100.64.0.X:8443`)**: accepts intra-mesh callers only (the `coolify firewall` CLI via SSH-bounce, other coolds in the same mesh, a per-customer gateway). Not reachable from public internet — wg0 is the L3 boundary. Bearer-token auth on every request.
|
||||
- **No inbound from central**: central never dials coold. All mutations arrive over the coold-initiated stream; no `COOLIFY-ALLOW` rule for "central → host:8443" needed. Works through NAT/corp firewalls.
|
||||
|
||||
#### Control channel transport (stream)
|
||||
|
||||
Two candidates; spec-time decision, not per-host:
|
||||
|
||||
| Option | Pros | Cons |
|
||||
|---|---|---|
|
||||
| **gRPC bidi stream over HTTP/2** *(chosen)* | typed Protobuf schemas, native server-streaming for logs/exec, versionable wire | stricter proxy requirements (some corp proxies still mangle HTTP/2); larger runtime |
|
||||
| WebSocket (WSS over :443) *(fallback)* | traverses every proxy, tiny overhead, libs everywhere | framing is custom-on-top; manual request/response correlation |
|
||||
|
||||
**Decision: gRPC bidi + Protobuf.** Typed schemas + native server-streaming for logs and exec outweigh the proxy risk; WSS remains the documented fallback if gRPC-through-proxy issues show up in the field. Both run on :443, so customer-side egress rules stay unchanged either way.
|
||||
|
||||
#### Enrollment
|
||||
|
||||
coold registers once at install using a one-time token from central:
|
||||
|
||||
```bash
|
||||
coolify init bootstrap \
|
||||
--central-url https://cloud.coolify.io \
|
||||
--enroll-token <one-time-hex>
|
||||
```
|
||||
|
||||
1. coold POSTs `(host_id, wg0_mgmt_ip, container_subnet, enroll_token)` to `https://<central>/v1/enroll`.
|
||||
2. Central validates the enroll token (scoped to a tenant, single-use, short TTL) and issues a long-lived per-host JWT + TLS-pinned central cert. Response stored in `/etc/coolify/coold.env` (mode 0600).
|
||||
3. coold burns the enroll token and switches to JWT for the persistent stream.
|
||||
4. Central revokes by invalidating the JWT in its own DB; next stream reconnect fails auth and the host is quarantined until re-enrolled.
|
||||
|
||||
#### Reconnect + fleet-restart storms
|
||||
|
||||
Single-central-restart would otherwise trigger simultaneous reconnects from every host. Mitigations:
|
||||
|
||||
- **Jittered backoff**: exponential from 1s up to 60s with full jitter. 10k hosts reconnecting spread across ~minutes, not seconds.
|
||||
- **Resumable streams**: stream carries a monotonic `last_seq` per host so central can replay missed commands after reconnect without central-side queueing beyond an in-memory ring buffer.
|
||||
- **Region sharding**: DNS round-robin or geo-steering across multiple central stream gateways; each gateway holds O(10k) streams. Stateful routing via consistent-hashing on host_id so a host lands on the same gateway across reconnects (cache affinity).
|
||||
|
||||
#### Per-customer gateway (optional)
|
||||
|
||||
For customers with 50+ hosts, one designated mesh host runs a **gateway mode coold** (same binary, different role):
|
||||
|
||||
- Dials central like any other coold.
|
||||
- Accepts incoming streams from its peer coolds over wg0 (they dial `wss://<gateway-mgmt-ip>:8443/v1/agent-peer` instead of central).
|
||||
- Relays commands down, responses up. Maintains O(hosts-in-mesh) inbound streams + 1 outbound to central.
|
||||
|
||||
Saves N-1 WAN streams at central per customer; costs one hop of latency + one more thing to keep alive. Opt-in via `coolify init bootstrap --gateway-for-mesh` on the chosen host; peers get `--via-gateway <gateway-mgmt-ip>` at install.
|
||||
|
||||
### 3. Network policy (firewall)
|
||||
|
||||
When host has `--default-deny` enabled, **all cross-host container traffic is dropped by default**. The control plane decides who talks to whom.
|
||||
|
||||
#### Division of labour: bootstrap vs coold vs central
|
||||
|
||||
| Layer | Owner | Responsibility |
|
||||
|---|---|---|
|
||||
| Chain scaffold (COOLIFY-INTRA, COOLIFY-ALLOW, FORWARD jumps, conntrack early-accept, POSTROUTING RETURN) | `coolify init bootstrap` (also reconverges on `extend`) | Install + idempotently re-converge on flag change. Never touches individual allow rules. |
|
||||
| Rule metadata (who/when/why, audit log, RBAC, tenant scoping, app→rule mapping) | **Coolify central DB** | Authoritative store. All rich queries, audit trails, and access control live here. |
|
||||
| Raw rule tuples `(src, dst, proto, port)` on the host | **coold** (single writer) | Apply to kernel + snapshot to `/etc/coolify/allow.rules` for reboot. Stateless-ish — just a cache of what the caller (central Coolify or `coolify firewall` CLI) told it to apply. No metadata, no DB. |
|
||||
|
||||
**Key split**: central Coolify owns rich state (metadata, audit, RBAC). Per-host coold owns only the raw rules needed to program the kernel + survive reboot. This keeps coold small and lets a single central DB be the source of truth for all cross-cutting concerns.
|
||||
|
||||
**App-topology compilation happens in central.** coold applies the rule tuples it is told to apply; it does not generate rules from app intent (e.g. "allow service `web` → `db`"). Central compiles that from the app model and sends individual `POST /firewall/allow` frames.
|
||||
|
||||
**`coolify init` is intentionally not the rule store.** Bootstrap creates the empty allow chain. coold is the sole writer into it. Callers reach coold via two paths: (a) central Coolify over the coold-initiated outbound stream, (b) intra-mesh callers (`coolify firewall` CLI via SSH-bounce, other coolds, optional per-customer gateway) via coold's local REST API on wg0 mgmt IP.
|
||||
|
||||
#### Reboot persistence
|
||||
|
||||
Works the same pre- and post-coold because both use the same file format:
|
||||
|
||||
- `/etc/coolify/allow.rules` — filter-table fragment, `:COOLIFY-ALLOW` + `-A COOLIFY-ALLOW` lines only. Written atomically (`.tmp` + `mv`) on every rule change.
|
||||
- `/etc/systemd/system/coolify-mesh-allow.service` — `Type=oneshot`, `After=coolify-mesh-fw.service`, `Wants=coolify-mesh-fw.service`. `ExecStart=iptables-restore --noflush /etc/coolify/allow.rules`. `--noflush` means only `COOLIFY-ALLOW` is populated; nothing else is disturbed.
|
||||
|
||||
coold owns the file: it rewrites `/etc/coolify/allow.rules` on every successful API mutate, keeping it in sync with the live kernel. The `coolify firewall` CLI never touches the file — it POSTs/DELETEs through coold and coold handles persistence + systemd unit install. One writer, one format.
|
||||
|
||||
#### Allow-rule lifecycle
|
||||
|
||||
For an allow `(srcIP, dstIP)`:
|
||||
- Add ACCEPT to `COOLIFY-ALLOW` on the host that **owns dstIP** (where DROP would otherwise fire).
|
||||
- For bidirectional traffic (e.g. TCP, ICMP echo+reply), add the reverse `(dstIP, srcIP)` on the host that owns srcIP. (Reply packets traverse THAT host's FORWARD chain when arriving back, and dst-side check fires there.)
|
||||
- **One unidirectional allow = one rule on one host. One bidirectional allow = two rules on two hosts.**
|
||||
- Conntrack ESTABLISHED early-accept (installed by bootstrap) handles in-flow follow-up packets — no need to add per-packet rules.
|
||||
|
||||
#### Persistence + scale model
|
||||
|
||||
Per-rule systemd dropins do NOT scale (1000 rules × `daemon-reload` + restart = minutes, fs clutter, audit nightmare). Instead, coold is a thin rule-applier backed by central:
|
||||
|
||||
```
|
||||
coold service (per host)
|
||||
├─ Snapshot file: /etc/coolify/allow.rules (flat iptables-save fragment)
|
||||
├─ Boot: systemd unit runs iptables-restore --noflush from file
|
||||
├─ API mutate: apply iptables -A/-D → regen snapshot via iptables-save
|
||||
└─ Reconcile: central periodically diffs its DB vs coold's live
|
||||
`iptables -S COOLIFY-ALLOW`; pushes deltas to re-converge
|
||||
```
|
||||
|
||||
Source of truth for **the set of rules that should exist** = central Coolify DB. Source of truth for **what's programmed in the kernel right now** = kernel itself, mirrored to `/etc/coolify/allow.rules` for reboot. coold does not keep its own DB.
|
||||
|
||||
#### Write ordering (crash/reboot safety)
|
||||
|
||||
Every mutating call from central → coold follows this sequence:
|
||||
|
||||
1. **Central writes to its own DB first** (with its own audit/tenant metadata). Durable with the rest of Coolify's state.
|
||||
2. **Central sends command over the open stream** to coold with just `(src, dst, proto, port)`. No inbound connection to coold — the stream was already established by coold at boot.
|
||||
3. **coold applies `iptables -A/-D`** to kernel.
|
||||
4. **coold regenerates `/etc/coolify/allow.rules`** via `iptables-save` (atomic `.tmp` + `mv`).
|
||||
5. **coold returns success to central** over the same stream (response carries the request id).
|
||||
6. **On any failure in 3–5**, central marks the row "pending" in its DB and retries / surfaces to operator. Nothing is lost because step 1 is already durable.
|
||||
|
||||
Consequences:
|
||||
- **Crash between steps 3 and 4** → kernel has the rule, file doesn't. Reboot loses the rule. Central's reconcile loop detects divergence (its DB has the rule, live kernel doesn't after boot) and re-pushes. Safe, with a small drift window bounded by reconcile cadence.
|
||||
- **Crash between steps 4 and 5** → kernel + file both updated, but central didn't get the ack. Central retries; `iptables -C` guard makes the retry a no-op. Safe.
|
||||
- **coold down when central wants to mutate** → central queues the change and retries on reconnect. No state loss on either side.
|
||||
- **Central DB is authoritative** — a reboot can only *shrink* the live rule set compared to central's view, never grow it.
|
||||
|
||||
Bulk ops (`/bulk`) ship the whole batch in one REST call. coold applies via `iptables-restore --noflush` / `nft -f` (atomic transaction), then regens snapshot once.
|
||||
|
||||
Apply paths:
|
||||
|
||||
| Backend | Bulk apply (1000 rules) | Atomicity |
|
||||
|---|---|---|
|
||||
| `iptables -A` per rule | ~5s | per-rule |
|
||||
| `iptables-restore --noflush` (preferred for iptables-legacy) | ~50ms | per-batch |
|
||||
| `nft -f /tmp/rules.nft` (preferred when host uses nftables backend) | ~10ms | atomic transaction |
|
||||
|
||||
coold detects backend (`iptables --version` or presence of nftables socket) and picks. Bootstrap doesn't care.
|
||||
|
||||
For **systemctl restart coolify-mesh-fw.service** (e.g. a `coolify init bootstrap` re-run after a flag flip, or `coolify init extend` reinstalling the unit because the namespace list changed): the unit flushes COOLIFY-INTRA but **never flushes COOLIFY-ALLOW** — existing rules survive. If somehow lost (manual `iptables -F COOLIFY-ALLOW`, crash mid-write), central's reconcile loop compares its own DB against `iptables -S COOLIFY-ALLOW` from each host and re-pushes any missing tuples within the reconcile interval.
|
||||
|
||||
#### Allow API surface
|
||||
|
||||
Same method/path set is served on both transports — stream (central → coold) and local REST (intra-mesh → coold). Stream = JSON-RPC frames carrying the same `(method, path, body)` tuple; REST = plain HTTP on wg0 mgmt IP :8443.
|
||||
|
||||
```
|
||||
POST /api/v1/firewall/allow {src, dst, proto?, port?, comment?} → returns id
|
||||
DELETE /api/v1/firewall/allow/{id}
|
||||
GET /api/v1/firewall/allow list
|
||||
GET /api/v1/firewall/allow/{id} show + match counters
|
||||
POST /api/v1/firewall/allow/bulk {add: [...], remove: [...]} atomic batch
|
||||
POST /api/v1/firewall/reconcile force full reload
|
||||
```
|
||||
|
||||
coold translates each row into the right iptables/nft fragment. Per-port: `-p tcp --dport <N>`. Source/dest IP, CIDR, or set reference (for grouping like "all-frontend-ips").
|
||||
|
||||
For very large rule sets: use **nftables sets** so a rule references a set name, and the set membership changes are O(1):
|
||||
|
||||
```
|
||||
nft add element ip filter coolify_allowed_pairs { 10.210.0.10 . 10.210.1.10 }
|
||||
```
|
||||
|
||||
One static rule like `ct state new ip saddr . ip daddr @coolify_allowed_pairs accept` evaluates in O(log n) regardless of set size. coold maintains the set rather than thousands of rules. Optional optimization for v5+.
|
||||
|
||||
#### Intra-host isolation (NOT enforced by `--default-deny`)
|
||||
|
||||
Linux + netavark + Ubuntu 24.04: bridge L2 traffic bypasses iptables FORWARD even with `bridge-nf-call-iptables=1`. **Containers on the same host's `coolify-mesh` bridge can always reach each other.**
|
||||
|
||||
Two paths for v5 to enforce intra-host isolation:
|
||||
|
||||
- **(Recommended) Per-app podman networks**: each Coolify service = own podman network with `--opt isolate=true`. Different networks can't talk by default; use `podman network connect` for cross-app.
|
||||
- Trade-off: each network needs its own `/24` from container pool → wastes pool. Or carve `/27`s (allocator extension needed).
|
||||
- **(Alternative) ebtables L2 filter**: `ebtables --logical-in podman1 --logical-out podman1 --ip-src X --ip-dst Y -j ACCEPT/DROP`. Independent toolchain, separate persistence. Bridge name discovery needed.
|
||||
|
||||
v1 ships without intra-host enforcement. v5 picks one path.
|
||||
|
||||
### 4. Container IP allocation per host
|
||||
|
||||
The bootstrap gives each host a `/24` (e.g. `10.210.0.0/24`). The control plane:
|
||||
- Reserves `.1` (bridge gateway, skip).
|
||||
- Allocates `.2-.254` for containers, deduplicated against running `podman ps` IPs.
|
||||
- Pins IP via `podman run --ip <IP>` so DNS/firewall rules stay stable.
|
||||
- Detects exhaustion early; alerts user to grow `--container-pool` or `--container-prefix`.
|
||||
|
||||
For `/24` per host: 253 containers max. For higher density: re-bootstrap with `--container-prefix 23` or larger pool.
|
||||
|
||||
### 5. Service discovery
|
||||
|
||||
**Pattern**: embedded DNS server in coold, backed by [Corrosion](https://github.com/superfly/corrosion) (CRDT sqlite gossiped via SWIM across the mesh). No env injection. No container restarts on backend movement.
|
||||
|
||||
#### Why DNS-via-coold over alternatives
|
||||
|
||||
| Approach | Stable target? | Backend move = restart? | Complexity |
|
||||
|---|---|---|---|
|
||||
| Env injection (`DB_HOST=10.210.5.42`) | no — IP changes | yes (rolling redeploy on every change) | medium (template engine + dep graph) |
|
||||
| **Embedded DNS in coold** | **yes (hostname)** | **no** | **low (~200 LoC)** |
|
||||
| VIP per service | yes (IP) | no | high (keepalived/BGP/IPVS) |
|
||||
| Per-host HTTP/TCP proxy | yes (port) | no | medium (proxy config) |
|
||||
|
||||
DNS chosen: smallest moving parts, works for any protocol, standard `getaddrinfo()` path, ubiquitous client support.
|
||||
|
||||
#### Corrosion schema (replicated sqlite)
|
||||
|
||||
```sql
|
||||
CREATE TABLE services (
|
||||
id TEXT PRIMARY KEY, -- "myapp.db"
|
||||
coolify_app_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL, -- "db"
|
||||
namespace TEXT NOT NULL, -- "myapp"
|
||||
port INTEGER, -- canonical port (informational)
|
||||
updated_at INTEGER NOT NULL -- ms epoch (CRDT clock)
|
||||
);
|
||||
|
||||
CREATE TABLE service_endpoints (
|
||||
service_id TEXT NOT NULL,
|
||||
container_id TEXT NOT NULL,
|
||||
host_mgmt_ip TEXT NOT NULL, -- 100.64.0.X (host running the container)
|
||||
container_ip TEXT NOT NULL, -- 10.210.X.Y
|
||||
healthy INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (service_id, container_id)
|
||||
);
|
||||
```
|
||||
|
||||
Each coold writes its own host's container facts. Reads are local sqlite (sub-ms). Gossip handles distribution; convergence ~1s in small clusters.
|
||||
|
||||
#### Embedded DNS server
|
||||
|
||||
```go
|
||||
// pseudocode — ~200 LoC total
|
||||
func (c *Coold) serveDNS() {
|
||||
pc, _ := net.ListenPacket("udp", "10.210.X.1:53") // bridge gateway IP
|
||||
for {
|
||||
buf := make([]byte, 512)
|
||||
n, addr, _ := pc.ReadFrom(buf)
|
||||
go c.handle(buf[:n], addr, pc)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Coold) handle(query []byte, src net.Addr, pc net.PacketConn) {
|
||||
msg := dns.Unpack(query)
|
||||
name := msg.Questions[0].Name // "myapp.db.coolify.internal."
|
||||
|
||||
if !strings.HasSuffix(name, ".coolify.internal.") {
|
||||
// Forward to upstream (configurable; default 1.1.1.1).
|
||||
pc.WriteTo(c.upstream.Query(msg), src)
|
||||
return
|
||||
}
|
||||
|
||||
serviceID := strings.TrimSuffix(name, ".coolify.internal.")
|
||||
var ips []string
|
||||
c.corrosion.Query(`
|
||||
SELECT container_ip FROM service_endpoints
|
||||
WHERE service_id = ? AND healthy = 1
|
||||
`, serviceID).Scan(&ips)
|
||||
|
||||
if len(ips) == 0 {
|
||||
pc.WriteTo(dns.NXDOMAIN(msg), src); return
|
||||
}
|
||||
pc.WriteTo(dns.AnswerA(msg, ips, ttl=5), src)
|
||||
}
|
||||
```
|
||||
|
||||
Listens on **bridge gateway IP** (`10.210.X.1:53`) of the host's `coolify-mesh` bridge — reachable from every container in the host's `/24` via standard kernel routing.
|
||||
|
||||
#### Container creation hook
|
||||
|
||||
Every container coold creates gets:
|
||||
```
|
||||
podman run --dns 10.210.X.1 --dns-search coolify.internal ...
|
||||
```
|
||||
|
||||
App code uses short names: `getaddrinfo("myapp.db", ...)` → libc appends search suffix → `myapp.db.coolify.internal` → coold answers from local Corrosion.
|
||||
|
||||
#### Resolution flow
|
||||
|
||||
```
|
||||
1. App in container A on host-1 (10.210.0.10) calls getaddrinfo("myapp.db")
|
||||
2. libc reads /etc/resolv.conf:
|
||||
nameserver 10.210.0.1
|
||||
search coolify.internal
|
||||
3. UDP query "myapp.db.coolify.internal" → 10.210.0.1:53
|
||||
4. coold@host-1 reads local Corrosion → 10.210.5.42 (running on host-3)
|
||||
5. Reply: A 10.210.5.42, TTL=5
|
||||
6. App opens TCP to 10.210.5.42:5432
|
||||
7. Routed via wg0 (peer host-3's AllowedIPs covers 10.210.5.0/24)
|
||||
→ bridge → container
|
||||
8. (If --default-deny is on, COOLIFY-ALLOW on host-3 must permit
|
||||
10.210.0.10 → 10.210.5.42.)
|
||||
```
|
||||
|
||||
#### Backend movement (zero restart on dependents)
|
||||
|
||||
```
|
||||
T+0: myapp.db @ 10.210.5.42 on host-3. Endpoint row gossiped.
|
||||
T+10s: User redeploys myapp.db on host-3.
|
||||
coold@host-3:
|
||||
- new container at 10.210.5.43
|
||||
- INSERT new endpoint row (10.210.5.43)
|
||||
- DELETE old endpoint row (10.210.5.42)
|
||||
- kill old container
|
||||
Corrosion gossips delta.
|
||||
T+11s: All hosts have updated state.
|
||||
T+15s: App on host-1 has stale TCP to 10.210.5.42 — broken when old container died.
|
||||
App's reconnect logic re-resolves myapp.db → 10.210.5.43 → reconnects.
|
||||
App container NEVER restarted, env NEVER changed.
|
||||
```
|
||||
|
||||
App must have reconnect logic (every reasonable DB/cache client does). DNS provides the new IP transparently.
|
||||
|
||||
#### TTL
|
||||
|
||||
5s. Trade-off:
|
||||
- Lower = faster failover, more queries.
|
||||
- Higher = quieter DNS, slower failover.
|
||||
|
||||
Apps with infinite-cache resolvers (Java's `networkaddress.cache.ttl=-1`) won't see updates. Document for users; not coold's problem.
|
||||
|
||||
#### Multi-replica services
|
||||
|
||||
Resolver returns ALL healthy A records. Apps with proper conn pools (postgres, redis clients) handle multi-target naturally. No client-side LB protocol needed.
|
||||
|
||||
#### Health & staleness
|
||||
|
||||
- coold marks `healthy=0` on healthcheck fail. DNS stops returning that IP within next query.
|
||||
- Stale-row TTL: rows older than 60s without heartbeat are pruned (owning coold heartbeats every 15s).
|
||||
|
||||
#### TLD
|
||||
|
||||
`.coolify.internal` — `.internal` is RFC 6761 reserved for private use. Won't collide with public TLDs. Configurable per-cluster.
|
||||
|
||||
#### Failure modes
|
||||
|
||||
| Failure | Behaviour |
|
||||
|---|---|
|
||||
| coold dies | Cluster DNS resolution stops. systemd restarts coold (~3s). Existing connections survive. Same profile as k8s losing CoreDNS. |
|
||||
| Corrosion split-brain | Each partition serves local view; CRDT merges cleanly when partition heals. May serve stale IPs during partition. |
|
||||
| Backend healthy in DB but unreachable | DNS returns IP → app connection fails → app retries. If multi-replica, may pick different one on retry. |
|
||||
| Container has no `--dns` (created outside coold) | No cluster resolution. Document: only coold-managed containers get discovery. |
|
||||
| Cross-region high latency | Slower convergence; stale DNS for 10–30s. Acceptable v1. |
|
||||
|
||||
#### API surface
|
||||
|
||||
Same dual-transport model as the firewall API — stream from central, REST from intra-mesh callers.
|
||||
|
||||
```
|
||||
POST /api/v1/services/register {service_id, app_id, name, namespace, port, container_id, container_ip, host_mgmt_ip}
|
||||
DELETE /api/v1/services/{service_id}/endpoints/{container_id}
|
||||
GET /api/v1/services/{service_id}/endpoints
|
||||
GET /api/v1/services?namespace=myapp
|
||||
GET /api/v1/dns/lookup/{name} (debug — what coold would answer)
|
||||
GET /api/v1/dns/stats (qps, hit/miss/forward counts)
|
||||
```
|
||||
|
||||
Most ops are automatic side effects of deploy/scale/health-check. Central rarely calls `/services/register` directly — coold registers on container create, deregisters on remove.
|
||||
|
||||
coold writes Corrosion rows on behalf of central (explicit `POST /services/register` frames); it does not infer service identity from container labels. Central supplies `service_id` explicitly so naming policy stays in one place.
|
||||
|
||||
#### Bootstrap impact
|
||||
|
||||
Minimal. `coolify init bootstrap` creates every `coolify-<ns>-mesh` Podman network with `--disable-dns` so netavark never starts aardvark-dns on the bridge gateway `:53`. coold owns that socket. Bridge gateway IP was always reserved by `MachineIP()`.
|
||||
|
||||
Pre-alpha deployments that created the network without `--disable-dns` are detected at plan-time (probe reads `podman network inspect .DNSEnabled`). A `recreate-podman-network` action drops and recreates the network — same subnet, same gateway, but with DNS disabled. Any attached containers are disconnected via `podman network rm -f`.
|
||||
|
||||
#### Port 53 conflict handling
|
||||
|
||||
Three layers protect coold's `10.210.X.1:53` socket:
|
||||
|
||||
| Layer | Mechanism | Covers |
|
||||
|---|---|---|
|
||||
| 1. Bootstrap | `podman network create --disable-dns` (+ drift recreate) | aardvark-dns squat |
|
||||
| 2. Bind target | coold binds **bridge gateway IP only**, not `0.0.0.0` and not wg0 mgmt IP | host wildcard DNS daemons (dnsmasq/pihole on `0.0.0.0:53`) and wg0 bloat |
|
||||
| 3. Preflight | `net.Listen("tcp", gateway+":53")` probe before `ListenPacket` | clear actionable error + systemd `Restart=on-failure` retry |
|
||||
|
||||
systemd-resolved on Ubuntu binds `127.0.0.53:53` — no conflict with bridge gateway.
|
||||
|
||||
Bind rule: coold DNS is container-facing only (listen on bridge gateway IP). coold REST API is operator-facing (listen on wg0 mgmt IP, port 8443). Separate concerns, separate sockets.
|
||||
|
||||
### 6. Ingress (public traffic → containers)
|
||||
|
||||
`coolify init` doesn't manage public ingress. v5 deploys a reverse proxy (Traefik/Caddy) per host or HA pair:
|
||||
- Listens on host public IP `:80/:443`.
|
||||
- Routes `Host: app.example.com` → container IP (over container bridge or wg0 if cross-host).
|
||||
- Cert management via ACME.
|
||||
- Coolify generates proxy config from app routing rules.
|
||||
|
||||
Important: ingress proxy needs its own podman network OR can share `coolify-mesh`. Sharing means proxy can reach all containers — fine since it's the entrypoint.
|
||||
|
||||
### 7. Deployment workflows
|
||||
|
||||
Deploy is a **central-side state machine** that compiles app intent (compose / Dockerfile / buildpack / Nixpacks / raw image) into a sequence of coold primitives (see §2 wire surface). coold does not participate in planning — it executes one primitive per frame.
|
||||
|
||||
#### Build pipeline (not in coold)
|
||||
|
||||
```
|
||||
git push
|
||||
│
|
||||
▼
|
||||
Central receives webhook
|
||||
│
|
||||
▼
|
||||
Builder (BuildKit / Buildpacks / Nixpacks) ← coold NOT involved
|
||||
- Self-hosted: first mesh host by default;
|
||||
central may pin via target_host_id per build.
|
||||
- Cloud: central-run.
|
||||
│
|
||||
▼
|
||||
Push to registry (registry.coolify.io or customer's) ← coold NOT involved
|
||||
│
|
||||
▼
|
||||
Central deploy controller → primitive op stream → coold on target host
|
||||
```
|
||||
|
||||
coold's only role in the build path: `POST /images/pull` once the tag exists in the registry.
|
||||
|
||||
#### Deploy flow (T0–T10 — every frame = one §2 primitive)
|
||||
|
||||
```
|
||||
T0 Central builder clones source, invokes BuildKit / buildpack / nixpacks.
|
||||
Output: OCI image @ registry.coolify.io/tenant/web:v2.
|
||||
|
||||
T1 Central deploy controller picks target host H (scheduler = least-loaded / pin).
|
||||
|
||||
T2 Frame: POST /images/pull {ref: "registry.coolify.io/tenant/web:v2"}
|
||||
coold@H calls podman.sock /images/create, streams progress back.
|
||||
|
||||
T3 Frame: POST /volumes {name: "web-data", driver: "local"}
|
||||
coold@H idempotent; no-op if exists.
|
||||
|
||||
T4 Frame: POST /containers (central templates from compose + resolved secrets)
|
||||
body:
|
||||
{
|
||||
"image": "registry.coolify.io/tenant/web:v2",
|
||||
"name": "web-v2-a3f91",
|
||||
"network": "coolify-mesh",
|
||||
"ip": "10.210.H.42",
|
||||
"dns": ["10.210.H.1"],
|
||||
"dns_search": ["coolify.internal"],
|
||||
"env": {"DATABASE_URL": "postgres://…"},
|
||||
"mounts": [{"volume": "web-data", "target": "/data"}],
|
||||
"healthcheck": {"test": ["CMD","curl","-f","http://localhost/"], "interval": "5s"},
|
||||
"labels": {"coolify.app": "web", "coolify.version": "v2"}
|
||||
}
|
||||
coold checks deny filter → calls podman.sock /containers/create → returns id.
|
||||
|
||||
T5 Frame: POST /containers/{id}/start
|
||||
coold starts container.
|
||||
|
||||
T6 Central polls GET /containers/{id} or subscribes to events.
|
||||
Wait for healthy; abort + rollback on timeout.
|
||||
|
||||
T7 Frame: POST /services/register
|
||||
coold writes Corrosion row. Gossip distributes; DNS now answers new IP.
|
||||
|
||||
T8 Frame: POST /firewall/allow (on dst host — coold = sole kernel writer)
|
||||
{src: proxy-ip, dst: 10.210.H.42, proto: "tcp", port: 80}
|
||||
|
||||
T9 Central ingress controller regenerates proxy config (Caddy/Traefik/nginx)
|
||||
→ upstreams point to new container IP.
|
||||
Frame: POST /containers/{proxy-id}/exec (reload) or proxy-specific reload.
|
||||
|
||||
T10 Cutover complete. Central retires the old container:
|
||||
POST /containers/{old-id}/stop {timeout: 10}
|
||||
DELETE /containers/{old-id}
|
||||
DELETE /services/web/endpoints/{old-container-id}
|
||||
DELETE /firewall/allow/{old-rule-id}
|
||||
```
|
||||
|
||||
Every T-frame is one of the narrow primitives in §2. coold never runs compose, never builds, never picks hosts, never reads app config. If a future verb is needed, it gets added to §2 and the coold release, not smuggled through a passthrough.
|
||||
|
||||
**coold non-goals for deploy**: no compose parser, no buildpacks, no Dockerfile handler, no Nixpacks, no scheduler, no ingress templating, no rollback orchestration, no secrets store.
|
||||
|
||||
### 8. Storage & volumes
|
||||
|
||||
- Local podman volumes per host (`/var/lib/containers/storage/volumes`).
|
||||
- Cross-host: distributed FS (out of scope) OR pin stateful services to a host (anti-affinity rules in scheduler).
|
||||
- Backup: `podman volume export` + scp to backup target. Coolify orchestrates schedule.
|
||||
- **v5 alpha decision**: stateful services **pin to host**. Cross-host volume movement / distributed FS is post-alpha.
|
||||
|
||||
### 9. Scheduling
|
||||
|
||||
**Placement lives in central.** coold provides facts (`GET /host/info`, `/host/stats`, `/host/containers`); central consumes them, picks the target host, and sends the resulting primitives. coold has no placement logic.
|
||||
|
||||
When user creates an app, central decides which host runs it:
|
||||
- Round-robin / least-loaded / explicit pin.
|
||||
- Pinned services (DB, persistent volumes) tracked in central DB.
|
||||
- Re-schedule on host failure (wg0 down, last-handshake stale).
|
||||
|
||||
Failure detection: central polls `wg show wg0 latest-handshakes` via `GET /host/info` on every host, parses seconds-since-handshake; alerts if > N seconds.
|
||||
|
||||
### 10. Observability
|
||||
|
||||
coold exposes read-only `/host/*` endpoints surfacing the facts below. Central (or a central-side scraper) pulls from each host and feeds Prometheus / VictoriaMetrics. coold does **not** push metrics.
|
||||
|
||||
Per host metrics (over wg0 via coold endpoints):
|
||||
- `GET /host/info` → podman info (version, storage driver, free space), kernel, wg state, load.
|
||||
- `GET /host/containers` → `podman ps -a --format json` state.
|
||||
- `GET /host/stats` → `podman stats --no-stream --format json` CPU/mem per container.
|
||||
- Wg handshake + transfer bytes via `GET /host/info` (`wg show wg0 dump` internally).
|
||||
- `iptables -nvL COOLIFY-ALLOW` match counters (for audit) exposed through `GET /firewall/allow` with counters.
|
||||
|
||||
Stream into central time-series store (Prometheus / VictoriaMetrics).
|
||||
|
||||
### 11. Updates
|
||||
|
||||
- Coolify runtime image self-updates (container restart with new image).
|
||||
- WireGuard / Podman package updates: `coolify init bootstrap` re-runs idempotently and picks up newer packages from apt. Agent (coold/corrosion/scheduler/builder) bumps go through `coolify init upgrade --coold-version vX.Y.Z` etc. Schedule periodic re-apply (weekly?).
|
||||
- Mesh config changes (new host, removed host) trigger re-apply on all hosts; control plane orchestrates.
|
||||
|
||||
### 12. Security posture
|
||||
|
||||
- **Private keys never leave hosts**: WG private key generated on remote, never transits SSH (already done by bootstrap).
|
||||
- **Podman socket access**: `/run/podman/podman.sock` stays as a rootful Unix socket on each host — **NEVER exposed on TCP**. Only **coold** (per-host agent, see §2) has access via bind-mount. coold surfaces a curated REST API over wg0 with TLS + bearer auth. This means:
|
||||
- Compromise of a non-coold container does NOT grant podman API access.
|
||||
- coold enforces bearer-token authn and can deny dangerous flags (e.g. `--privileged`) at the API surface. RBAC, per-user/tenant scoping, and business audit live **only** in central Coolify (see §3 split).
|
||||
- No `podman system service tcp://...` listener; no need for socket-level TLS.
|
||||
- Central Coolify only knows the coold endpoint, not the underlying socket.
|
||||
- **SSH access**: bootstrap uses key-based SSH. Control plane should rotate SSH keys per agent install, store in encrypted DB. After bootstrap, day-to-day ops go via coold REST — SSH is for re-bootstrap only.
|
||||
- **Host firewall (iptables INPUT chain)**: bootstrap doesn't lock down INPUT. v5 should drop public access to ports other than `:51820/udp` (WG), `:22/tcp` (SSH), `:80/:443` (ingress). coold's `:8443` binds to the wg0 IP only, so it's already not on the public interface.
|
||||
- **coold port reachability**: central never dials in — coold's outbound stream is the control path — so no `COOLIFY-ALLOW` rule for central is needed. coold's local REST on wg0 mgmt IP (`:8443`) is reachable only from inside the mesh, and is used by (a) the `coolify firewall` CLI via SSH-bounce, (b) other coolds in the same mesh, (c) an optional per-customer gateway. Nothing on the public internet reaches coold. Outbound TLS :443 to central must be permitted by the customer's egress firewall — standard for any SaaS agent.
|
||||
- **Audit**: central Coolify is the sole authoritative audit log — who-when-why metadata for every COOLIFY-ALLOW change. coold writes only an ops/debug request log (request id, endpoint, status, duration) for troubleshooting; it never sees the identity of the human caller, only the bearer token used to reach it.
|
||||
|
||||
### 13. Failure modes & recovery
|
||||
|
||||
| Failure | Detection | Recovery |
|
||||
|---|---|---|
|
||||
| Host SSH unreachable | bootstrap apply error | Manual investigation; node marked unhealthy in DB |
|
||||
| WG peer offline (`latest_handshake > 180s`) | `wg show` poll | Mark unhealthy; re-schedule containers if pinning permits |
|
||||
| Podman socket unreachable | API call timeout | Restart `podman.socket`; if persistent, re-bootstrap |
|
||||
| Firewall service failed | `systemctl is-active != active` | Re-run `coolify init bootstrap`; service is idempotent |
|
||||
| Container OOM/crash | `podman events` watcher | Restart per restart policy; alert after N crashes |
|
||||
| Container subnet exhausted | allocator returns error | Alert; offer apply with bigger `--container-prefix` |
|
||||
| Mgmt IP exhausted | allocator returns error | Alert; rare for /16 |
|
||||
| `coolify-mesh` bridge missing | probe `podman network exists` returns no | Re-run apply |
|
||||
| User manually deletes COOLIFY-ALLOW chain | runtime check | Re-run apply (recreates chain via service restart) |
|
||||
|
||||
### 14. Multi-tenancy (deferred)
|
||||
|
||||
If Coolify ever supports tenant isolation:
|
||||
- Tenant = own podman network namespace per host.
|
||||
- Allows always scoped within tenant; cross-tenant requires explicit allow.
|
||||
- Pool subdivided per tenant. Allocator extension.
|
||||
|
||||
Not in v1 or v5 initial.
|
||||
|
||||
---
|
||||
|
||||
## Out of scope (now and likely v5)
|
||||
|
||||
- Rootless containers (would need user namespace mapping, separate sockets per user).
|
||||
- IPv6 mesh (`fdcc::` style, ip6tables mirror).
|
||||
- Hardware-level isolation (SELinux profiles, AppArmor).
|
||||
- Live migration (qemu/criu).
|
||||
- Distributed storage (Ceph/Longhorn).
|
||||
- macvlan / SR-IOV networking.
|
||||
- Autoscaling.
|
||||
- BGP / external network announcements.
|
||||
|
||||
---
|
||||
|
||||
## Quick reference — operations the agent CLI should expose
|
||||
|
||||
(Future `coolify-cli` subcommands beyond `init`)
|
||||
|
||||
```
|
||||
coolify deploy <app> # build + push + run
|
||||
coolify scale <app> --replicas N
|
||||
coolify firewall containers --servers A,B ... # discover mesh containers (SSH+podman)
|
||||
coolify firewall list --servers A,B ... # list allow rules across hosts (coold GET /allow, SSH-bounced)
|
||||
coolify firewall allow --from <ref> --to <ref> --port N # add allow rule (coold POST /allow, SSH-bounced)
|
||||
coolify firewall revoke --from <ref> --to <ref> --port N # remove allow rule (coold DELETE /allow/{id})
|
||||
coolify host list # show mesh state, last-handshake, container count
|
||||
coolify host add <ip> --ssh-key K
|
||||
coolify host remove <ip>
|
||||
coolify logs <container>
|
||||
coolify exec <container> -- sh
|
||||
```
|
||||
|
||||
`coolify firewall` is implemented today as a thin SSH-bounced REST client of coold (§3 above). The laptop running the CLI isn't a mesh peer, so every call SSHes into the target host and runs `curl "http://<wg0-mgmt-ip>:8443/api/v1/firewall/..."` against coold locally. Per-host bearer tokens are fetched from `/etc/coolify/api-token` on demand (with `--coold-token` as an override for homogeneous test clusters).
|
||||
|
||||
Everything else on the roadmap (`coolify deploy`, `coolify scale`, `coolify logs`, `coolify exec`) targets the **central** API (SaaS or self-hosted central), not coold directly. Central compiles the request into the primitive-op sequence in §7 and streams it to coold. Only `coolify firewall` currently bypasses central and hits coold directly — legacy + test harness until central wires up `/firewall/*` itself.
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
`coolify init bootstrap` does the **first-time host install**: WG mesh, podman runtime, bridge network, default-deny scaffold, coold/corrosion/scheduler/builder agents. `coolify init extend` adds hosts to an existing mesh without disturbing converged ones; `coolify init upgrade` bumps agent versions across the fleet. After that, **everything dynamic is the v5 control plane's job**: container lifecycle, allow rules in COOLIFY-ALLOW (via systemd dropins for persistence), scheduling, observability, ingress, updates.
|
||||
|
||||
The pieces communicate via:
|
||||
1. **SSH** for host provisioning + re-converge (idempotent `coolify init bootstrap` / `extend` / `upgrade` re-runs). SSH is the installer channel only, not a steady-state control path.
|
||||
2. **coold → central outbound stream** (WSS / gRPC bidi on :443) for day-to-day runtime ops from central. One topology for self-hosted and cloud SaaS; central never dials coold, never joins any mesh. Per-customer gateway (optional) collapses N streams into 1 per mesh.
|
||||
3. **coold local REST API** on wg0 mgmt IP (`http://100.64.0.X:8443`) for intra-mesh callers: the `coolify firewall` CLI via SSH-bounce, other coolds, the per-customer gateway. Never reachable from the public internet.
|
||||
|
||||
coold is the *only* process with access to the local podman socket AND the sole writer of allow rules in COOLIFY-ALLOW. Both transports hit the same API surface.
|
||||
|
||||
Persistence model:
|
||||
- Bootstrap state (chains, jumps, conntrack accept) → idempotent `coolify init bootstrap` re-runs (and `extend` when a namespace is added).
|
||||
- Rule metadata (who/when/why, audit, RBAC, tenant scoping) → central Coolify DB only. coold does not duplicate this.
|
||||
- Kernel rules → programmed by coold on every API call (from either central Coolify or the `coolify firewall` CLI); mirrored to `/etc/coolify/allow.rules` for reboot via `coolify-mesh-allow.service` (oneshot `iptables-restore --noflush`).
|
||||
- Today the `coolify firewall` CLI is the primary caller of coold (SSH-bounced REST client with per-host `/etc/coolify/api-token` resolution). Central Coolify will call the same API once wired.
|
||||
|
||||
The podman socket is host-local. There is no TCP podman API. coold is the **authn + privilege boundary** between any caller (central Coolify over the outbound stream, or the `coolify firewall` CLI via SSH-bounced local REST) and the host, AND the kernel-rule applier. Central Coolify owns RBAC, tenant scoping, and the business audit log (who/when/why). coold only verifies a bearer token (per-host static for local REST; per-host JWT for the stream), applies the rule, and keeps an ops/debug request log. `coolify firewall` exercises the local REST surface today; central will exercise the stream surface — same code path end-to-end, different transport.
|
||||
|
||||
**coold stays small.** All app-aware logic (compose, Dockerfile, buildpacks, Nixpacks, scheduling, rollback, ingress templating, RBAC, audit) lives in central. coold's wire surface is enumerable (§2); new verbs require a coold release, not a `/podman/raw` passthrough. If coold ever grows a `/apps` or `/compose` endpoint, that is the wrong layer.
|
||||
@@ -0,0 +1,124 @@
|
||||
# How to Release Coolify CLI
|
||||
|
||||
This guide explains the release process for the Coolify CLI.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Write access to the `coollabsio/coolify-cli` repository
|
||||
- All changes merged to the target branch (`v4.x`)
|
||||
- All tests passing (`go test ./internal/...`)
|
||||
|
||||
## Release Process
|
||||
|
||||
### 1. Create a GitHub Release
|
||||
|
||||
1. Go to https://github.com/coollabsio/coolify-cli/releases/new
|
||||
2. Click "Choose a tag" and create a new tag:
|
||||
- **Tag name**: `v1.x.x` (must start with `v`, e.g., `v1.2.3`)
|
||||
- **Target**: `v4.x` (or your target branch)
|
||||
3. **Release title**: `v1.x.x` (same as tag name)
|
||||
4. **Description**: Write release notes describing:
|
||||
- New features
|
||||
- Bug fixes
|
||||
- Breaking changes (if any)
|
||||
- Example:
|
||||
```markdown
|
||||
## What's New
|
||||
- Added support for database management
|
||||
- Improved error messages for API failures
|
||||
|
||||
## Bug Fixes
|
||||
- Fixed panic when config file is missing
|
||||
|
||||
## Breaking Changes
|
||||
- None
|
||||
```
|
||||
5. Click "Publish release"
|
||||
|
||||
### 2. Automated Build Process
|
||||
|
||||
Once you publish the release:
|
||||
|
||||
1. GitHub Actions automatically triggers the `release-cli.yml` workflow
|
||||
2. GoReleaser builds binaries for:
|
||||
- **Linux**: amd64, arm64
|
||||
- **macOS (Darwin)**: amd64, arm64
|
||||
- **Windows**: amd64, arm64
|
||||
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. 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), verify without touching your local install:
|
||||
|
||||
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
|
||||
|
||||
### Build Failed
|
||||
- Check the GitHub Actions logs at https://github.com/coollabsio/coolify-cli/actions
|
||||
- Common issues:
|
||||
- Syntax errors in Go code
|
||||
- Test failures
|
||||
- GoReleaser configuration issues
|
||||
|
||||
### Version Not Updating
|
||||
- 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 (`contents: write` in `release-cli.yml`)
|
||||
|
||||
### Install Script Not Finding New Version
|
||||
- Wait a few minutes for GitHub's CDN to update
|
||||
- Check that binaries were uploaded to the release
|
||||
- Verify the tag format is correct (`v1.x.x`)
|
||||
|
||||
## Release Checklist
|
||||
|
||||
Before creating a release:
|
||||
|
||||
- [ ] All tests pass: `go test ./internal/...`
|
||||
- [ ] Code is formatted: `go fmt ./...`
|
||||
- [ ] 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 (both `release-cli` and `update-version` jobs)
|
||||
- [ ] All platform binaries are present on the release page
|
||||
- [ ] `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, 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
|
||||
@@ -2,46 +2,557 @@
|
||||
|
||||
## Installation
|
||||
|
||||
### Install script (recommended)
|
||||
|
||||
#### Linux/macOS
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/coollabsio/coolify-cli/main/scripts/install.sh | bash
|
||||
```
|
||||
|
||||
It will install the CLI in `/usr/local/bin/coolify` and the configuration file in `~/.config/coolify/config.json`
|
||||
|
||||
> If you are a windows or mac user, please test the installation script and let us know if it works for you.
|
||||
### Homebrew (macOS/Linux)
|
||||
|
||||
## Configuration
|
||||
```bash
|
||||
brew install coollabsio/coolify-cli/coolify-cli
|
||||
```
|
||||
|
||||
#### Windows (PowerShell)
|
||||
|
||||
```powershell
|
||||
irm https://raw.githubusercontent.com/coollabsio/coolify-cli/main/scripts/install.ps1 | iex
|
||||
```
|
||||
|
||||
It will install the CLI in `%ProgramFiles%\Coolify\coolify.exe` and the configuration file in `%USERPROFILE%\.config\coolify\config.json`
|
||||
|
||||
For user installation (no admin rights required):
|
||||
```powershell
|
||||
$env:COOLIFY_USER_INSTALL=1; irm https://raw.githubusercontent.com/coollabsio/coolify-cli/main/scripts/install.ps1 | iex
|
||||
```
|
||||
|
||||
For a specific version:
|
||||
```powershell
|
||||
$env:COOLIFY_VERSION='v1.0.0'; irm https://raw.githubusercontent.com/coollabsio/coolify-cli/main/scripts/install.ps1 | iex
|
||||
```
|
||||
|
||||
### Using `go install`
|
||||
|
||||
```bash
|
||||
go install github.com/coollabsio/coolify-cli/coolify@latest
|
||||
```
|
||||
|
||||
This will install the `coolify` binary in your `$GOPATH/bin` directory (usually `~/go/bin`). Make sure this directory is in your `$PATH`.
|
||||
|
||||
### Using the install script
|
||||
|
||||
|
||||
## Getting Started
|
||||
1. Get a `<token>` from your Coolify dashboard (Cloud or self-hosted) at `/security/api-tokens`
|
||||
|
||||
### Cloud
|
||||
|
||||
2. Add the token with `coolify instances set token cloud <token>`
|
||||
2. Add the token with `coolify context set-token cloud <token>`
|
||||
|
||||
### Self-hosted
|
||||
|
||||
2. Add the token with `coolify instances add -d <name> <fqdn> <token>`
|
||||
|
||||
> Replace `<name>` with the name you want to give to the instance.
|
||||
2. Add the token with `coolify context add -d <context_name> <url> <token>`
|
||||
|
||||
> Replace `<context_name>` with the name you want to give to the context.
|
||||
>
|
||||
> Replace `<fqdn>` with the fully qualified domain name of your Coolify instance.
|
||||
> Replace `<url>` with the fully qualified domain name of your Coolify instance.
|
||||
|
||||
Now you can use the CLI with the token you just added.
|
||||
|
||||
## Change default instance
|
||||
You can change the default instance with `coolify instances set default <name>`
|
||||
## 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
|
||||
|
||||
### Update
|
||||
- `coolify update` - Update the CLI to the latest version
|
||||
|
||||
### Instances
|
||||
- `coolify instances list` - List all instances
|
||||
- `coolify instances add` - Create a new instance configuration
|
||||
- `coolify instances remove` - Remove an instance configuration
|
||||
- `coolify instances get` - Get an instance configuration
|
||||
- `coolify instances set <default>|<token>` - Set an instance as default or set a token for an instance
|
||||
- `coolify instances version` - Get the version of the Coolify API for an instance
|
||||
|
||||
### Configuration
|
||||
- `coolify config` - Show configuration file location
|
||||
|
||||
### Shell Completion
|
||||
- `coolify completion <shell>` - Generate shell completion script
|
||||
- Supported shells: `bash`, `zsh`, `fish`, `powershell`
|
||||
|
||||
### Context Management
|
||||
- `coolify context list` - List all configured contexts
|
||||
- `coolify context add <context_name> <url> <token>` - Add a new context
|
||||
- `-d, --default` - Set as default context
|
||||
- `-f, --force` - Force overwrite if context already exists
|
||||
- `coolify context delete <context_name>` - Delete a context
|
||||
- `coolify context get <context_name>` - Get details of a specific context
|
||||
- `coolify context set-token <context_name> <token>` - Update the API token for a context
|
||||
- `coolify context set-default <context_name>` - Set a context as the default
|
||||
- `coolify context update <context_name>` - Update a context's properties
|
||||
- `--name <new_name>` - Change the context name
|
||||
- `--url <new_url>` - Change the context URL
|
||||
- `--token <new_token>` - Change the context token
|
||||
- `coolify context use <context_name>` - Switch to a different context (set as default)
|
||||
- `coolify context verify` - Verify current context connection and authentication
|
||||
- `coolify context version` - Get the Coolify API version of the current context
|
||||
|
||||
### Servers
|
||||
- `coolify servers list` - List all servers
|
||||
- `coolify servers get` - Get a server
|
||||
- `--resources` - Get the resources and their status of a server
|
||||
|
||||
Commands can use `server` or `servers` interchangeably.
|
||||
|
||||
- `coolify server list` - List all servers
|
||||
- `coolify server get <uuid>` - Get a server by UUID
|
||||
- `--resources` - Get the resources and their status of a server
|
||||
- `coolify server add <name> <ip> <private_key_uuid>` - Add a new server
|
||||
- `-p, --port <port>` - SSH port (default: 22)
|
||||
- `-u, --user <user>` - SSH user (default: root)
|
||||
- `--validate` - Validate server immediately after adding
|
||||
- `coolify server remove <uuid>` - Remove a server
|
||||
- `coolify server validate <uuid>` - Validate a server connection
|
||||
- `coolify server domains <uuid>` - Get server domains by UUID
|
||||
|
||||
### Projects
|
||||
- `coolify projects list` - List all projects
|
||||
- `coolify projects get <uuid>` - Get project environments
|
||||
|
||||
### Resources
|
||||
- `coolify resources list` - List all resources
|
||||
|
||||
### Applications
|
||||
- `coolify app list` - List all applications
|
||||
- `coolify app get <uuid>` - Get application details
|
||||
- `coolify app update <uuid>` - Update application configuration
|
||||
- `--name <name>` - Application name
|
||||
- `--description <description>` - Application description
|
||||
- `--git-branch <branch>` - Git branch
|
||||
- `--git-repository <url>` - Git repository URL
|
||||
- `--domains <domains>` - Domains (comma-separated)
|
||||
- `--build-command <cmd>` - Build command
|
||||
- `--start-command <cmd>` - Start command
|
||||
- `--install-command <cmd>` - Install command
|
||||
- `--base-directory <path>` - Base directory
|
||||
- `--publish-directory <path>` - Publish directory
|
||||
- `--dockerfile <content>` - Dockerfile content
|
||||
- `--docker-image <image>` - Docker image name
|
||||
- `--docker-tag <tag>` - Docker image tag
|
||||
- `--ports-exposes <ports>` - Exposed ports
|
||||
- `--ports-mappings <mappings>` - Port mappings
|
||||
- `--health-check-enabled` - Enable health check
|
||||
- `--health-check-path <path>` - Health check path
|
||||
- `coolify app delete <uuid>` - Delete an application
|
||||
- `-f, --force` - Skip confirmation prompt
|
||||
- `coolify app start <uuid>` - Start an application
|
||||
- `coolify app stop <uuid>` - Stop an application
|
||||
- `coolify app restart <uuid>` - Restart an application
|
||||
- `coolify app logs <uuid>` - Get application logs
|
||||
|
||||
#### Application Environment Variables
|
||||
- `coolify app env list <app_uuid>` - List all environment variables
|
||||
- `coolify app env get <app_uuid> <env_uuid_or_key>` - Get a specific environment variable
|
||||
- `coolify app env create <app_uuid>` - Create a new environment variable
|
||||
- `--key <key>` - Variable key (required)
|
||||
- `--value <value>` - Variable value (required)
|
||||
- `--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
|
||||
- `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)
|
||||
- `--build-time` - Make all variables available at build time
|
||||
- `--preview` - Make all variables available in preview deployments
|
||||
- `--is-literal` - Treat all values as literal (don't interpolate variables)
|
||||
- **Behavior**: Updates existing variables, creates missing ones. Does NOT delete variables not in the file.
|
||||
|
||||
#### Application Deployments
|
||||
- `coolify app deployments list <app-uuid>` - List all deployments for an application
|
||||
- `coolify app deployments logs <app-uuid> [deployment-uuid]` - Get deployment logs (formatted as human-readable text)
|
||||
- If only `app-uuid` is provided: retrieves logs from the **latest/most recent deployment only**
|
||||
- If `deployment-uuid` is also provided: retrieves logs for that **specific deployment**
|
||||
- `-n, --lines <n>` - Number of log lines to display (default: 0 = all lines)
|
||||
- `-f, --follow` - Follow log output in real-time (like tail -f)
|
||||
- `--debuglogs` - Show debug logs (includes hidden commands and internal operations)
|
||||
|
||||
### Databases
|
||||
- `coolify database list` - List all databases
|
||||
- `coolify database get <uuid>` - Get database details
|
||||
- `coolify database create <type>` - Create a new database
|
||||
- Supported types: `postgresql`, `mysql`, `mariadb`, `mongodb`, `redis`, `keydb`, `clickhouse`, `dragonfly`
|
||||
- `--server-uuid <uuid>` - Server UUID (required)
|
||||
- `--project-uuid <uuid>` - Project UUID (required)
|
||||
- `--environment-name <name>` - Environment name (required unless using --environment-uuid)
|
||||
- `--environment-uuid <uuid>` - Environment UUID (required unless using --environment-name)
|
||||
- `--destination-uuid <uuid>` - Destination UUID if server has multiple destinations
|
||||
- `--name <name>` - Database name
|
||||
- `--description <description>` - Database description
|
||||
- `--image <image>` - Docker image
|
||||
- `--instant-deploy` - Deploy immediately after creation
|
||||
- `--is-public` - Make database publicly accessible
|
||||
- `--public-port <port>` - Public port number
|
||||
- `--limits-memory <size>` - Memory limit (e.g., '512m', '2g')
|
||||
- `--limits-cpus <cpus>` - CPU limit (e.g., '0.5', '2')
|
||||
- Database-specific flags (postgres-user, mysql-root-password, etc.)
|
||||
- `coolify database update <uuid>` - Update database configuration
|
||||
- `coolify database delete <uuid>` - Delete a database
|
||||
- `--delete-configurations` - Delete configurations (default: true)
|
||||
- `--delete-volumes` - Delete volumes (default: true)
|
||||
- `--docker-cleanup` - Run docker cleanup (default: true)
|
||||
- `--delete-connected-networks` - Delete connected networks (default: true)
|
||||
- `coolify database start <uuid>` - Start a database
|
||||
- `coolify database stop <uuid>` - Stop a database
|
||||
- `coolify database restart <uuid>` - Restart a database
|
||||
|
||||
#### Database Backups
|
||||
- `coolify database backup list <database_uuid>` - List all backup configurations
|
||||
- `coolify database backup create <database_uuid>` - Create a new backup configuration
|
||||
- `--frequency <cron>` - Backup frequency (cron expression)
|
||||
- `--enabled` - Enable backup schedule
|
||||
- `--save-s3` - Save backups to S3
|
||||
- `--s3-storage-uuid <uuid>` - S3 storage UUID
|
||||
- `--databases-to-backup <list>` - Comma-separated list of databases to backup
|
||||
- `--dump-all` - Dump all databases
|
||||
- `--retention-amount-local <n>` - Number of backups to retain locally
|
||||
- `--retention-days-local <n>` - Days to retain backups locally
|
||||
- `--retention-storage-local <size>` - Max storage for local backups (e.g., '1GB', '500MB')
|
||||
- `--retention-amount-s3 <n>` - Number of backups to retain in S3
|
||||
- `--retention-days-s3 <n>` - Days to retain backups in S3
|
||||
- `--retention-storage-s3 <size>` - Max storage for S3 backups (e.g., '1GB', '500MB')
|
||||
- `--timeout <seconds>` - Backup timeout in seconds
|
||||
- `--disable-local` - Disable local backup storage
|
||||
- `coolify database backup update <database_uuid> <backup_uuid>` - Update a backup configuration
|
||||
- `coolify database backup delete <database_uuid> <backup_uuid>` - Delete a backup configuration
|
||||
- `coolify database backup trigger <database_uuid> <backup_uuid>` - Trigger an immediate backup
|
||||
- `coolify database backup executions <database_uuid> <backup_uuid>` - List backup executions
|
||||
- `coolify database backup delete-execution <database_uuid> <backup_uuid> <execution_uuid>` - Delete a backup execution
|
||||
|
||||
### Services
|
||||
- `coolify service list` - List all services
|
||||
- `coolify service get <uuid>` - Get service details
|
||||
- `coolify service start <uuid>` - Start a service
|
||||
- `coolify service stop <uuid>` - Stop a service
|
||||
- `coolify service restart <uuid>` - Restart a service
|
||||
- `coolify service delete <uuid>` - Delete a service
|
||||
|
||||
#### Service Environment Variables
|
||||
- `coolify service env list <service_uuid>` - List all environment variables
|
||||
- `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_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)
|
||||
- `--build-time` - Make all variables available at build time
|
||||
- `--preview` - Make all variables available in preview deployments
|
||||
- `--is-literal` - Treat all values as literal (don't interpolate variables)
|
||||
- **Behavior**: Updates existing variables, creates missing ones. Does NOT delete variables not in the file.
|
||||
|
||||
### 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
|
||||
- `-f, --force` - Skip confirmation prompt
|
||||
|
||||
### GitHub Apps
|
||||
- `coolify github list` - List all GitHub App integrations
|
||||
- `coolify github get <app_uuid>` - Get GitHub App details
|
||||
- `coolify github create` - Create a new GitHub App integration
|
||||
- `--name <name>` - GitHub App name (required)
|
||||
- `--api-url <url>` - GitHub API URL (required, e.g., https://api.github.com)
|
||||
- `--html-url <url>` - GitHub HTML URL (required, e.g., https://github.com)
|
||||
- `--app-id <id>` - GitHub App ID (required)
|
||||
- `--installation-id <id>` - GitHub Installation ID (required)
|
||||
- `--client-id <id>` - GitHub OAuth Client ID (required)
|
||||
- `--client-secret <secret>` - GitHub OAuth Client Secret (required)
|
||||
- `--private-key-uuid <uuid>` - UUID of existing private key (required)
|
||||
- `--organization <org>` - GitHub organization
|
||||
- `--custom-user <user>` - Custom user for SSH (default: git)
|
||||
- `--custom-port <port>` - Custom port for SSH (default: 22)
|
||||
- `--webhook-secret <secret>` - GitHub Webhook Secret
|
||||
- `--system-wide` - Is this app system-wide (cloud only)
|
||||
- `coolify github update <app_uuid>` - Update a GitHub App
|
||||
- `coolify github delete <app_uuid>` - Delete a GitHub App
|
||||
- `-f, --force` - Skip confirmation prompt
|
||||
- `coolify github repos <app_uuid>` - List repositories accessible by a GitHub App
|
||||
- `coolify github branches <app_uuid> <owner/repo>` - List branches for a repository
|
||||
|
||||
### Teams
|
||||
- `coolify team list` - List all teams
|
||||
- `coolify team get <team_id>` - Get team details
|
||||
- `coolify team current` - Get current team
|
||||
- `coolify team members list [team_id]` - List team members
|
||||
|
||||
### Private Keys
|
||||
|
||||
Commands can use `private-key`, `private-keys`, `key`, or `keys` interchangeably.
|
||||
|
||||
- `coolify private-key list` - List all private keys
|
||||
- `coolify private-key add <key_name> <private-key>` - Add a new private key
|
||||
- Use `@filename` to read from file: `coolify private-key add mykey @~/.ssh/id_rsa`
|
||||
- `coolify private-key remove <uuid>` - Remove a private key
|
||||
|
||||
## Global Flags
|
||||
|
||||
All commands support these global flags:
|
||||
|
||||
- `--context <name>` - Use a specific context instead of default
|
||||
- `--host <fqdn>` - Override the Coolify instance hostname
|
||||
- `--token <token>` - Override the authentication token
|
||||
- `--format <format>` - Output format: `table` (default), `json`, or `pretty`
|
||||
- `-s, --show-sensitive` - Show sensitive information (tokens, IPs, etc.)
|
||||
- `-f, --force` - Force operation (skip confirmations)
|
||||
- `--debug` - Enable debug mode
|
||||
|
||||
## Examples
|
||||
|
||||
### Multi-Environment Workflows
|
||||
|
||||
```bash
|
||||
# Add multiple contexts
|
||||
coolify context add prod https://prod.coolify.io <prod-token>
|
||||
coolify context add staging https://staging.coolify.io <staging-token>
|
||||
coolify context add dev https://dev.coolify.io <dev-token>
|
||||
|
||||
# Set default
|
||||
coolify context use prod
|
||||
|
||||
# Use different contexts
|
||||
coolify --context=staging servers list
|
||||
coolify --context=prod deploy name api
|
||||
coolify --context=dev resources list
|
||||
|
||||
# Default context (prod in this case)
|
||||
coolify servers list
|
||||
```
|
||||
|
||||
### Application Management
|
||||
|
||||
```bash
|
||||
# List all applications
|
||||
coolify app list
|
||||
|
||||
# Get application details
|
||||
coolify app get <uuid>
|
||||
|
||||
# Manage application lifecycle
|
||||
coolify app start <uuid>
|
||||
coolify app stop <uuid>
|
||||
coolify app restart <uuid>
|
||||
|
||||
# View application logs
|
||||
coolify app logs <uuid>
|
||||
|
||||
# Environment variables
|
||||
coolify app env list <uuid>
|
||||
coolify app env create <uuid> --key API_KEY --value secret123
|
||||
|
||||
# Sync from .env file (updates existing, creates new, keeps others unchanged)
|
||||
coolify app env sync <uuid> --file .env
|
||||
coolify app env sync <uuid> --file .env.production --build-time --preview
|
||||
```
|
||||
|
||||
### Database Management
|
||||
|
||||
```bash
|
||||
# List databases
|
||||
coolify database list
|
||||
|
||||
# Create a PostgreSQL database
|
||||
coolify database create postgresql \
|
||||
--server-uuid <server-uuid> \
|
||||
--project-uuid <project-uuid> \
|
||||
--name mydb \
|
||||
--instant-deploy
|
||||
|
||||
# Manage database lifecycle
|
||||
coolify database start <uuid>
|
||||
coolify database stop <uuid>
|
||||
coolify database restart <uuid>
|
||||
|
||||
# Backup management
|
||||
coolify database backup list <database-uuid>
|
||||
coolify database backup create <database-uuid> \
|
||||
--frequency "0 2 * * *" \
|
||||
--enabled \
|
||||
--save-s3 \
|
||||
--retention-days-locally 7
|
||||
coolify database backup trigger <database-uuid> <backup-uuid>
|
||||
```
|
||||
|
||||
### Service Management
|
||||
|
||||
```bash
|
||||
# List services
|
||||
coolify service list
|
||||
|
||||
# Get service details
|
||||
coolify service get <uuid>
|
||||
|
||||
# Manage services
|
||||
coolify service start <uuid>
|
||||
coolify service restart <uuid>
|
||||
|
||||
# Environment variables (same as applications)
|
||||
coolify service env sync <uuid> --file .env
|
||||
```
|
||||
|
||||
### Deploy Workflows
|
||||
|
||||
```bash
|
||||
# Deploy single app by name (easier than UUID)
|
||||
coolify deploy name my-application
|
||||
|
||||
# Deploy multiple apps at once
|
||||
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-...
|
||||
|
||||
# Monitor deployments
|
||||
coolify deploy list
|
||||
coolify deploy get <deployment-uuid>
|
||||
|
||||
# Cancel a deployment
|
||||
coolify deploy cancel <deployment-uuid>
|
||||
```
|
||||
|
||||
### GitHub Apps Integration
|
||||
|
||||
```bash
|
||||
# List GitHub Apps
|
||||
coolify github list
|
||||
|
||||
# Create a GitHub App integration
|
||||
coolify github create \
|
||||
--name "My GitHub App" \
|
||||
--api-url "https://api.github.com" \
|
||||
--html-url "https://github.com" \
|
||||
--app-id 123456 \
|
||||
--installation-id 789012 \
|
||||
--client-id "Iv1.abc123" \
|
||||
--client-secret "secret" \
|
||||
--private-key-uuid <key-uuid>
|
||||
|
||||
# List repositories accessible by the app
|
||||
coolify github repos <app-uuid>
|
||||
|
||||
# List branches for a repository
|
||||
coolify github branches <app-uuid> owner/repo
|
||||
|
||||
# Delete a GitHub App
|
||||
coolify github delete <app-uuid>
|
||||
```
|
||||
|
||||
### Team Management
|
||||
|
||||
```bash
|
||||
# List teams
|
||||
coolify team list
|
||||
|
||||
# Get current team
|
||||
coolify team current
|
||||
|
||||
# List team members
|
||||
coolify team members list
|
||||
```
|
||||
|
||||
### Server Management
|
||||
|
||||
```bash
|
||||
# List servers in production
|
||||
coolify --context=prod server list
|
||||
|
||||
# Add a server with validation
|
||||
coolify server add myserver 192.168.1.100 <key-uuid> --validate
|
||||
|
||||
# Get server details with resources
|
||||
coolify server get <uuid> --resources
|
||||
```
|
||||
|
||||
## Output Formats
|
||||
|
||||
The CLI supports three output formats:
|
||||
|
||||
```bash
|
||||
# Table format (default, human-readable)
|
||||
coolify server list
|
||||
|
||||
# JSON format (for scripts)
|
||||
coolify server list --format=json
|
||||
|
||||
# Pretty JSON (for debugging)
|
||||
coolify server list --format=pretty
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
This CLI follows a clean architecture with:
|
||||
- **Service Layer**: Business logic and API interactions
|
||||
- **Output Layer**: Consistent formatting across all commands
|
||||
- **Config Layer**: Multi-context configuration management
|
||||
- **Models Layer**: Type-safe data structures
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
# Build
|
||||
go build -o coolify ./coolify
|
||||
|
||||
# Run tests
|
||||
go test ./...
|
||||
|
||||
# Run with coverage
|
||||
go test -cover ./...
|
||||
|
||||
# Install locally
|
||||
go install ./coolify
|
||||
```
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions are welcome!
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
package application
|
||||
|
||||
import (
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"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
|
||||
func NewAppCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "app",
|
||||
Aliases: []string{"apps", "application", "applications"},
|
||||
Short: "Application related commands",
|
||||
Long: `Manage Coolify applications - list, get, create, update, delete, and control application lifecycle.`,
|
||||
}
|
||||
|
||||
// Add main subcommands
|
||||
cmd.AddCommand(NewListCommand())
|
||||
cmd.AddCommand(NewGetCommand())
|
||||
cmd.AddCommand(create.NewCreateCommand())
|
||||
cmd.AddCommand(NewUpdateCommand())
|
||||
cmd.AddCommand(NewDeleteCommand())
|
||||
cmd.AddCommand(NewStartCommand())
|
||||
cmd.AddCommand(NewStopCommand())
|
||||
cmd.AddCommand(NewRestartCommand())
|
||||
cmd.AddCommand(NewLogsCommand())
|
||||
cmd.AddCommand(NewDeploymentsCommand())
|
||||
|
||||
// Add env subcommand with its children
|
||||
envCmd := &cobra.Command{
|
||||
Use: "env",
|
||||
Aliases: []string{"envs", "environment"},
|
||||
Short: "Manage application environment variables",
|
||||
Long: `List and manage environment variables for applications. All commands require the application UUID first to establish context.`,
|
||||
}
|
||||
envCmd.AddCommand(env.NewListEnvCommand())
|
||||
envCmd.AddCommand(env.NewGetEnvCommand())
|
||||
envCmd.AddCommand(env.NewCreateEnvCommand())
|
||||
envCmd.AddCommand(env.NewUpdateEnvCommand())
|
||||
envCmd.AddCommand(env.NewDeleteEnvCommand())
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package create
|
||||
|
||||
import "github.com/spf13/cobra"
|
||||
|
||||
// NewCreateCommand creates the create parent command with all subcommands
|
||||
func NewCreateCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "create",
|
||||
Short: "Create a new application",
|
||||
Long: `Create a new application from various sources.
|
||||
|
||||
Available source types:
|
||||
public Create from a public git repository
|
||||
github Create from a private repository using GitHub App
|
||||
deploy-key Create from a private repository using SSH deploy key
|
||||
dockerfile Create from a custom Dockerfile
|
||||
dockerimage Create from a pre-built Docker image
|
||||
|
||||
Examples:
|
||||
coolify app create public --server-uuid <uuid> --project-uuid <uuid> --environment-name production \
|
||||
--git-repository "https://github.com/user/repo" --git-branch main --build-pack nixpacks --ports-exposes 3000
|
||||
|
||||
coolify app create github --server-uuid <uuid> --project-uuid <uuid> --environment-name production \
|
||||
--github-app-uuid <uuid> --git-repository "user/repo" --git-branch main --build-pack nixpacks --ports-exposes 3000
|
||||
|
||||
coolify app create dockerimage --server-uuid <uuid> --project-uuid <uuid> --environment-name production \
|
||||
--docker-registry-image-name "nginx:latest" --ports-exposes 80`,
|
||||
}
|
||||
|
||||
// Add all create subcommands
|
||||
cmd.AddCommand(NewPublicCommand())
|
||||
cmd.AddCommand(NewGitHubCommand())
|
||||
cmd.AddCommand(NewDeployKeyCommand())
|
||||
cmd.AddCommand(NewDockerfileCommand())
|
||||
cmd.AddCommand(NewDockerImageCommand())
|
||||
|
||||
return cmd
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
package create
|
||||
|
||||
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/output"
|
||||
"github.com/coollabsio/coolify-cli/internal/service"
|
||||
)
|
||||
|
||||
// NewDeployKeyCommand returns the create deploy-key application command
|
||||
func NewDeployKeyCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "deploy-key",
|
||||
Short: "Create an application from a private repository using SSH deploy key",
|
||||
Long: `Create a new application from a private git repository using SSH deploy key authentication.
|
||||
|
||||
Use 'coolify privatekeys list' to find your private key UUID.
|
||||
|
||||
Examples:
|
||||
coolify app create deploy-key --server-uuid <uuid> --project-uuid <uuid> --environment-name production \
|
||||
--private-key-uuid <uuid> --git-repository "git@github.com:owner/repo.git" --git-branch main \
|
||||
--build-pack nixpacks --ports-exposes 3000
|
||||
|
||||
coolify app create deploy-key --server-uuid <uuid> --project-uuid <uuid> --environment-name production \
|
||||
--private-key-uuid <uuid> --git-repository "git@gitlab.com:owner/repo.git" --git-branch main \
|
||||
--build-pack dockerfile --ports-exposes 8080 --instant-deploy`,
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
ctx := cmd.Context()
|
||||
|
||||
// Get required flags
|
||||
serverUUID, _ := cmd.Flags().GetString("server-uuid")
|
||||
projectUUID, _ := cmd.Flags().GetString("project-uuid")
|
||||
privateKeyUUID, _ := cmd.Flags().GetString("private-key-uuid")
|
||||
gitRepository, _ := cmd.Flags().GetString("git-repository")
|
||||
gitBranch, _ := cmd.Flags().GetString("git-branch")
|
||||
buildPack, _ := cmd.Flags().GetString("build-pack")
|
||||
portsExposes, _ := cmd.Flags().GetString("ports-exposes")
|
||||
environmentName, _ := cmd.Flags().GetString("environment-name")
|
||||
environmentUUID, _ := cmd.Flags().GetString("environment-uuid")
|
||||
|
||||
// Validate required fields
|
||||
if serverUUID == "" || projectUUID == "" {
|
||||
return fmt.Errorf("--server-uuid and --project-uuid are required")
|
||||
}
|
||||
if privateKeyUUID == "" {
|
||||
return fmt.Errorf("--private-key-uuid is required")
|
||||
}
|
||||
if gitRepository == "" || gitBranch == "" {
|
||||
return fmt.Errorf("--git-repository and --git-branch are required")
|
||||
}
|
||||
if buildPack == "" || portsExposes == "" {
|
||||
return fmt.Errorf("--build-pack and --ports-exposes are required")
|
||||
}
|
||||
if environmentName == "" && environmentUUID == "" {
|
||||
return fmt.Errorf("either --environment-name or --environment-uuid must be provided")
|
||||
}
|
||||
|
||||
req := &models.ApplicationCreateDeployKeyRequest{
|
||||
ServerUUID: serverUUID,
|
||||
ProjectUUID: projectUUID,
|
||||
PrivateKeyUUID: privateKeyUUID,
|
||||
GitRepository: gitRepository,
|
||||
GitBranch: gitBranch,
|
||||
BuildPack: buildPack,
|
||||
PortsExposes: portsExposes,
|
||||
}
|
||||
|
||||
if environmentName != "" {
|
||||
req.EnvironmentName = &environmentName
|
||||
}
|
||||
if environmentUUID != "" {
|
||||
req.EnvironmentUUID = &environmentUUID
|
||||
}
|
||||
|
||||
// Optional fields
|
||||
setOptionalStringFlag(cmd, "name", &req.Name)
|
||||
setOptionalStringFlag(cmd, "description", &req.Description)
|
||||
setOptionalStringFlag(cmd, "domains", &req.Domains)
|
||||
setOptionalStringFlag(cmd, "git-commit-sha", &req.GitCommitSHA)
|
||||
setOptionalStringFlag(cmd, "destination-uuid", &req.DestinationUUID)
|
||||
setOptionalStringFlag(cmd, "build-command", &req.BuildCommand)
|
||||
setOptionalStringFlag(cmd, "start-command", &req.StartCommand)
|
||||
setOptionalStringFlag(cmd, "install-command", &req.InstallCommand)
|
||||
setOptionalStringFlag(cmd, "base-directory", &req.BaseDirectory)
|
||||
setOptionalStringFlag(cmd, "publish-directory", &req.PublishDirectory)
|
||||
setOptionalStringFlag(cmd, "ports-mappings", &req.PortsMappings)
|
||||
setOptionalStringFlag(cmd, "limits-cpus", &req.LimitsCPUs)
|
||||
setOptionalStringFlag(cmd, "limits-memory", &req.LimitsMemory)
|
||||
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 {
|
||||
return fmt.Errorf("failed to get API client: %w", err)
|
||||
}
|
||||
|
||||
appSvc := service.NewApplicationService(client)
|
||||
app, err := appSvc.CreateDeployKey(ctx, req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create application: %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(app)
|
||||
},
|
||||
}
|
||||
|
||||
// Required flags
|
||||
cmd.Flags().String("server-uuid", "", "Server UUID (required)")
|
||||
cmd.Flags().String("project-uuid", "", "Project UUID (required)")
|
||||
cmd.Flags().String("environment-name", "", "Environment name")
|
||||
cmd.Flags().String("environment-uuid", "", "Environment UUID")
|
||||
cmd.Flags().String("private-key-uuid", "", "Private key UUID (required)")
|
||||
cmd.Flags().String("git-repository", "", "Git repository SSH URL, e.g., 'git@github.com:owner/repo.git' (required)")
|
||||
cmd.Flags().String("git-branch", "", "Git branch (required)")
|
||||
cmd.Flags().String("build-pack", "", "Build pack: nixpacks, static, dockerfile, dockercompose (required)")
|
||||
cmd.Flags().String("ports-exposes", "", "Exposed ports, e.g., '3000' or '3000,8080' (required)")
|
||||
|
||||
// Optional flags
|
||||
cmd.Flags().String("name", "", "Application name")
|
||||
cmd.Flags().String("description", "", "Application description")
|
||||
cmd.Flags().String("domains", "", "Domain(s) for the application")
|
||||
cmd.Flags().Bool("instant-deploy", false, "Deploy immediately after creation")
|
||||
cmd.Flags().String("git-commit-sha", "", "Specific commit SHA to deploy")
|
||||
cmd.Flags().String("destination-uuid", "", "Destination UUID if server has multiple destinations")
|
||||
cmd.Flags().String("build-command", "", "Custom build command")
|
||||
cmd.Flags().String("start-command", "", "Custom start command")
|
||||
cmd.Flags().String("install-command", "", "Custom install command")
|
||||
cmd.Flags().String("base-directory", "", "Base directory for the application")
|
||||
cmd.Flags().String("publish-directory", "", "Publish directory for static builds")
|
||||
cmd.Flags().String("ports-mappings", "", "Port mappings (host:container)")
|
||||
cmd.Flags().String("limits-cpus", "", "CPU limit")
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
package create
|
||||
|
||||
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/output"
|
||||
"github.com/coollabsio/coolify-cli/internal/service"
|
||||
)
|
||||
|
||||
// NewDockerfileCommand returns the create dockerfile application command
|
||||
func NewDockerfileCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "dockerfile",
|
||||
Short: "Create an application from a custom Dockerfile",
|
||||
Long: `Create a new application from a custom Dockerfile content.
|
||||
|
||||
Examples:
|
||||
coolify app create dockerfile --server-uuid <uuid> --project-uuid <uuid> --environment-name production \
|
||||
--dockerfile "FROM node:18\nWORKDIR /app\nCOPY . .\nRUN npm install\nCMD [\"npm\", \"start\"]"
|
||||
|
||||
coolify app create dockerfile --server-uuid <uuid> --project-uuid <uuid> --environment-name production \
|
||||
--dockerfile "$(cat Dockerfile)" --ports-exposes 3000 --instant-deploy`,
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
ctx := cmd.Context()
|
||||
|
||||
// Get required flags
|
||||
serverUUID, _ := cmd.Flags().GetString("server-uuid")
|
||||
projectUUID, _ := cmd.Flags().GetString("project-uuid")
|
||||
dockerfile, _ := cmd.Flags().GetString("dockerfile")
|
||||
environmentName, _ := cmd.Flags().GetString("environment-name")
|
||||
environmentUUID, _ := cmd.Flags().GetString("environment-uuid")
|
||||
|
||||
// Validate required fields
|
||||
if serverUUID == "" || projectUUID == "" {
|
||||
return fmt.Errorf("--server-uuid and --project-uuid are required")
|
||||
}
|
||||
if dockerfile == "" {
|
||||
return fmt.Errorf("--dockerfile is required")
|
||||
}
|
||||
if environmentName == "" && environmentUUID == "" {
|
||||
return fmt.Errorf("either --environment-name or --environment-uuid must be provided")
|
||||
}
|
||||
|
||||
req := &models.ApplicationCreateDockerfileRequest{
|
||||
ServerUUID: serverUUID,
|
||||
ProjectUUID: projectUUID,
|
||||
Dockerfile: dockerfile,
|
||||
}
|
||||
|
||||
if environmentName != "" {
|
||||
req.EnvironmentName = &environmentName
|
||||
}
|
||||
if environmentUUID != "" {
|
||||
req.EnvironmentUUID = &environmentUUID
|
||||
}
|
||||
|
||||
// Optional fields
|
||||
setOptionalStringFlag(cmd, "name", &req.Name)
|
||||
setOptionalStringFlag(cmd, "description", &req.Description)
|
||||
setOptionalStringFlag(cmd, "domains", &req.Domains)
|
||||
setOptionalStringFlag(cmd, "destination-uuid", &req.DestinationUUID)
|
||||
setOptionalStringFlag(cmd, "ports-exposes", &req.PortsExposes)
|
||||
setOptionalStringFlag(cmd, "ports-mappings", &req.PortsMappings)
|
||||
setOptionalStringFlag(cmd, "limits-cpus", &req.LimitsCPUs)
|
||||
setOptionalStringFlag(cmd, "limits-memory", &req.LimitsMemory)
|
||||
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 {
|
||||
return fmt.Errorf("failed to get API client: %w", err)
|
||||
}
|
||||
|
||||
appSvc := service.NewApplicationService(client)
|
||||
app, err := appSvc.CreateDockerfile(ctx, req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create application: %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(app)
|
||||
},
|
||||
}
|
||||
|
||||
// Required flags
|
||||
cmd.Flags().String("server-uuid", "", "Server UUID (required)")
|
||||
cmd.Flags().String("project-uuid", "", "Project UUID (required)")
|
||||
cmd.Flags().String("environment-name", "", "Environment name")
|
||||
cmd.Flags().String("environment-uuid", "", "Environment UUID")
|
||||
cmd.Flags().String("dockerfile", "", "Dockerfile content (required)")
|
||||
|
||||
// Optional flags
|
||||
cmd.Flags().String("name", "", "Application name")
|
||||
cmd.Flags().String("description", "", "Application description")
|
||||
cmd.Flags().String("domains", "", "Domain(s) for the application")
|
||||
cmd.Flags().Bool("instant-deploy", false, "Deploy immediately after creation")
|
||||
cmd.Flags().String("destination-uuid", "", "Destination UUID if server has multiple destinations")
|
||||
cmd.Flags().String("ports-exposes", "", "Exposed ports, e.g., '3000' or '3000,8080'")
|
||||
cmd.Flags().String("ports-mappings", "", "Port mappings (host:container)")
|
||||
cmd.Flags().String("limits-cpus", "", "CPU limit")
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
package create
|
||||
|
||||
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/output"
|
||||
"github.com/coollabsio/coolify-cli/internal/service"
|
||||
)
|
||||
|
||||
// NewDockerImageCommand returns the create dockerimage application command
|
||||
func NewDockerImageCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "dockerimage",
|
||||
Short: "Create an application from a pre-built Docker image",
|
||||
Long: `Create a new application from a pre-built Docker image from a registry.
|
||||
|
||||
Examples:
|
||||
coolify app create dockerimage --server-uuid <uuid> --project-uuid <uuid> --environment-name production \
|
||||
--docker-registry-image-name "nginx:latest" --ports-exposes 80
|
||||
|
||||
coolify app create dockerimage --server-uuid <uuid> --project-uuid <uuid> --environment-name production \
|
||||
--docker-registry-image-name "ghcr.io/myorg/myapp" --docker-registry-image-tag "v1.0.0" \
|
||||
--ports-exposes 3000 --domains "myapp.example.com" --instant-deploy`,
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
ctx := cmd.Context()
|
||||
|
||||
// Get required flags
|
||||
serverUUID, _ := cmd.Flags().GetString("server-uuid")
|
||||
projectUUID, _ := cmd.Flags().GetString("project-uuid")
|
||||
dockerRegistryImageName, _ := cmd.Flags().GetString("docker-registry-image-name")
|
||||
portsExposes, _ := cmd.Flags().GetString("ports-exposes")
|
||||
environmentName, _ := cmd.Flags().GetString("environment-name")
|
||||
environmentUUID, _ := cmd.Flags().GetString("environment-uuid")
|
||||
|
||||
// Validate required fields
|
||||
if serverUUID == "" || projectUUID == "" {
|
||||
return fmt.Errorf("--server-uuid and --project-uuid are required")
|
||||
}
|
||||
if dockerRegistryImageName == "" {
|
||||
return fmt.Errorf("--docker-registry-image-name is required")
|
||||
}
|
||||
if portsExposes == "" {
|
||||
return fmt.Errorf("--ports-exposes is required")
|
||||
}
|
||||
if environmentName == "" && environmentUUID == "" {
|
||||
return fmt.Errorf("either --environment-name or --environment-uuid must be provided")
|
||||
}
|
||||
|
||||
req := &models.ApplicationCreateDockerImageRequest{
|
||||
ServerUUID: serverUUID,
|
||||
ProjectUUID: projectUUID,
|
||||
DockerRegistryImageName: dockerRegistryImageName,
|
||||
PortsExposes: portsExposes,
|
||||
}
|
||||
|
||||
if environmentName != "" {
|
||||
req.EnvironmentName = &environmentName
|
||||
}
|
||||
if environmentUUID != "" {
|
||||
req.EnvironmentUUID = &environmentUUID
|
||||
}
|
||||
|
||||
// Optional fields
|
||||
setOptionalStringFlag(cmd, "name", &req.Name)
|
||||
setOptionalStringFlag(cmd, "description", &req.Description)
|
||||
setOptionalStringFlag(cmd, "domains", &req.Domains)
|
||||
setOptionalStringFlag(cmd, "destination-uuid", &req.DestinationUUID)
|
||||
setOptionalStringFlag(cmd, "docker-registry-image-tag", &req.DockerRegistryImageTag)
|
||||
setOptionalStringFlag(cmd, "ports-mappings", &req.PortsMappings)
|
||||
setOptionalStringFlag(cmd, "limits-cpus", &req.LimitsCPUs)
|
||||
setOptionalStringFlag(cmd, "limits-memory", &req.LimitsMemory)
|
||||
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 {
|
||||
return fmt.Errorf("failed to get API client: %w", err)
|
||||
}
|
||||
|
||||
appSvc := service.NewApplicationService(client)
|
||||
app, err := appSvc.CreateDockerImage(ctx, req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create application: %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(app)
|
||||
},
|
||||
}
|
||||
|
||||
// Required flags
|
||||
cmd.Flags().String("server-uuid", "", "Server UUID (required)")
|
||||
cmd.Flags().String("project-uuid", "", "Project UUID (required)")
|
||||
cmd.Flags().String("environment-name", "", "Environment name")
|
||||
cmd.Flags().String("environment-uuid", "", "Environment UUID")
|
||||
cmd.Flags().String("docker-registry-image-name", "", "Docker image name from registry (required)")
|
||||
cmd.Flags().String("ports-exposes", "", "Exposed ports, e.g., '80' or '80,443' (required)")
|
||||
|
||||
// Optional flags
|
||||
cmd.Flags().String("name", "", "Application name")
|
||||
cmd.Flags().String("description", "", "Application description")
|
||||
cmd.Flags().String("domains", "", "Domain(s) for the application")
|
||||
cmd.Flags().Bool("instant-deploy", false, "Deploy immediately after creation")
|
||||
cmd.Flags().String("destination-uuid", "", "Destination UUID if server has multiple destinations")
|
||||
cmd.Flags().String("docker-registry-image-tag", "", "Docker image tag (defaults to 'latest')")
|
||||
cmd.Flags().String("ports-mappings", "", "Port mappings (host:container)")
|
||||
cmd.Flags().String("limits-cpus", "", "CPU limit")
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
package create
|
||||
|
||||
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/output"
|
||||
"github.com/coollabsio/coolify-cli/internal/service"
|
||||
)
|
||||
|
||||
// NewGitHubCommand returns the create github application command
|
||||
func NewGitHubCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "github",
|
||||
Short: "Create an application from a private repository using GitHub App",
|
||||
Long: `Create a new application from a private git repository using GitHub App authentication.
|
||||
|
||||
Use 'coolify github list' to find your GitHub App UUID.
|
||||
Use 'coolify github repos <app-uuid>' to list accessible repositories.
|
||||
|
||||
Examples:
|
||||
coolify app create github --server-uuid <uuid> --project-uuid <uuid> --environment-name production \
|
||||
--github-app-uuid <uuid> --git-repository "owner/repo" --git-branch main \
|
||||
--build-pack nixpacks --ports-exposes 3000
|
||||
|
||||
coolify app create github --server-uuid <uuid> --project-uuid <uuid> --environment-name production \
|
||||
--github-app-uuid <uuid> --git-repository "owner/repo" --git-branch main \
|
||||
--build-pack dockerfile --ports-exposes 8080 --instant-deploy`,
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
ctx := cmd.Context()
|
||||
|
||||
// Get required flags
|
||||
serverUUID, _ := cmd.Flags().GetString("server-uuid")
|
||||
projectUUID, _ := cmd.Flags().GetString("project-uuid")
|
||||
gitHubAppUUID, _ := cmd.Flags().GetString("github-app-uuid")
|
||||
gitRepository, _ := cmd.Flags().GetString("git-repository")
|
||||
gitBranch, _ := cmd.Flags().GetString("git-branch")
|
||||
buildPack, _ := cmd.Flags().GetString("build-pack")
|
||||
portsExposes, _ := cmd.Flags().GetString("ports-exposes")
|
||||
environmentName, _ := cmd.Flags().GetString("environment-name")
|
||||
environmentUUID, _ := cmd.Flags().GetString("environment-uuid")
|
||||
|
||||
// Validate required fields
|
||||
if serverUUID == "" || projectUUID == "" {
|
||||
return fmt.Errorf("--server-uuid and --project-uuid are required")
|
||||
}
|
||||
if gitHubAppUUID == "" {
|
||||
return fmt.Errorf("--github-app-uuid is required")
|
||||
}
|
||||
if gitRepository == "" || gitBranch == "" {
|
||||
return fmt.Errorf("--git-repository and --git-branch are required")
|
||||
}
|
||||
if buildPack == "" || portsExposes == "" {
|
||||
return fmt.Errorf("--build-pack and --ports-exposes are required")
|
||||
}
|
||||
if environmentName == "" && environmentUUID == "" {
|
||||
return fmt.Errorf("either --environment-name or --environment-uuid must be provided")
|
||||
}
|
||||
|
||||
req := &models.ApplicationCreateGitHubAppRequest{
|
||||
ServerUUID: serverUUID,
|
||||
ProjectUUID: projectUUID,
|
||||
GitHubAppUUID: gitHubAppUUID,
|
||||
GitRepository: gitRepository,
|
||||
GitBranch: gitBranch,
|
||||
BuildPack: buildPack,
|
||||
PortsExposes: portsExposes,
|
||||
}
|
||||
|
||||
if environmentName != "" {
|
||||
req.EnvironmentName = &environmentName
|
||||
}
|
||||
if environmentUUID != "" {
|
||||
req.EnvironmentUUID = &environmentUUID
|
||||
}
|
||||
|
||||
// Optional fields
|
||||
setOptionalStringFlag(cmd, "name", &req.Name)
|
||||
setOptionalStringFlag(cmd, "description", &req.Description)
|
||||
setOptionalStringFlag(cmd, "domains", &req.Domains)
|
||||
setOptionalStringFlag(cmd, "git-commit-sha", &req.GitCommitSHA)
|
||||
setOptionalStringFlag(cmd, "destination-uuid", &req.DestinationUUID)
|
||||
setOptionalStringFlag(cmd, "build-command", &req.BuildCommand)
|
||||
setOptionalStringFlag(cmd, "start-command", &req.StartCommand)
|
||||
setOptionalStringFlag(cmd, "install-command", &req.InstallCommand)
|
||||
setOptionalStringFlag(cmd, "base-directory", &req.BaseDirectory)
|
||||
setOptionalStringFlag(cmd, "publish-directory", &req.PublishDirectory)
|
||||
setOptionalStringFlag(cmd, "ports-mappings", &req.PortsMappings)
|
||||
setOptionalStringFlag(cmd, "limits-cpus", &req.LimitsCPUs)
|
||||
setOptionalStringFlag(cmd, "limits-memory", &req.LimitsMemory)
|
||||
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 {
|
||||
return fmt.Errorf("failed to get API client: %w", err)
|
||||
}
|
||||
|
||||
appSvc := service.NewApplicationService(client)
|
||||
app, err := appSvc.CreateGitHubApp(ctx, req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create application: %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(app)
|
||||
},
|
||||
}
|
||||
|
||||
// Required flags
|
||||
cmd.Flags().String("server-uuid", "", "Server UUID (required)")
|
||||
cmd.Flags().String("project-uuid", "", "Project UUID (required)")
|
||||
cmd.Flags().String("environment-name", "", "Environment name")
|
||||
cmd.Flags().String("environment-uuid", "", "Environment UUID")
|
||||
cmd.Flags().String("github-app-uuid", "", "GitHub App UUID (required)")
|
||||
cmd.Flags().String("git-repository", "", "Git repository in format 'owner/repo' (required)")
|
||||
cmd.Flags().String("git-branch", "", "Git branch (required)")
|
||||
cmd.Flags().String("build-pack", "", "Build pack: nixpacks, static, dockerfile, dockercompose (required)")
|
||||
cmd.Flags().String("ports-exposes", "", "Exposed ports, e.g., '3000' or '3000,8080' (required)")
|
||||
|
||||
// Optional flags
|
||||
cmd.Flags().String("name", "", "Application name")
|
||||
cmd.Flags().String("description", "", "Application description")
|
||||
cmd.Flags().String("domains", "", "Domain(s) for the application")
|
||||
cmd.Flags().Bool("instant-deploy", false, "Deploy immediately after creation")
|
||||
cmd.Flags().String("git-commit-sha", "", "Specific commit SHA to deploy")
|
||||
cmd.Flags().String("destination-uuid", "", "Destination UUID if server has multiple destinations")
|
||||
cmd.Flags().String("build-command", "", "Custom build command")
|
||||
cmd.Flags().String("start-command", "", "Custom start command")
|
||||
cmd.Flags().String("install-command", "", "Custom install command")
|
||||
cmd.Flags().String("base-directory", "", "Base directory for the application")
|
||||
cmd.Flags().String("publish-directory", "", "Publish directory for static builds")
|
||||
cmd.Flags().String("ports-mappings", "", "Port mappings (host:container)")
|
||||
cmd.Flags().String("limits-cpus", "", "CPU limit")
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
package create
|
||||
|
||||
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/output"
|
||||
"github.com/coollabsio/coolify-cli/internal/service"
|
||||
)
|
||||
|
||||
// NewPublicCommand returns the create public application command
|
||||
func NewPublicCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "public",
|
||||
Short: "Create an application from a public git repository",
|
||||
Long: `Create a new application from a public git repository.
|
||||
|
||||
Examples:
|
||||
coolify app create public --server-uuid <uuid> --project-uuid <uuid> --environment-name production \
|
||||
--git-repository "https://github.com/user/repo" --git-branch main --build-pack nixpacks --ports-exposes 3000
|
||||
|
||||
coolify app create public --server-uuid <uuid> --project-uuid <uuid> --environment-name production \
|
||||
--git-repository "https://github.com/user/repo" --git-branch main --build-pack dockerfile --ports-exposes 8080 \
|
||||
--instant-deploy --domains "myapp.example.com"`,
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
ctx := cmd.Context()
|
||||
|
||||
// Get required flags
|
||||
serverUUID, _ := cmd.Flags().GetString("server-uuid")
|
||||
projectUUID, _ := cmd.Flags().GetString("project-uuid")
|
||||
gitRepository, _ := cmd.Flags().GetString("git-repository")
|
||||
gitBranch, _ := cmd.Flags().GetString("git-branch")
|
||||
buildPack, _ := cmd.Flags().GetString("build-pack")
|
||||
portsExposes, _ := cmd.Flags().GetString("ports-exposes")
|
||||
environmentName, _ := cmd.Flags().GetString("environment-name")
|
||||
environmentUUID, _ := cmd.Flags().GetString("environment-uuid")
|
||||
|
||||
// Validate required fields
|
||||
if serverUUID == "" || projectUUID == "" {
|
||||
return fmt.Errorf("--server-uuid and --project-uuid are required")
|
||||
}
|
||||
if gitRepository == "" || gitBranch == "" {
|
||||
return fmt.Errorf("--git-repository and --git-branch are required")
|
||||
}
|
||||
if buildPack == "" || portsExposes == "" {
|
||||
return fmt.Errorf("--build-pack and --ports-exposes are required")
|
||||
}
|
||||
if environmentName == "" && environmentUUID == "" {
|
||||
return fmt.Errorf("either --environment-name or --environment-uuid must be provided")
|
||||
}
|
||||
|
||||
req := &models.ApplicationCreatePublicRequest{
|
||||
ServerUUID: serverUUID,
|
||||
ProjectUUID: projectUUID,
|
||||
GitRepository: gitRepository,
|
||||
GitBranch: gitBranch,
|
||||
BuildPack: buildPack,
|
||||
PortsExposes: portsExposes,
|
||||
}
|
||||
|
||||
if environmentName != "" {
|
||||
req.EnvironmentName = &environmentName
|
||||
}
|
||||
if environmentUUID != "" {
|
||||
req.EnvironmentUUID = &environmentUUID
|
||||
}
|
||||
|
||||
// Optional fields
|
||||
setOptionalStringFlag(cmd, "name", &req.Name)
|
||||
setOptionalStringFlag(cmd, "description", &req.Description)
|
||||
setOptionalStringFlag(cmd, "domains", &req.Domains)
|
||||
setOptionalStringFlag(cmd, "git-commit-sha", &req.GitCommitSHA)
|
||||
setOptionalStringFlag(cmd, "destination-uuid", &req.DestinationUUID)
|
||||
setOptionalStringFlag(cmd, "build-command", &req.BuildCommand)
|
||||
setOptionalStringFlag(cmd, "start-command", &req.StartCommand)
|
||||
setOptionalStringFlag(cmd, "install-command", &req.InstallCommand)
|
||||
setOptionalStringFlag(cmd, "base-directory", &req.BaseDirectory)
|
||||
setOptionalStringFlag(cmd, "publish-directory", &req.PublishDirectory)
|
||||
setOptionalStringFlag(cmd, "ports-mappings", &req.PortsMappings)
|
||||
setOptionalStringFlag(cmd, "limits-cpus", &req.LimitsCPUs)
|
||||
setOptionalStringFlag(cmd, "limits-memory", &req.LimitsMemory)
|
||||
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 {
|
||||
return fmt.Errorf("failed to get API client: %w", err)
|
||||
}
|
||||
|
||||
appSvc := service.NewApplicationService(client)
|
||||
app, err := appSvc.CreatePublic(ctx, req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create application: %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(app)
|
||||
},
|
||||
}
|
||||
|
||||
// Required flags
|
||||
cmd.Flags().String("server-uuid", "", "Server UUID (required)")
|
||||
cmd.Flags().String("project-uuid", "", "Project UUID (required)")
|
||||
cmd.Flags().String("environment-name", "", "Environment name")
|
||||
cmd.Flags().String("environment-uuid", "", "Environment UUID")
|
||||
cmd.Flags().String("git-repository", "", "Git repository URL (required)")
|
||||
cmd.Flags().String("git-branch", "", "Git branch (required)")
|
||||
cmd.Flags().String("build-pack", "", "Build pack: nixpacks, static, dockerfile, dockercompose (required)")
|
||||
cmd.Flags().String("ports-exposes", "", "Exposed ports, e.g., '3000' or '3000,8080' (required)")
|
||||
|
||||
// Optional flags
|
||||
cmd.Flags().String("name", "", "Application name")
|
||||
cmd.Flags().String("description", "", "Application description")
|
||||
cmd.Flags().String("domains", "", "Domain(s) for the application")
|
||||
cmd.Flags().Bool("instant-deploy", false, "Deploy immediately after creation")
|
||||
cmd.Flags().String("git-commit-sha", "", "Specific commit SHA to deploy")
|
||||
cmd.Flags().String("destination-uuid", "", "Destination UUID if server has multiple destinations")
|
||||
cmd.Flags().String("build-command", "", "Custom build command")
|
||||
cmd.Flags().String("start-command", "", "Custom start command")
|
||||
cmd.Flags().String("install-command", "", "Custom install command")
|
||||
cmd.Flags().String("base-directory", "", "Base directory for the application")
|
||||
cmd.Flags().String("publish-directory", "", "Publish directory for static builds")
|
||||
cmd.Flags().String("ports-mappings", "", "Port mappings (host:container)")
|
||||
cmd.Flags().String("limits-cpus", "", "CPU limit")
|
||||
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
|
||||
}
|
||||
|
||||
// Helper functions for optional flags
|
||||
func setOptionalStringFlag(cmd *cobra.Command, flagName string, target **string) {
|
||||
if cmd.Flags().Changed(flagName) {
|
||||
val, _ := cmd.Flags().GetString(flagName)
|
||||
*target = &val
|
||||
}
|
||||
}
|
||||
|
||||
func setOptionalBoolFlag(cmd *cobra.Command, flagName string, target **bool) {
|
||||
if cmd.Flags().Changed(flagName) {
|
||||
val, _ := cmd.Flags().GetBool(flagName)
|
||||
*target = &val
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package application
|
||||
|
||||
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 <uuid>",
|
||||
Short: "Delete an application",
|
||||
Long: `Delete an application. This action cannot be undone.`,
|
||||
Args: cli.ExactArgs(1, "<uuid>"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := cmd.Context()
|
||||
uuid := args[0]
|
||||
|
||||
force, _ := cmd.Flags().GetBool("force")
|
||||
|
||||
if !force {
|
||||
var response string
|
||||
fmt.Printf("Are you sure you want to delete application %s? This cannot be undone. (yes/no): ", uuid)
|
||||
_, err := fmt.Scanln(&response)
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read input: %w", err)
|
||||
}
|
||||
|
||||
if response != "yes" && response != "y" {
|
||||
fmt.Println("Delete cancelled.")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
client, err := cli.GetAPIClient(cmd)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get API client: %w", err)
|
||||
}
|
||||
|
||||
appSvc := service.NewApplicationService(client)
|
||||
err = appSvc.Delete(ctx, uuid)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete application: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("Application %s deleted successfully.\n", uuid)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().BoolP("force", "f", false, "Skip confirmation prompt")
|
||||
return cmd
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
package application
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"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 NewDeploymentsCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "deployments",
|
||||
Short: "Deployment related commands for an application",
|
||||
Long: `Manage deployments for a specific application. List deployments or view deployment logs.`,
|
||||
}
|
||||
|
||||
cmd.AddCommand(NewListDeploymentsCommand())
|
||||
cmd.AddCommand(NewLogsDeploymentsCommand())
|
||||
return cmd
|
||||
}
|
||||
|
||||
func NewListDeploymentsCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "list <app-uuid>",
|
||||
Short: "List all deployments for an application",
|
||||
Long: `Retrieve a list of all deployments for a specific application.`,
|
||||
Args: cli.ExactArgs(1, "<app-uuid>"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := context.Background()
|
||||
appUUID := args[0]
|
||||
|
||||
client, err := cli.GetAPIClient(cmd)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get API client: %w", err)
|
||||
}
|
||||
|
||||
deploySvc := service.NewDeploymentService(client)
|
||||
deployments, err := deploySvc.ListByApplication(ctx, appUUID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to list deployments: %w", err)
|
||||
}
|
||||
|
||||
format, _ := cmd.Flags().GetString("format")
|
||||
formatter, err := output.NewFormatter(format, output.Options{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return formatter.Format(deployments)
|
||||
},
|
||||
}
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func NewLogsDeploymentsCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "logs <app-uuid> [deployment-uuid]",
|
||||
Short: "Get deployment logs for an application",
|
||||
Long: `Retrieve deployment logs for a specific application or deployment.
|
||||
|
||||
If only app-uuid is provided, retrieves logs from the latest deployment.
|
||||
If deployment-uuid is also provided, retrieves logs for that specific deployment.
|
||||
|
||||
Use --follow to continuously stream new logs.`,
|
||||
Args: cobra.RangeArgs(1, 2),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := context.Background()
|
||||
appUUID := args[0]
|
||||
var deploymentUUID string
|
||||
if len(args) == 2 {
|
||||
deploymentUUID = args[1]
|
||||
}
|
||||
|
||||
client, err := cli.GetAPIClient(cmd)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get API client: %w", err)
|
||||
}
|
||||
|
||||
lines, _ := cmd.Flags().GetInt("lines")
|
||||
follow, _ := cmd.Flags().GetBool("follow")
|
||||
debugLogs, _ := cmd.Flags().GetBool("debuglogs")
|
||||
format, _ := cmd.Flags().GetString("format")
|
||||
deploySvc := service.NewDeploymentService(client)
|
||||
|
||||
// Function to get logs based on whether we have a deployment UUID
|
||||
// Returns raw or formatted based on format flag
|
||||
getLogs := func() (string, error) {
|
||||
if deploymentUUID != "" {
|
||||
return deploySvc.GetLogsByDeploymentWithFormat(ctx, deploymentUUID, debugLogs, format)
|
||||
}
|
||||
// Get logs from the latest deployment
|
||||
// Use take=1 internally to efficiently fetch only the most recent deployment
|
||||
return deploySvc.GetLogsByApplicationWithFormat(ctx, appUUID, 1, debugLogs, format)
|
||||
}
|
||||
|
||||
if !follow {
|
||||
logs, err := getLogs()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get deployment logs: %w", err)
|
||||
}
|
||||
|
||||
// Apply line limit if specified (only for text output)
|
||||
if lines > 0 && format == "table" {
|
||||
logs = limitLogLines(logs, lines)
|
||||
}
|
||||
|
||||
fmt.Print(logs)
|
||||
return nil
|
||||
}
|
||||
|
||||
ticker := time.NewTicker(2 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
sigChan := make(chan os.Signal, 1)
|
||||
signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)
|
||||
|
||||
lastLogs := ""
|
||||
|
||||
logs, err := getLogs()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get deployment logs: %w", err)
|
||||
}
|
||||
fmt.Print(logs)
|
||||
lastLogs = logs
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-sigChan:
|
||||
fmt.Println("\nStopping log follow...")
|
||||
return nil
|
||||
case <-ticker.C:
|
||||
logs, err := getLogs()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if logs != lastLogs {
|
||||
if len(logs) > len(lastLogs) && strings.HasPrefix(logs, lastLogs) {
|
||||
fmt.Print(logs[len(lastLogs):])
|
||||
} else {
|
||||
fmt.Print(logs)
|
||||
}
|
||||
lastLogs = logs
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().IntP("lines", "n", 0, "Number of log lines to display (0 = all)")
|
||||
cmd.Flags().BoolP("follow", "f", false, "Follow log output (like tail -f)")
|
||||
cmd.Flags().Bool("debuglogs", false, "Show debug logs (includes hidden commands and internal operations)")
|
||||
return cmd
|
||||
}
|
||||
|
||||
// limitLogLines limits the output to the last N lines
|
||||
func limitLogLines(logs string, n int) string {
|
||||
if n <= 0 {
|
||||
return logs
|
||||
}
|
||||
|
||||
// Trim trailing newline to avoid empty element at the end
|
||||
logs = strings.TrimRight(logs, "\n")
|
||||
lines := strings.Split(logs, "\n")
|
||||
|
||||
// If we have fewer lines than requested, return all
|
||||
if len(lines) <= n {
|
||||
return logs + "\n"
|
||||
}
|
||||
|
||||
// Get the last N lines
|
||||
lastLines := lines[len(lines)-n:]
|
||||
return strings.Join(lastLines, "\n") + "\n"
|
||||
}
|
||||
Vendored
+89
@@ -0,0 +1,89 @@
|
||||
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 NewCreateEnvCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "create <app_uuid>",
|
||||
Short: "Create an environment variable for an application",
|
||||
Long: `Create a new environment variable for a specific application. Use --key and --value flags to specify the variable.`,
|
||||
Args: cli.ExactArgs(1, "<app_uuid>"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := cmd.Context()
|
||||
appUUID := 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")
|
||||
isBuildTime, _ := cmd.Flags().GetBool("build-time")
|
||||
isPreview, _ := cmd.Flags().GetBool("preview")
|
||||
isLiteral, _ := cmd.Flags().GetBool("is-literal")
|
||||
isMultiline, _ := cmd.Flags().GetBool("is-multiline")
|
||||
isRuntime, _ := cmd.Flags().GetBool("runtime")
|
||||
|
||||
if key == "" {
|
||||
return fmt.Errorf("--key is required")
|
||||
}
|
||||
if value == "" {
|
||||
return fmt.Errorf("--value is required")
|
||||
}
|
||||
|
||||
req := &models.EnvironmentVariableCreateRequest{
|
||||
Key: key,
|
||||
Value: value,
|
||||
}
|
||||
|
||||
if cmd.Flags().Changed("build-time") {
|
||||
req.IsBuildTime = &isBuildTime
|
||||
}
|
||||
if cmd.Flags().Changed("preview") {
|
||||
req.IsPreview = &isPreview
|
||||
}
|
||||
if cmd.Flags().Changed("is-literal") {
|
||||
req.IsLiteral = &isLiteral
|
||||
}
|
||||
if cmd.Flags().Changed("is-multiline") {
|
||||
req.IsMultiline = &isMultiline
|
||||
}
|
||||
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)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create environment variable: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("Environment variable '%s' created successfully.\n", key)
|
||||
fmt.Printf("UUID: %s\n", env.UUID)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().String("key", "", "Environment variable key (required)")
|
||||
cmd.Flags().String("value", "", "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 (don't interpolate variables)")
|
||||
cmd.Flags().Bool("is-multiline", false, "Value is multiline")
|
||||
cmd.Flags().Bool("runtime", true, "Available at runtime (default: true)")
|
||||
cmd.Flags().String("comment", "", "Comment for the environment variable")
|
||||
return cmd
|
||||
}
|
||||
Vendored
+59
@@ -0,0 +1,59 @@
|
||||
package env
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/coollabsio/coolify-cli/internal/cli"
|
||||
"github.com/coollabsio/coolify-cli/internal/service"
|
||||
)
|
||||
|
||||
func NewDeleteEnvCommand() *cobra.Command {
|
||||
deleteEnvCmd := &cobra.Command{
|
||||
Use: "delete <app_uuid> <env_uuid>",
|
||||
Short: "Delete an environment variable",
|
||||
Long: `Delete an environment variable from an application. First UUID is the application, 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()
|
||||
appUUID := 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): ")
|
||||
_, 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.DeleteEnv(ctx, appUUID, envUUID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete environment variable: %w", err)
|
||||
}
|
||||
|
||||
fmt.Println("Environment variable deleted successfully.")
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
deleteEnvCmd.Flags().Bool("force", false, "Skip confirmation prompt")
|
||||
return deleteEnvCmd
|
||||
}
|
||||
Vendored
+60
@@ -0,0 +1,60 @@
|
||||
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 NewGetEnvCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "get <app_uuid> <env_uuid_or_key>",
|
||||
Short: "Get environment variable details",
|
||||
Long: `Get detailed information about a specific environment variable 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]
|
||||
envUUIDOrKey := args[1]
|
||||
|
||||
client, err := cli.GetAPIClient(cmd)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get API client: %w", err)
|
||||
}
|
||||
|
||||
appSvc := service.NewApplicationService(client)
|
||||
|
||||
// First try to get by the identifier directly
|
||||
env, err := appSvc.GetEnv(ctx, appUUID, envUUIDOrKey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get environment variable: %w", err)
|
||||
}
|
||||
|
||||
showSensitive, _ := cmd.Flags().GetBool("show-sensitive")
|
||||
|
||||
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)
|
||||
},
|
||||
}
|
||||
|
||||
return cmd
|
||||
}
|
||||
Vendored
+87
@@ -0,0 +1,87 @@
|
||||
package env
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/coollabsio/coolify-cli/internal/cli"
|
||||
"github.com/coollabsio/coolify-cli/internal/models"
|
||||
"github.com/coollabsio/coolify-cli/internal/output"
|
||||
"github.com/coollabsio/coolify-cli/internal/service"
|
||||
)
|
||||
|
||||
func NewListEnvCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "list <app_uuid>",
|
||||
Short: "List all environment variables for an application",
|
||||
Long: `List all environment variables for a specific application. By default, only non-preview environment variables are shown. Use --preview to show preview environment variables instead, or --all to show all variables (non-preview first, then preview).`,
|
||||
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)
|
||||
}
|
||||
|
||||
appSvc := service.NewApplicationService(client)
|
||||
envs, err := appSvc.ListEnvs(ctx, uuid)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to list environment variables: %w", err)
|
||||
}
|
||||
|
||||
// Filter by preview/all flags
|
||||
showAll, _ := cmd.Flags().GetBool("all")
|
||||
showPreview, _ := cmd.Flags().GetBool("preview")
|
||||
|
||||
if showAll {
|
||||
// Sort: non-preview first, then preview
|
||||
sort.SliceStable(envs, func(i, j int) bool {
|
||||
if envs[i].IsPreview != envs[j].IsPreview {
|
||||
return !envs[i].IsPreview // non-preview (false) comes before preview (true)
|
||||
}
|
||||
return false // maintain original order within groups
|
||||
})
|
||||
} else {
|
||||
// Filter by preview flag
|
||||
var filtered []models.EnvironmentVariable
|
||||
for _, env := range envs {
|
||||
if env.IsPreview == showPreview {
|
||||
filtered = append(filtered, env)
|
||||
}
|
||||
}
|
||||
envs = filtered
|
||||
}
|
||||
|
||||
format, _ := cmd.Flags().GetString("format")
|
||||
showSensitive, _ := cmd.Flags().GetBool("show-sensitive")
|
||||
|
||||
if !showSensitive {
|
||||
for i := range envs {
|
||||
envs[i].Value = "********"
|
||||
if envs[i].RealValue != nil {
|
||||
masked := "********"
|
||||
envs[i].RealValue = &masked
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
formatter, err := output.NewFormatter(format, output.Options{
|
||||
ShowSensitive: showSensitive,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return formatter.Format(envs)
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().Bool("preview", false, "Show preview environment variables instead of regular ones")
|
||||
cmd.Flags().Bool("all", false, "Show all environment variables (non-preview first, then preview)")
|
||||
|
||||
return cmd
|
||||
}
|
||||
Vendored
+159
@@ -0,0 +1,159 @@
|
||||
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 NewSyncEnvCommand() *cobra.Command {
|
||||
syncEnvCmd := &cobra.Command{
|
||||
Use: "sync <app_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 app 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")
|
||||
}
|
||||
|
||||
isBuildTime, _ := cmd.Flags().GetBool("build-time")
|
||||
isPreview, _ := cmd.Flags().GetBool("preview")
|
||||
isLiteral, _ := cmd.Flags().GetBool("is-literal")
|
||||
isRuntime, _ := cmd.Flags().GetBool("runtime")
|
||||
|
||||
// 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
|
||||
appSvc := service.NewApplicationService(client)
|
||||
existingEnvs, err := appSvc.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.EnvironmentVariable)
|
||||
for _, env := range existingEnvs {
|
||||
existingMap[env.Key] = env
|
||||
}
|
||||
|
||||
// Separate into updates and creates
|
||||
var toUpdate []models.EnvironmentVariableCreateRequest
|
||||
var toCreate []models.EnvironmentVariableCreateRequest
|
||||
|
||||
for _, envVar := range envVars {
|
||||
req := models.EnvironmentVariableCreateRequest{
|
||||
Key: envVar.Key,
|
||||
Value: envVar.Value,
|
||||
}
|
||||
|
||||
// Apply flags if explicitly provided
|
||||
if cmd.Flags().Changed("build-time") {
|
||||
req.IsBuildTime = &isBuildTime
|
||||
}
|
||||
if cmd.Flags().Changed("preview") {
|
||||
req.IsPreview = &isPreview
|
||||
}
|
||||
if cmd.Flags().Changed("is-literal") {
|
||||
req.IsLiteral = &isLiteral
|
||||
}
|
||||
if cmd.Flags().Changed("runtime") {
|
||||
req.IsRuntime = &isRuntime
|
||||
}
|
||||
|
||||
// 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 := &service.BulkUpdateEnvsRequest{
|
||||
Data: toUpdate,
|
||||
}
|
||||
_, err := appSvc.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 := appSvc.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
|
||||
},
|
||||
}
|
||||
|
||||
syncEnvCmd.Flags().StringP("file", "f", "", "Path to .env file (required)")
|
||||
syncEnvCmd.Flags().Bool("build-time", true, "Make all variables available at build time (default: true)")
|
||||
syncEnvCmd.Flags().Bool("preview", false, "Make all variables available in preview deployments")
|
||||
syncEnvCmd.Flags().Bool("is-literal", false, "Treat all values as literal (don't interpolate variables)")
|
||||
syncEnvCmd.Flags().Bool("runtime", true, "Make all variables available at runtime (default: true)")
|
||||
return syncEnvCmd
|
||||
}
|
||||
Vendored
+104
@@ -0,0 +1,104 @@
|
||||
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 NewUpdateEnvCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "update <app_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, "<app_uuid> <env_uuid_or_key>"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := cmd.Context()
|
||||
appUUID := args[0]
|
||||
envIdentifier := args[1]
|
||||
|
||||
client, err := cli.GetAPIClient(cmd)
|
||||
if err != nil {
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
if cmd.Flags().Changed("build-time") {
|
||||
isBuildTime, _ := cmd.Flags().GetBool("build-time")
|
||||
req.IsBuildTime = &isBuildTime
|
||||
}
|
||||
if cmd.Flags().Changed("preview") {
|
||||
isPreview, _ := cmd.Flags().GetBool("preview")
|
||||
req.IsPreview = &isPreview
|
||||
}
|
||||
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("runtime") {
|
||||
isRuntime, _ := cmd.Flags().GetBool("runtime")
|
||||
req.IsRuntime = &isRuntime
|
||||
}
|
||||
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 := appSvc.UpdateEnv(ctx, appUUID, 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("build-time", true, "Available at build time (default: true)")
|
||||
cmd.Flags().Bool("preview", false, "Available in preview deployments")
|
||||
cmd.Flags().Bool("is-literal", false, "Treat value as literal")
|
||||
cmd.Flags().Bool("is-multiline", false, "Value is multiline")
|
||||
cmd.Flags().Bool("runtime", true, "Available at runtime (default: true)")
|
||||
cmd.Flags().String("comment", "", "Comment for the environment variable")
|
||||
return cmd
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package application
|
||||
|
||||
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 <uuid>",
|
||||
Short: "Get application details by UUID",
|
||||
Long: `Retrieve detailed information about a specific application.`,
|
||||
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)
|
||||
}
|
||||
|
||||
appSvc := service.NewApplicationService(client)
|
||||
app, err := appSvc.Get(ctx, uuid)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get application: %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(app)
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package application
|
||||
|
||||
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/output"
|
||||
"github.com/coollabsio/coolify-cli/internal/service"
|
||||
)
|
||||
|
||||
func NewListCommand() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List all applications",
|
||||
Long: `List all applications in Coolify.`,
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
ctx := cmd.Context()
|
||||
|
||||
client, err := cli.GetAPIClient(cmd)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get API client: %w", err)
|
||||
}
|
||||
|
||||
appSvc := service.NewApplicationService(client)
|
||||
apps, err := appSvc.List(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to list applications: %w", err)
|
||||
}
|
||||
|
||||
format, _ := cmd.Flags().GetString("format")
|
||||
showSensitive, _ := cmd.Flags().GetBool("show-sensitive")
|
||||
|
||||
// For JSON/pretty formats, return the full application structure
|
||||
if format != output.FormatTable {
|
||||
formatter, err := output.NewFormatter(format, output.Options{
|
||||
ShowSensitive: showSensitive,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return formatter.Format(apps)
|
||||
}
|
||||
|
||||
// For table format, convert to simplified rows
|
||||
var rows []models.ApplicationListItem
|
||||
for _, app := range apps {
|
||||
rows = append(rows, models.ApplicationListItem{
|
||||
UUID: app.UUID,
|
||||
Name: app.Name,
|
||||
Description: app.Description,
|
||||
Status: app.Status,
|
||||
GitBranch: app.GitBranch,
|
||||
FQDN: app.FQDN,
|
||||
})
|
||||
}
|
||||
|
||||
formatter, err := output.NewFormatter(format, output.Options{
|
||||
ShowSensitive: showSensitive,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return formatter.Format(rows)
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package application
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/coollabsio/coolify-cli/internal/cli"
|
||||
"github.com/coollabsio/coolify-cli/internal/service"
|
||||
)
|
||||
|
||||
func NewLogsCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "logs <uuid>",
|
||||
Short: "Get application logs",
|
||||
Long: `Retrieve logs for an application. Use --follow to continuously stream new logs.`,
|
||||
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)
|
||||
}
|
||||
|
||||
lines, _ := cmd.Flags().GetInt("lines")
|
||||
follow, _ := cmd.Flags().GetBool("follow")
|
||||
appSvc := service.NewApplicationService(client)
|
||||
|
||||
if !follow {
|
||||
resp, err := appSvc.Logs(ctx, uuid, lines)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get logs: %w", err)
|
||||
}
|
||||
fmt.Print(resp.Logs)
|
||||
return nil
|
||||
}
|
||||
|
||||
ticker := time.NewTicker(2 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
sigChan := make(chan os.Signal, 1)
|
||||
signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)
|
||||
|
||||
lastLogs := ""
|
||||
|
||||
resp, err := appSvc.Logs(ctx, uuid, lines)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get logs: %w", err)
|
||||
}
|
||||
fmt.Print(resp.Logs)
|
||||
lastLogs = resp.Logs
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-sigChan:
|
||||
fmt.Println("\nStopping log follow...")
|
||||
return nil
|
||||
case <-ticker.C:
|
||||
resp, err := appSvc.Logs(ctx, uuid, lines)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if resp.Logs != lastLogs {
|
||||
if len(resp.Logs) > len(lastLogs) && strings.HasPrefix(resp.Logs, lastLogs) {
|
||||
fmt.Print(resp.Logs[len(lastLogs):])
|
||||
} else {
|
||||
fmt.Print(resp.Logs)
|
||||
}
|
||||
lastLogs = resp.Logs
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().IntP("lines", "n", 100, "Number of log lines to retrieve")
|
||||
cmd.Flags().BoolP("follow", "f", false, "Follow log output (like tail -f)")
|
||||
return cmd
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package previews
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/coollabsio/coolify-cli/internal/cli"
|
||||
"github.com/coollabsio/coolify-cli/internal/service"
|
||||
)
|
||||
|
||||
func NewDeletePreviewCommand() *cobra.Command {
|
||||
deletePreviewCmd := &cobra.Command{
|
||||
Use: "delete <app_uuid> <pr_id>",
|
||||
Short: "Delete a preview deployment",
|
||||
Long: `Delete a preview deployment for an application. First argument is the application UUID, second is the pull request ID.`,
|
||||
Args: cli.ExactArgs(2, "<app_uuid> <pr_id>"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := cmd.Context()
|
||||
appUUID := args[0]
|
||||
prID := args[1]
|
||||
|
||||
prIDInt, err := strconv.Atoi(prID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid pr_id: must be an integer")
|
||||
}
|
||||
if prIDInt <= 0 {
|
||||
return fmt.Errorf("invalid pr_id: must be a positive integer")
|
||||
}
|
||||
|
||||
client, err := cli.GetAPIClient(cmd)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get API client: %w", err)
|
||||
}
|
||||
|
||||
if err := cli.CheckMinimumVersion(ctx, client, "4.0.0-beta.474"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
force, _ := cmd.Flags().GetBool("force")
|
||||
|
||||
// Prompt for confirmation unless --force is used
|
||||
if !force {
|
||||
var response string
|
||||
fmt.Printf("Are you sure you want to delete the preview deployment for PR %s? (yes/no): ", prID)
|
||||
_, err := fmt.Scanln(&response)
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read confirmation: %w", err)
|
||||
}
|
||||
|
||||
if response != "yes" && response != "y" {
|
||||
fmt.Println("Delete cancelled.")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
appSvc := service.NewApplicationService(client)
|
||||
err = appSvc.DeletePreview(ctx, appUUID, prID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete preview deployment: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("Preview deployment for PR %s deleted successfully.\n", prID)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
deletePreviewCmd.Flags().Bool("force", false, "Skip confirmation prompt")
|
||||
return deletePreviewCmd
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package application
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/coollabsio/coolify-cli/internal/cli"
|
||||
"github.com/coollabsio/coolify-cli/internal/service"
|
||||
)
|
||||
|
||||
func NewRestartCommand() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "restart <uuid>",
|
||||
Short: "Restart an application",
|
||||
Long: `Restart a running application.`,
|
||||
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)
|
||||
}
|
||||
|
||||
appSvc := service.NewApplicationService(client)
|
||||
resp, err := appSvc.Restart(ctx, uuid)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to restart application: %w", err)
|
||||
}
|
||||
|
||||
fmt.Println(resp.Message)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package application
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/coollabsio/coolify-cli/internal/cli"
|
||||
"github.com/coollabsio/coolify-cli/internal/service"
|
||||
)
|
||||
|
||||
func NewStartCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "start <uuid>",
|
||||
Aliases: []string{"deploy"},
|
||||
Short: "Start an application",
|
||||
Long: `Start an application (initiates a deployment).`,
|
||||
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)
|
||||
}
|
||||
|
||||
force, _ := cmd.Flags().GetBool("force")
|
||||
instantDeploy, _ := cmd.Flags().GetBool("instant-deploy")
|
||||
|
||||
appSvc := service.NewApplicationService(client)
|
||||
resp, err := appSvc.Start(ctx, uuid, force, instantDeploy)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to start application: %w", err)
|
||||
}
|
||||
|
||||
fmt.Println(resp.Message)
|
||||
if resp.DeploymentUUID != nil && *resp.DeploymentUUID != "" {
|
||||
fmt.Printf("Deployment UUID: %s\n", *resp.DeploymentUUID)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().Bool("force", false, "Force rebuild")
|
||||
cmd.Flags().Bool("instant-deploy", false, "Instant deploy (skip queuing)")
|
||||
return cmd
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package application
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/coollabsio/coolify-cli/internal/cli"
|
||||
"github.com/coollabsio/coolify-cli/internal/service"
|
||||
)
|
||||
|
||||
func NewStopCommand() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "stop <uuid>",
|
||||
Short: "Stop an application",
|
||||
Long: `Stop a running application.`,
|
||||
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)
|
||||
}
|
||||
|
||||
appSvc := service.NewApplicationService(client)
|
||||
resp, err := appSvc.Stop(ctx, uuid)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to stop application: %w", err)
|
||||
}
|
||||
|
||||
fmt.Println(resp.Message)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/coollabsio/coolify-cli/internal/cli"
|
||||
"github.com/coollabsio/coolify-cli/internal/models"
|
||||
"github.com/coollabsio/coolify-cli/internal/service"
|
||||
)
|
||||
|
||||
// NewCreateCommand returns the storage create command
|
||||
func NewCreateCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "create <app_uuid>",
|
||||
Short: "Create a storage for an application",
|
||||
Long: `Create a persistent volume or file storage for an application.
|
||||
|
||||
Examples:
|
||||
coolify app storage create <app_uuid> --type persistent --name my-volume --mount-path /data
|
||||
coolify app storage create <app_uuid> --type persistent --name my-volume --mount-path /data --host-path /var/data
|
||||
coolify app storage create <app_uuid> --type file --mount-path /app/config.yml --content "key: value"
|
||||
coolify app storage create <app_uuid> --type file --mount-path /app/data --is-directory --fs-path /app/data`,
|
||||
Args: cli.ExactArgs(1, "<app_uuid>"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := cmd.Context()
|
||||
|
||||
storageType, _ := cmd.Flags().GetString("type")
|
||||
mountPath, _ := cmd.Flags().GetString("mount-path")
|
||||
|
||||
if storageType == "" {
|
||||
return fmt.Errorf("--type is required (persistent or file)")
|
||||
}
|
||||
if storageType != "persistent" && storageType != "file" {
|
||||
return fmt.Errorf("--type must be 'persistent' or 'file'")
|
||||
}
|
||||
if mountPath == "" {
|
||||
return fmt.Errorf("--mount-path is required")
|
||||
}
|
||||
|
||||
req := &models.StorageCreateRequest{
|
||||
Type: storageType,
|
||||
MountPath: mountPath,
|
||||
}
|
||||
|
||||
if cmd.Flags().Changed("name") {
|
||||
val, _ := cmd.Flags().GetString("name")
|
||||
req.Name = &val
|
||||
}
|
||||
if cmd.Flags().Changed("host-path") {
|
||||
val, _ := cmd.Flags().GetString("host-path")
|
||||
req.HostPath = &val
|
||||
}
|
||||
if cmd.Flags().Changed("content") {
|
||||
val, _ := cmd.Flags().GetString("content")
|
||||
req.Content = &val
|
||||
}
|
||||
if cmd.Flags().Changed("is-directory") {
|
||||
val, _ := cmd.Flags().GetBool("is-directory")
|
||||
req.IsDirectory = &val
|
||||
}
|
||||
if cmd.Flags().Changed("fs-path") {
|
||||
val, _ := cmd.Flags().GetString("fs-path")
|
||||
req.FsPath = &val
|
||||
}
|
||||
|
||||
client, err := cli.GetAPIClient(cmd)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get API client: %w", err)
|
||||
}
|
||||
|
||||
if err := cli.CheckMinimumVersion(ctx, client, "4.0.0-beta.470"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
appSvc := service.NewApplicationService(client)
|
||||
if err := appSvc.CreateStorage(ctx, args[0], req); err != nil {
|
||||
return fmt.Errorf("failed to create storage: %w", err)
|
||||
}
|
||||
|
||||
fmt.Println("Storage created successfully.")
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().String("type", "", "Storage type: 'persistent' or 'file' (required)")
|
||||
cmd.Flags().String("mount-path", "", "Mount path inside the container (required)")
|
||||
cmd.Flags().String("name", "", "Volume name (persistent only)")
|
||||
cmd.Flags().String("host-path", "", "Host path (persistent only)")
|
||||
cmd.Flags().String("content", "", "File content (file only)")
|
||||
cmd.Flags().Bool("is-directory", false, "Whether this is a directory mount (file only)")
|
||||
cmd.Flags().String("fs-path", "", "Host directory path (file only, required when --is-directory is set)")
|
||||
|
||||
return cmd
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/coollabsio/coolify-cli/internal/cli"
|
||||
"github.com/coollabsio/coolify-cli/internal/service"
|
||||
)
|
||||
|
||||
// NewDeleteCommand returns the storage delete command
|
||||
func NewDeleteCommand() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "delete <app_uuid> <storage_uuid>",
|
||||
Short: "Delete a storage from an application",
|
||||
Long: `Delete a persistent volume or file storage from an application.
|
||||
|
||||
Examples:
|
||||
coolify app storage delete <app_uuid> <storage_uuid>`,
|
||||
Args: cli.ExactArgs(2, "<app_uuid> <storage_uuid>"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := cmd.Context()
|
||||
|
||||
client, err := cli.GetAPIClient(cmd)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get API client: %w", err)
|
||||
}
|
||||
|
||||
if err := cli.CheckMinimumVersion(ctx, client, "4.0.0-beta.470"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
appSvc := service.NewApplicationService(client)
|
||||
if err := appSvc.DeleteStorage(ctx, args[0], args[1]); err != nil {
|
||||
return fmt.Errorf("failed to delete storage: %w", err)
|
||||
}
|
||||
|
||||
fmt.Println("Storage deleted successfully.")
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/coollabsio/coolify-cli/internal/cli"
|
||||
"github.com/coollabsio/coolify-cli/internal/output"
|
||||
"github.com/coollabsio/coolify-cli/internal/service"
|
||||
)
|
||||
|
||||
// NewListCommand returns the storage list command
|
||||
func NewListCommand() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "list <app_uuid>",
|
||||
Short: "List all storages for an application",
|
||||
Long: `List all persistent volumes and file storages for a specific application.`,
|
||||
Args: cli.ExactArgs(1, "<app_uuid>"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := cmd.Context()
|
||||
|
||||
client, err := cli.GetAPIClient(cmd)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get API client: %w", err)
|
||||
}
|
||||
|
||||
if err := cli.CheckMinimumVersion(ctx, client, "4.0.0-beta.470"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
appSvc := service.NewApplicationService(client)
|
||||
storages, err := appSvc.ListStorages(ctx, args[0])
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to list storages: %w", err)
|
||||
}
|
||||
|
||||
format, _ := cmd.Flags().GetString("format")
|
||||
showSensitive, _ := cmd.Flags().GetBool("show-sensitive")
|
||||
|
||||
formatter, err := output.NewFormatter(format, output.Options{
|
||||
ShowSensitive: showSensitive,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return formatter.Format(storages)
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/coollabsio/coolify-cli/internal/cli"
|
||||
"github.com/coollabsio/coolify-cli/internal/models"
|
||||
"github.com/coollabsio/coolify-cli/internal/service"
|
||||
)
|
||||
|
||||
// NewUpdateCommand returns the storage update command
|
||||
func NewUpdateCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "update <app_uuid>",
|
||||
Short: "Update a storage for an application",
|
||||
Long: `Update a persistent volume or file storage for an application.
|
||||
|
||||
The --uuid and --type flags are required. Use 'coolify app storage list' to find storage UUIDs.
|
||||
|
||||
For read-only storages (from docker-compose or services), only --is-preview-suffix-enabled can be updated.
|
||||
|
||||
Examples:
|
||||
coolify app storage update <app_uuid> --uuid <storage_uuid> --type persistent --name my-volume --mount-path /data
|
||||
coolify app storage update <app_uuid> --uuid <storage_uuid> --type file --content "config content" --mount-path /app/config.yml
|
||||
coolify app storage update <app_uuid> --uuid <storage_uuid> --type persistent --is-preview-suffix-enabled`,
|
||||
Args: cli.ExactArgs(1, "<app_uuid>"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := cmd.Context()
|
||||
|
||||
storageUUID, _ := cmd.Flags().GetString("uuid")
|
||||
storageID, _ := cmd.Flags().GetInt("id")
|
||||
storageType, _ := cmd.Flags().GetString("type")
|
||||
|
||||
if storageUUID == "" && storageID == 0 {
|
||||
return fmt.Errorf("--uuid is required (or --id as deprecated fallback)")
|
||||
}
|
||||
if storageType == "" {
|
||||
return fmt.Errorf("--type is required (persistent or file)")
|
||||
}
|
||||
if storageType != "persistent" && storageType != "file" {
|
||||
return fmt.Errorf("--type must be 'persistent' or 'file'")
|
||||
}
|
||||
|
||||
req := &models.StorageUpdateRequest{
|
||||
Type: storageType,
|
||||
}
|
||||
|
||||
if storageUUID != "" {
|
||||
req.UUID = &storageUUID
|
||||
} else {
|
||||
req.ID = &storageID
|
||||
}
|
||||
|
||||
hasUpdates := false
|
||||
|
||||
if cmd.Flags().Changed("is-preview-suffix-enabled") {
|
||||
val, _ := cmd.Flags().GetBool("is-preview-suffix-enabled")
|
||||
req.IsPreviewSuffixEnabled = &val
|
||||
hasUpdates = true
|
||||
}
|
||||
if cmd.Flags().Changed("name") {
|
||||
val, _ := cmd.Flags().GetString("name")
|
||||
req.Name = &val
|
||||
hasUpdates = true
|
||||
}
|
||||
if cmd.Flags().Changed("mount-path") {
|
||||
val, _ := cmd.Flags().GetString("mount-path")
|
||||
req.MountPath = &val
|
||||
hasUpdates = true
|
||||
}
|
||||
if cmd.Flags().Changed("host-path") {
|
||||
val, _ := cmd.Flags().GetString("host-path")
|
||||
req.HostPath = &val
|
||||
hasUpdates = true
|
||||
}
|
||||
if cmd.Flags().Changed("content") {
|
||||
val, _ := cmd.Flags().GetString("content")
|
||||
req.Content = &val
|
||||
hasUpdates = true
|
||||
}
|
||||
|
||||
if !hasUpdates {
|
||||
return fmt.Errorf("no fields to update. Use --help to see available flags")
|
||||
}
|
||||
|
||||
client, err := cli.GetAPIClient(cmd)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get API client: %w", err)
|
||||
}
|
||||
|
||||
if err := cli.CheckMinimumVersion(ctx, client, "4.0.0-beta.470"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
appSvc := service.NewApplicationService(client)
|
||||
if err := appSvc.UpdateStorage(ctx, args[0], req); err != nil {
|
||||
return fmt.Errorf("failed to update storage: %w", err)
|
||||
}
|
||||
|
||||
fmt.Println("Storage updated successfully.")
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().String("uuid", "", "Storage UUID (required, use 'storage list' to find)")
|
||||
cmd.Flags().Int("id", 0, "Storage ID (deprecated, use --uuid instead)")
|
||||
cmd.Flags().String("type", "", "Storage type: 'persistent' or 'file' (required)")
|
||||
cmd.Flags().Bool("is-preview-suffix-enabled", false, "Enable preview suffix for this storage")
|
||||
cmd.Flags().String("name", "", "Storage name (persistent only)")
|
||||
cmd.Flags().String("mount-path", "", "Mount path inside the container")
|
||||
cmd.Flags().String("host-path", "", "Host path (persistent only)")
|
||||
cmd.Flags().String("content", "", "File content (file only)")
|
||||
|
||||
return cmd
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
package application
|
||||
|
||||
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/output"
|
||||
"github.com/coollabsio/coolify-cli/internal/service"
|
||||
)
|
||||
|
||||
func NewUpdateCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "update <uuid>",
|
||||
Short: "Update application configuration",
|
||||
Long: `Update configuration for a specific application. Only specified fields will be updated.`,
|
||||
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)
|
||||
}
|
||||
|
||||
req := models.ApplicationUpdateRequest{}
|
||||
hasUpdates := false
|
||||
|
||||
if cmd.Flags().Changed("name") {
|
||||
name, _ := cmd.Flags().GetString("name")
|
||||
req.Name = &name
|
||||
hasUpdates = true
|
||||
}
|
||||
if cmd.Flags().Changed("description") {
|
||||
desc, _ := cmd.Flags().GetString("description")
|
||||
req.Description = &desc
|
||||
hasUpdates = true
|
||||
}
|
||||
if cmd.Flags().Changed("git-branch") {
|
||||
branch, _ := cmd.Flags().GetString("git-branch")
|
||||
req.GitBranch = &branch
|
||||
hasUpdates = true
|
||||
}
|
||||
if cmd.Flags().Changed("git-repository") {
|
||||
repo, _ := cmd.Flags().GetString("git-repository")
|
||||
req.GitRepository = &repo
|
||||
hasUpdates = true
|
||||
}
|
||||
if cmd.Flags().Changed("domains") {
|
||||
domains, _ := cmd.Flags().GetString("domains")
|
||||
req.Domains = &domains
|
||||
hasUpdates = true
|
||||
}
|
||||
if cmd.Flags().Changed("build-command") {
|
||||
buildCmd, _ := cmd.Flags().GetString("build-command")
|
||||
req.BuildCommand = &buildCmd
|
||||
hasUpdates = true
|
||||
}
|
||||
if cmd.Flags().Changed("start-command") {
|
||||
startCmd, _ := cmd.Flags().GetString("start-command")
|
||||
req.StartCommand = &startCmd
|
||||
hasUpdates = true
|
||||
}
|
||||
if cmd.Flags().Changed("install-command") {
|
||||
installCmd, _ := cmd.Flags().GetString("install-command")
|
||||
req.InstallCommand = &installCmd
|
||||
hasUpdates = true
|
||||
}
|
||||
if cmd.Flags().Changed("base-directory") {
|
||||
baseDir, _ := cmd.Flags().GetString("base-directory")
|
||||
req.BaseDirectory = &baseDir
|
||||
hasUpdates = true
|
||||
}
|
||||
if cmd.Flags().Changed("publish-directory") {
|
||||
publishDir, _ := cmd.Flags().GetString("publish-directory")
|
||||
req.PublishDirectory = &publishDir
|
||||
hasUpdates = true
|
||||
}
|
||||
if cmd.Flags().Changed("dockerfile") {
|
||||
dockerfile, _ := cmd.Flags().GetString("dockerfile")
|
||||
req.Dockerfile = &dockerfile
|
||||
hasUpdates = true
|
||||
}
|
||||
if cmd.Flags().Changed("docker-image") {
|
||||
image, _ := cmd.Flags().GetString("docker-image")
|
||||
req.DockerRegistryImageName = &image
|
||||
hasUpdates = true
|
||||
}
|
||||
if cmd.Flags().Changed("docker-tag") {
|
||||
tag, _ := cmd.Flags().GetString("docker-tag")
|
||||
req.DockerRegistryImageTag = &tag
|
||||
hasUpdates = true
|
||||
}
|
||||
if cmd.Flags().Changed("ports-exposes") {
|
||||
ports, _ := cmd.Flags().GetString("ports-exposes")
|
||||
req.PortsExposes = &ports
|
||||
hasUpdates = true
|
||||
}
|
||||
if cmd.Flags().Changed("ports-mappings") {
|
||||
ports, _ := cmd.Flags().GetString("ports-mappings")
|
||||
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
|
||||
hasUpdates = true
|
||||
}
|
||||
if cmd.Flags().Changed("health-check-path") {
|
||||
path, _ := cmd.Flags().GetString("health-check-path")
|
||||
req.HealthCheckPath = &path
|
||||
hasUpdates = true
|
||||
}
|
||||
|
||||
if !hasUpdates {
|
||||
return fmt.Errorf("no fields to update. Use --help to see available flags")
|
||||
}
|
||||
|
||||
appSvc := service.NewApplicationService(client)
|
||||
app, err := appSvc.Update(ctx, uuid, req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to update application: %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(app)
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().String("name", "", "Application name")
|
||||
cmd.Flags().String("description", "", "Application description")
|
||||
cmd.Flags().String("git-branch", "", "Git branch")
|
||||
cmd.Flags().String("git-repository", "", "Git repository URL")
|
||||
cmd.Flags().String("domains", "", "Domains (comma-separated)")
|
||||
cmd.Flags().String("build-command", "", "Build command")
|
||||
cmd.Flags().String("start-command", "", "Start command")
|
||||
cmd.Flags().String("install-command", "", "Install command")
|
||||
cmd.Flags().String("base-directory", "", "Base directory")
|
||||
cmd.Flags().String("publish-directory", "", "Publish directory")
|
||||
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")
|
||||
cmd.Flags().String("health-check-path", "", "Health check path")
|
||||
|
||||
return cmd
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
// Package common holds flag structs and helpers shared between the
|
||||
// `coolify init` and `coolify firewall` command trees. Kept intentionally
|
||||
// small: only cross-command plumbing (SSH mesh flags, namespace validation)
|
||||
// lives here.
|
||||
//
|
||||
//nolint:revive // "common" is the conventional sharing point for these cobra subtrees
|
||||
package common
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// DefaultNamespace is the namespace used when the user does not pass
|
||||
// --namespaces. It is also always present (implicitly) so existing workflows
|
||||
// and coold defaults keep working.
|
||||
const DefaultNamespace = "default"
|
||||
|
||||
// PodmanNetworkFor returns the podman bridge network name that backs
|
||||
// namespace ns on every host. Derived as `coolify-<ns>-mesh` so the
|
||||
// namespace name is visible in `podman network ls`.
|
||||
func PodmanNetworkFor(ns string) string {
|
||||
return "coolify-" + ns + "-mesh"
|
||||
}
|
||||
|
||||
// MeshNetFlags holds the flag set shared between `coolify init` (which creates
|
||||
// per-namespace podman networks on every host) and `coolify firewall` (which
|
||||
// talks to coold about per-namespace rules).
|
||||
//
|
||||
// `init` binds it as a slice so a single command sets up the entire cluster;
|
||||
// `firewall` binds it as a single value since each allow/revoke/list call
|
||||
// operates on one namespace at a time.
|
||||
type MeshNetFlags struct {
|
||||
// Namespaces enumerates every namespace the mesh should carry. At least
|
||||
// one entry is required; the first element is the implicit "default"
|
||||
// unless the user overrides it.
|
||||
Namespaces []string
|
||||
|
||||
// ContainerPool is the shared address pool every namespace carves its
|
||||
// per-host /<ContainerPrefix> from. One pool covers all namespaces;
|
||||
// subnets never overlap.
|
||||
ContainerPool string
|
||||
|
||||
// ContainerPrefix is the prefix length of each per-host, per-namespace
|
||||
// container subnet (default 24 → 254 container IPs per host per ns).
|
||||
ContainerPrefix int
|
||||
}
|
||||
|
||||
// BindMeshNetMultiFlags registers --namespaces/--container-pool/--container-prefix
|
||||
// on cmd (init-style: many namespaces per invocation).
|
||||
func BindMeshNetMultiFlags(cmd *cobra.Command, f *MeshNetFlags) {
|
||||
pf := cmd.PersistentFlags()
|
||||
pf.StringSliceVar(&f.Namespaces, "namespaces", []string{DefaultNamespace},
|
||||
"Comma-separated list of namespaces to create on each host. Each "+
|
||||
"namespace is a separate Podman bridge network (coolify-<ns>-mesh) "+
|
||||
"with its own /<container-prefix> per host")
|
||||
pf.StringVar(&f.ContainerPool, "container-pool", "10.210.0.0/16",
|
||||
"Shared container address pool — each (namespace, host) pair gets a "+
|
||||
"/<container-prefix> from here, owned by that namespace's Podman bridge")
|
||||
pf.IntVar(&f.ContainerPrefix, "container-prefix", 24,
|
||||
"Prefix length of each per-host, per-namespace container subnet")
|
||||
}
|
||||
|
||||
// BindMeshNetSingleFlags registers --namespace on cmd (firewall-style: one
|
||||
// namespace per invocation).
|
||||
func BindMeshNetSingleFlags(cmd *cobra.Command, ns *string) {
|
||||
pf := cmd.PersistentFlags()
|
||||
pf.StringVar(ns, "namespace", DefaultNamespace,
|
||||
"Namespace the command operates against (must match a namespace created by `coolify init`)")
|
||||
}
|
||||
|
||||
// namespaceRegex matches a valid DNS label (namespace names appear in the
|
||||
// podman network name, in iptables chain names, and — post-coold-changes —
|
||||
// as DNS labels like web.<ns>.coolify.internal).
|
||||
var namespaceRegex = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$`)
|
||||
|
||||
// ValidateNamespaces checks that every namespace is a valid DNS label and
|
||||
// that the list has no duplicates.
|
||||
func (f *MeshNetFlags) ValidateNamespaces() error {
|
||||
if len(f.Namespaces) == 0 {
|
||||
return fmt.Errorf("--namespaces must list at least one namespace")
|
||||
}
|
||||
seen := make(map[string]struct{}, len(f.Namespaces))
|
||||
for _, ns := range f.Namespaces {
|
||||
if !namespaceRegex.MatchString(ns) {
|
||||
return fmt.Errorf("invalid namespace %q (must be a DNS label: lowercase alphanumerics + '-', 1-63 chars)", ns)
|
||||
}
|
||||
if _, dup := seen[ns]; dup {
|
||||
return fmt.Errorf("duplicate namespace %q in --namespaces", ns)
|
||||
}
|
||||
seen[ns] = struct{}{}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateNamespace validates a single namespace value (used by the firewall
|
||||
// command's --namespace flag).
|
||||
func ValidateNamespace(ns string) error {
|
||||
if !namespaceRegex.MatchString(ns) {
|
||||
return fmt.Errorf("invalid --namespace %q (must be a DNS label: lowercase alphanumerics + '-', 1-63 chars)", ns)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
// Package common hosts flag sets and helpers shared between multiple
|
||||
// top-level commands that SSH into a list of servers (init, firewall, ...).
|
||||
package common
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"golang.org/x/term"
|
||||
|
||||
internalssh "github.com/coollabsio/coolify-cli/internal/ssh"
|
||||
)
|
||||
|
||||
// SSHMeshFlags holds the flags shared by every command that fans out over
|
||||
// a list of SSH-reachable servers (coolify init, coolify firewall, ...).
|
||||
type SSHMeshFlags struct {
|
||||
Servers []string
|
||||
SSHKey string
|
||||
SSHUser string
|
||||
SSHPort int
|
||||
SSHPassphrasePrompt bool
|
||||
Concurrency int
|
||||
SSHTimeout string
|
||||
}
|
||||
|
||||
// BindSSHMeshFlags registers the shared flags as PersistentFlags on cmd.
|
||||
func BindSSHMeshFlags(cmd *cobra.Command, f *SSHMeshFlags) {
|
||||
pf := cmd.PersistentFlags()
|
||||
|
||||
pf.StringSliceVar(&f.Servers, "servers", nil,
|
||||
"Comma-separated server IPs (required)")
|
||||
pf.StringVar(&f.SSHKey, "ssh-key", "",
|
||||
"Path to SSH private key used to connect to servers (required)")
|
||||
pf.StringVar(&f.SSHUser, "ssh-user", "root",
|
||||
"SSH username")
|
||||
pf.IntVar(&f.SSHPort, "ssh-port", 22,
|
||||
"SSH port")
|
||||
pf.BoolVar(&f.SSHPassphrasePrompt, "ssh-passphrase-prompt", false,
|
||||
"Prompt for SSH key passphrase (also reads COOLIFY_SSH_PASSPHRASE env var)")
|
||||
pf.IntVar(&f.Concurrency, "concurrency", 10,
|
||||
"Maximum number of parallel SSH connections")
|
||||
pf.StringVar(&f.SSHTimeout, "ssh-timeout", "30s",
|
||||
"SSH connection timeout (e.g. 30s, 1m)")
|
||||
}
|
||||
|
||||
// ParseSSHTimeout parses SSHTimeout, falling back to 30s on error/zero.
|
||||
func (f *SSHMeshFlags) ParseSSHTimeout() time.Duration {
|
||||
d, err := time.ParseDuration(f.SSHTimeout)
|
||||
if err != nil || d <= 0 {
|
||||
return 30 * time.Second
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
// ResolvePassphrase returns the SSH key passphrase in this priority order:
|
||||
// 1. COOLIFY_SSH_PASSPHRASE env var
|
||||
// 2. Interactive prompt when --ssh-passphrase-prompt is set
|
||||
// 3. nil (no passphrase)
|
||||
func (f *SSHMeshFlags) ResolvePassphrase() ([]byte, error) {
|
||||
if env := os.Getenv("COOLIFY_SSH_PASSPHRASE"); env != "" {
|
||||
return []byte(env), nil
|
||||
}
|
||||
if f.SSHPassphrasePrompt {
|
||||
fmt.Fprint(os.Stderr, "SSH key passphrase: ")
|
||||
pass, err := term.ReadPassword(int(os.Stdin.Fd()))
|
||||
fmt.Fprintln(os.Stderr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read passphrase: %w", err)
|
||||
}
|
||||
return pass, nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// BuildSSHClient creates an SSH client, resolving any key passphrase first.
|
||||
func (f *SSHMeshFlags) BuildSSHClient() (*internalssh.Client, error) {
|
||||
passphrase, err := f.ResolvePassphrase()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return internalssh.NewClient(f.SSHKey, passphrase, f.ParseSSHTimeout())
|
||||
}
|
||||
|
||||
// Validate checks that the required flags are set.
|
||||
func (f *SSHMeshFlags) Validate() error {
|
||||
if len(f.Servers) == 0 {
|
||||
return fmt.Errorf("--servers is required")
|
||||
}
|
||||
if f.SSHKey == "" {
|
||||
return fmt.Errorf("--ssh-key is required")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestSSHMeshFlags_ParseSSHTimeout(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
want time.Duration
|
||||
}{
|
||||
{"30s", 30 * time.Second},
|
||||
{"1m", time.Minute},
|
||||
{"invalid", 30 * time.Second},
|
||||
{"0s", 30 * time.Second},
|
||||
{"", 30 * time.Second},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
f := &SSHMeshFlags{SSHTimeout: tt.input}
|
||||
assert.Equal(t, tt.want, f.ParseSSHTimeout(), "input: %q", tt.input)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSSHMeshFlags_Validate(t *testing.T) {
|
||||
t.Run("missing servers", func(t *testing.T) {
|
||||
err := (&SSHMeshFlags{SSHKey: "/k"}).Validate()
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "--servers")
|
||||
})
|
||||
t.Run("missing ssh key", func(t *testing.T) {
|
||||
err := (&SSHMeshFlags{Servers: []string{"1.1.1.1"}}).Validate()
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "--ssh-key")
|
||||
})
|
||||
t.Run("valid", func(t *testing.T) {
|
||||
err := (&SSHMeshFlags{Servers: []string{"1.1.1.1"}, SSHKey: "/k"}).Validate()
|
||||
require.NoError(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
func TestSSHMeshFlags_ResolvePassphrase_Env(t *testing.T) {
|
||||
t.Setenv("COOLIFY_SSH_PASSPHRASE", "hunter2")
|
||||
pass, err := (&SSHMeshFlags{}).ResolvePassphrase()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []byte("hunter2"), pass)
|
||||
}
|
||||
|
||||
func TestSSHMeshFlags_ResolvePassphrase_NoPrompt(t *testing.T) {
|
||||
t.Setenv("COOLIFY_SSH_PASSPHRASE", "")
|
||||
pass, err := (&SSHMeshFlags{SSHPassphrasePrompt: false}).ResolvePassphrase()
|
||||
require.NoError(t, err)
|
||||
assert.Nil(t, pass)
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package completion
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/coollabsio/coolify-cli/internal/cli"
|
||||
)
|
||||
|
||||
func NewCompletionsCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "completion <shell>",
|
||||
Short: "Output shell completion code for the specified shell",
|
||||
Long: `To load completions:
|
||||
|
||||
### Bash
|
||||
|
||||
To load completions into the current shell execute:
|
||||
|
||||
source <(coolify completion bash)
|
||||
|
||||
In order to make the completions permanent, append the line above to
|
||||
your .bashrc.
|
||||
|
||||
### Zsh
|
||||
|
||||
If shell completions are not already enabled for your environment need
|
||||
to enable them. Add the following line to your ~/.zshrc file:
|
||||
|
||||
autoload -Uz compinit; compinit
|
||||
|
||||
To load completions for each session execute the following commands:
|
||||
|
||||
mkdir -p ~/.config/coolify/completion/zsh
|
||||
coolify completion zsh > ~/.config/coolify/completion/zsh/_coolify
|
||||
|
||||
Finally add the following line to your ~/.zshrc file, *before* you
|
||||
call the compinit function:
|
||||
|
||||
fpath+=(~/.config/coolify/completion/zsh)
|
||||
|
||||
In the end your ~/.zshrc file should contain the following two lines
|
||||
in the order given here.
|
||||
|
||||
fpath+=(~/.config/coolify/completion/zsh)
|
||||
# ... anything else that needs to be done before compinit
|
||||
autoload -Uz compinit; compinit
|
||||
# ...
|
||||
|
||||
You will need to start a new shell for this setup to take effect.
|
||||
|
||||
### Fish
|
||||
|
||||
To load completions into the current shell execute:
|
||||
|
||||
coolify completion fish | source
|
||||
|
||||
In order to make the completions permanent execute once:
|
||||
|
||||
coolify completion fish > ~/.config/fish/completions/coolify.fish
|
||||
|
||||
### PowerShell:
|
||||
|
||||
To load completions into the current shell execute:
|
||||
|
||||
PS> coolify completion powershell | Out-String | Invoke-Expression
|
||||
|
||||
To load completions for every new session, run
|
||||
and source this file from your PowerShell profile.
|
||||
|
||||
PS> coolify completion powershell > coolify.ps1
|
||||
`,
|
||||
Args: cli.ExactArgs(1, "<shell>"),
|
||||
ValidArgs: []string{"bash", "fish", "zsh", "powershell"},
|
||||
DisableFlagsInUseLine: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
var err error
|
||||
|
||||
switch args[0] {
|
||||
case "bash":
|
||||
err = cmd.Root().GenBashCompletion(os.Stdout)
|
||||
case "fish":
|
||||
err = cmd.Root().GenFishCompletion(os.Stdout, true)
|
||||
case "zsh":
|
||||
err = cmd.Root().GenZshCompletion(os.Stdout)
|
||||
case "powershell":
|
||||
err = cmd.Root().GenPowerShellCompletion(os.Stdout)
|
||||
default:
|
||||
err = fmt.Errorf("Unsupported shell: %s", args[0])
|
||||
}
|
||||
return err
|
||||
},
|
||||
}
|
||||
return cmd
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/coollabsio/coolify-cli/internal/config"
|
||||
)
|
||||
|
||||
// NewConfigCommand creates the config command
|
||||
func NewConfigCommand() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "config",
|
||||
Short: "Show configuration file location",
|
||||
Long: "Display the path to the Coolify CLI configuration file",
|
||||
Run: func(_ *cobra.Command, _ []string) {
|
||||
fmt.Println(config.Path())
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/coollabsio/coolify-cli/internal/config"
|
||||
)
|
||||
|
||||
func TestNewConfigCommand(t *testing.T) {
|
||||
cmd := NewConfigCommand()
|
||||
|
||||
if cmd.Use != "config" {
|
||||
t.Errorf("Expected Use to be 'config', got '%s'", cmd.Use)
|
||||
}
|
||||
|
||||
if cmd.Short == "" {
|
||||
t.Error("Short description should not be empty")
|
||||
}
|
||||
|
||||
if cmd.Long == "" {
|
||||
t.Error("Long description should not be empty")
|
||||
}
|
||||
|
||||
if cmd.Run == nil {
|
||||
t.Error("Run function should not be nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigCommand_Output(t *testing.T) {
|
||||
// Test that the command returns the expected config path
|
||||
expectedPath := config.Path()
|
||||
|
||||
// The path should not be empty
|
||||
if expectedPath == "" {
|
||||
t.Error("Expected config path to not be empty")
|
||||
}
|
||||
|
||||
// The path should end with config.json
|
||||
if !strings.HasSuffix(expectedPath, "config.json") {
|
||||
t.Errorf("Expected path to end with 'config.json', got '%s'", expectedPath)
|
||||
}
|
||||
|
||||
// The path should contain the coolify directory
|
||||
if !strings.Contains(expectedPath, "coolify") {
|
||||
t.Errorf("Expected path to contain 'coolify', got '%s'", expectedPath)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package context
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/viper"
|
||||
|
||||
"github.com/coollabsio/coolify-cli/internal/cli"
|
||||
"github.com/coollabsio/coolify-cli/internal/config"
|
||||
)
|
||||
|
||||
// NewAddCommand creates the add command
|
||||
func NewAddCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "add <context_name> <url> <token>",
|
||||
Example: `context add myserver https://coolify.example.com your-api-token`,
|
||||
Args: cli.ExactArgs(3, "<context_name> <url> <token>"),
|
||||
Short: "Add a new context",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
name := args[0]
|
||||
host := args[1]
|
||||
token := args[2]
|
||||
|
||||
force, _ := cmd.Flags().GetBool("force")
|
||||
setDefault, _ := cmd.Flags().GetBool("default")
|
||||
|
||||
instances := viper.Get("instances").([]any)
|
||||
|
||||
// Check if instance already exists
|
||||
for _, instance := range instances {
|
||||
instanceMap := instance.(map[string]any)
|
||||
if instanceMap["name"] == name {
|
||||
if force {
|
||||
instanceMap["token"] = token
|
||||
if setDefault {
|
||||
// Remove default from all instances
|
||||
for _, inst := range instances {
|
||||
instMap := inst.(map[string]any)
|
||||
instMap["default"] = false
|
||||
}
|
||||
instanceMap["default"] = true
|
||||
fmt.Printf("%s already exists. Force overwriting. Setting it as default.\n", name)
|
||||
} else {
|
||||
fmt.Printf("%s already exists. Force overwriting.\n", name)
|
||||
}
|
||||
viper.Set("instances", instances)
|
||||
if err := viper.WriteConfig(); err != nil {
|
||||
return fmt.Errorf("failed to write config: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
fmt.Printf("%s already exists.\n", name)
|
||||
fmt.Println("\nNote: Use --force to force overwrite.")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// Add new instance
|
||||
newInstance := config.Instance{
|
||||
Name: name,
|
||||
FQDN: host,
|
||||
Token: token,
|
||||
Default: false,
|
||||
}
|
||||
|
||||
if setDefault {
|
||||
// Remove default from all instances
|
||||
for _, inst := range instances {
|
||||
instMap := inst.(map[string]any)
|
||||
instMap["default"] = false
|
||||
}
|
||||
newInstance.Default = true
|
||||
fmt.Printf("Context '%s' added and set as default.\n", newInstance.Name)
|
||||
} else {
|
||||
fmt.Printf("Context '%s' added successfully.\n", newInstance.Name)
|
||||
}
|
||||
|
||||
instances = append(instances, newInstance)
|
||||
|
||||
viper.Set("instances", instances)
|
||||
if err := viper.WriteConfig(); err != nil {
|
||||
return fmt.Errorf("failed to write config: %w", err)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().BoolP("default", "d", false, "Set as default context")
|
||||
cmd.Flags().BoolP("force", "f", false, "Force overwrite if context already exists")
|
||||
|
||||
return cmd
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package context
|
||||
|
||||
import (
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// NewContextCommand creates the context parent command
|
||||
func NewContextCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "context",
|
||||
Short: "Manage Coolify contexts",
|
||||
Long: `Manage Coolify contexts. A context contains the configuration (URL and token) for connecting to Coolify.`,
|
||||
}
|
||||
|
||||
// Add subcommands
|
||||
cmd.AddCommand(NewListCommand())
|
||||
cmd.AddCommand(NewAddCommand())
|
||||
cmd.AddCommand(NewDeleteCommand())
|
||||
cmd.AddCommand(NewUseCommand())
|
||||
cmd.AddCommand(NewUpdateCommand())
|
||||
cmd.AddCommand(NewGetCommand())
|
||||
cmd.AddCommand(NewSetTokenCommand())
|
||||
cmd.AddCommand(NewSetDefaultCommand())
|
||||
cmd.AddCommand(NewVersionCommand())
|
||||
cmd.AddCommand(NewVerifyCommand())
|
||||
|
||||
return cmd
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package context
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"slices"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/viper"
|
||||
|
||||
"github.com/coollabsio/coolify-cli/internal/cli"
|
||||
)
|
||||
|
||||
// NewDeleteCommand creates the delete command
|
||||
func NewDeleteCommand() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "delete <context_name>",
|
||||
Example: `context delete myserver`,
|
||||
Args: cli.ExactArgs(1, "<context_name>"),
|
||||
Short: "Delete a context",
|
||||
|
||||
RunE: func(_ *cobra.Command, args []string) error {
|
||||
Name := args[0]
|
||||
instances := viper.Get("instances").([]interface{})
|
||||
for i, instance := range instances {
|
||||
instanceMap := instance.(map[string]interface{})
|
||||
if instanceMap["name"] == Name {
|
||||
instances = slices.Delete(instances, i, i+1)
|
||||
viper.Set("instances", instances)
|
||||
if err := viper.WriteConfig(); err != nil {
|
||||
return fmt.Errorf("failed to write config: %w", err)
|
||||
}
|
||||
|
||||
if instanceMap["default"] == true {
|
||||
if len(instances) > 0 {
|
||||
instances[0].(map[string]interface{})["default"] = true
|
||||
viper.Set("instances", instances)
|
||||
if err := viper.WriteConfig(); err != nil {
|
||||
return fmt.Errorf("failed to write config: %w", err)
|
||||
}
|
||||
newDefaultName := instances[0].(map[string]interface{})["name"]
|
||||
fmt.Printf("Context '%s' deleted. '%s' is now the default context.\n", Name, newDefaultName)
|
||||
} else {
|
||||
fmt.Printf("Context '%s' deleted. No contexts remaining.\n", Name)
|
||||
}
|
||||
} else {
|
||||
fmt.Printf("Context '%s' deleted.\n", Name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("context '%s' not found", Name)
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package context
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/viper"
|
||||
|
||||
"github.com/coollabsio/coolify-cli/internal/cli"
|
||||
"github.com/coollabsio/coolify-cli/internal/config"
|
||||
"github.com/coollabsio/coolify-cli/internal/output"
|
||||
)
|
||||
|
||||
// NewGetCommand creates the get command
|
||||
func NewGetCommand() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "get <context_name>",
|
||||
Example: `context get myserver`,
|
||||
Args: cli.ExactArgs(1, "<context_name>"),
|
||||
Short: "Get details of a specific context",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
name := args[0]
|
||||
|
||||
instancesRaw := viper.Get("instances")
|
||||
if instancesRaw == nil {
|
||||
instancesRaw = []any{}
|
||||
}
|
||||
instancesInterface := instancesRaw.([]any)
|
||||
|
||||
format, _ := cmd.Flags().GetString("format")
|
||||
showSensitive, _ := cmd.Flags().GetBool("show-sensitive")
|
||||
|
||||
// Convert interface{} to config.Instance structs
|
||||
var instances []config.Instance
|
||||
for _, item := range instancesInterface {
|
||||
itemMap := item.(map[string]any)
|
||||
|
||||
instance := config.Instance{
|
||||
Name: getString(itemMap, "name"),
|
||||
FQDN: getString(itemMap, "fqdn"),
|
||||
Token: getString(itemMap, "token"),
|
||||
Default: getBool(itemMap, "default"),
|
||||
}
|
||||
instances = append(instances, instance)
|
||||
}
|
||||
|
||||
// If a name was provided, filter to that single instance
|
||||
var results []config.Instance
|
||||
for _, inst := range instances {
|
||||
if inst.Name == name {
|
||||
results = append(results, inst)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if len(results) == 0 {
|
||||
return fmt.Errorf("Context '%s' not found", name)
|
||||
}
|
||||
|
||||
formatter, err := output.NewFormatter(format, output.Options{
|
||||
ShowSensitive: showSensitive,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return formatter.Format(results)
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package context
|
||||
|
||||
import (
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/viper"
|
||||
|
||||
"github.com/coollabsio/coolify-cli/internal/config"
|
||||
"github.com/coollabsio/coolify-cli/internal/output"
|
||||
)
|
||||
|
||||
// NewListCommand creates the list command
|
||||
func NewListCommand() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List all configured contexts",
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
// Get instances from viper (returns []interface{})
|
||||
instancesRaw := viper.Get("instances")
|
||||
if instancesRaw == nil {
|
||||
instancesRaw = []interface{}{}
|
||||
}
|
||||
instancesInterface := instancesRaw.([]interface{})
|
||||
|
||||
format, _ := cmd.Flags().GetString("format")
|
||||
showSensitive, _ := cmd.Flags().GetBool("show-sensitive")
|
||||
|
||||
// Convert interface{} to config.Instance structs
|
||||
var instances []config.Instance
|
||||
for _, item := range instancesInterface {
|
||||
itemMap := item.(map[string]any)
|
||||
|
||||
instance := config.Instance{
|
||||
Name: getString(itemMap, "name"),
|
||||
FQDN: getString(itemMap, "fqdn"),
|
||||
Token: getString(itemMap, "token"),
|
||||
Default: getBool(itemMap, "default"),
|
||||
}
|
||||
instances = append(instances, instance)
|
||||
}
|
||||
|
||||
formatter, err := output.NewFormatter(format, output.Options{
|
||||
ShowSensitive: showSensitive,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return formatter.Format(instances)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Helper functions to safely extract values from map
|
||||
func getString(m map[string]interface{}, key string) string {
|
||||
if val, ok := m[key]; ok {
|
||||
if str, ok := val.(string); ok {
|
||||
return str
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func getBool(m map[string]interface{}, key string) bool {
|
||||
if val, ok := m[key]; ok {
|
||||
if b, ok := val.(bool); ok {
|
||||
return b
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package context
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/viper"
|
||||
|
||||
"github.com/coollabsio/coolify-cli/internal/cli"
|
||||
)
|
||||
|
||||
// NewSetTokenCommand creates the set-token command
|
||||
func NewSetDefaultCommand() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "set-default <context_name>",
|
||||
Example: `context set-default myserver`,
|
||||
Args: cli.ExactArgs(1, "<context_name>"),
|
||||
Short: "Set a context as the default",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
name := args[0]
|
||||
|
||||
raw := viper.Get("instances")
|
||||
instances, ok := raw.([]interface{})
|
||||
if !ok {
|
||||
return fmt.Errorf("invalid instances configuration")
|
||||
}
|
||||
|
||||
// Check if instance exists
|
||||
var found bool
|
||||
for _, instance := range instances {
|
||||
instanceMap, ok := instance.(map[string]interface{})
|
||||
if !ok {
|
||||
return fmt.Errorf("invalid instance configuration")
|
||||
}
|
||||
if val, ok := instanceMap["name"].(string); ok && val == name {
|
||||
found = true
|
||||
instanceMap["default"] = true
|
||||
}
|
||||
}
|
||||
|
||||
if !found {
|
||||
return fmt.Errorf("Context '%s' not found", name)
|
||||
}
|
||||
|
||||
// Only unset other defaults if we found the target instance
|
||||
for _, instance := range instances {
|
||||
instanceMap, ok := instance.(map[string]interface{})
|
||||
if !ok {
|
||||
return fmt.Errorf("invalid instance configuration")
|
||||
}
|
||||
|
||||
if val, ok := instanceMap["name"].(string); ok && val != name {
|
||||
instanceMap["default"] = false
|
||||
}
|
||||
}
|
||||
|
||||
viper.Set("instances", instances)
|
||||
if err := viper.WriteConfig(); err != nil {
|
||||
return fmt.Errorf("failed to write config: %w", err)
|
||||
}
|
||||
|
||||
// Show the list after updating
|
||||
return NewListCommand().RunE(cmd, args)
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package context
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/viper"
|
||||
|
||||
"github.com/coollabsio/coolify-cli/internal/cli"
|
||||
)
|
||||
|
||||
// NewSetTokenCommand creates the set-token command
|
||||
func NewSetTokenCommand() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "set-token <context_name> <token>",
|
||||
Example: `context set-token myserver your-new-api-token`,
|
||||
Args: cli.ExactArgs(2, "<context_name> <token>"),
|
||||
Short: "Update the API token for a context",
|
||||
RunE: func(_ *cobra.Command, args []string) error {
|
||||
name := args[0]
|
||||
token := args[1]
|
||||
var found interface{}
|
||||
for _, instance := range viper.Get("instances").([]interface{}) {
|
||||
instanceMap := instance.(map[string]interface{})
|
||||
if instanceMap["name"] == name {
|
||||
found = instanceMap
|
||||
break
|
||||
}
|
||||
}
|
||||
if found == nil {
|
||||
return fmt.Errorf("context '%s' not found", name)
|
||||
}
|
||||
instances := viper.Get("instances").([]interface{})
|
||||
for _, instance := range instances {
|
||||
instanceMap := instance.(map[string]interface{})
|
||||
if instanceMap["name"] == name {
|
||||
instanceMap["token"] = token
|
||||
}
|
||||
}
|
||||
viper.Set("instances", instances)
|
||||
if err := viper.WriteConfig(); err != nil {
|
||||
return fmt.Errorf("failed to update token for context '%s': %w", name, err)
|
||||
}
|
||||
fmt.Printf("Token updated for context '%s'.\n", name)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package context
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/viper"
|
||||
|
||||
"github.com/coollabsio/coolify-cli/internal/cli"
|
||||
)
|
||||
|
||||
// NewUpdateCommand creates the update command
|
||||
func NewUpdateCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "update <context_name>",
|
||||
Example: `context update myserver --name newname --url https://new.coolify.com --token newtoken`,
|
||||
Args: cli.ExactArgs(1, "<context_name>"),
|
||||
Short: "Update a context's properties (name, URL, token)",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
oldName := args[0]
|
||||
instances := viper.Get("instances").([]interface{})
|
||||
|
||||
// Get flags
|
||||
newName, _ := cmd.Flags().GetString("name")
|
||||
newURL, _ := cmd.Flags().GetString("url")
|
||||
newToken, _ := cmd.Flags().GetString("token")
|
||||
|
||||
// Check if at least one flag is provided
|
||||
if newName == "" && newURL == "" && newToken == "" {
|
||||
return fmt.Errorf("at least one of --name, --url, or --token must be provided")
|
||||
}
|
||||
|
||||
// Find the context
|
||||
var found bool
|
||||
var contextToUpdate map[string]interface{}
|
||||
for _, instance := range instances {
|
||||
instanceMap := instance.(map[string]interface{})
|
||||
if instanceMap["name"] == oldName {
|
||||
found = true
|
||||
contextToUpdate = instanceMap
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !found {
|
||||
return fmt.Errorf("context '%s' not found", oldName)
|
||||
}
|
||||
|
||||
// If renaming, check if new name already exists
|
||||
if newName != "" && newName != oldName {
|
||||
for _, instance := range instances {
|
||||
instanceMap := instance.(map[string]interface{})
|
||||
if instanceMap["name"] == newName {
|
||||
return fmt.Errorf("context with name '%s' already exists", newName)
|
||||
}
|
||||
}
|
||||
contextToUpdate["name"] = newName
|
||||
}
|
||||
|
||||
// Update URL if provided
|
||||
if newURL != "" {
|
||||
contextToUpdate["fqdn"] = newURL
|
||||
}
|
||||
|
||||
// Update token if provided
|
||||
if newToken != "" {
|
||||
contextToUpdate["token"] = newToken
|
||||
}
|
||||
|
||||
// Save changes
|
||||
viper.Set("instances", instances)
|
||||
if err := viper.WriteConfig(); err != nil {
|
||||
return fmt.Errorf("failed to save config: %w", err)
|
||||
}
|
||||
|
||||
// Use the new name if renamed, otherwise use old name
|
||||
finalName := oldName
|
||||
if newName != "" {
|
||||
finalName = newName
|
||||
}
|
||||
fmt.Printf("Context '%s' updated successfully.\n", finalName)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringP("name", "n", "", "New name for the context")
|
||||
cmd.Flags().StringP("url", "u", "", "New URL for the context")
|
||||
cmd.Flags().StringP("token", "t", "", "New token for the context")
|
||||
|
||||
return cmd
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package context
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/viper"
|
||||
|
||||
"github.com/coollabsio/coolify-cli/internal/cli"
|
||||
)
|
||||
|
||||
// NewUseCommand creates the use command
|
||||
func NewUseCommand() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "use <context_name>",
|
||||
Example: `context use myserver`,
|
||||
Args: cli.ExactArgs(1, "<context_name>"),
|
||||
Short: "Switch to a different context (set as default)",
|
||||
RunE: func(_ *cobra.Command, args []string) error {
|
||||
name := args[0]
|
||||
raw := viper.Get("instances")
|
||||
|
||||
instances, ok := raw.([]interface{})
|
||||
if !ok {
|
||||
return fmt.Errorf("invalid instances configuration")
|
||||
}
|
||||
// Check if instance exists
|
||||
var found bool
|
||||
for _, instance := range instances {
|
||||
instanceMap, ok := instance.(map[string]interface{})
|
||||
if !ok {
|
||||
return fmt.Errorf("invalid instance configuration")
|
||||
}
|
||||
if val, ok := instanceMap["name"].(string); ok && val == name {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !found {
|
||||
return fmt.Errorf("Context '%s' not found", name)
|
||||
}
|
||||
|
||||
// Update default
|
||||
for _, instance := range instances {
|
||||
instanceMap, ok := instance.(map[string]interface{})
|
||||
if !ok {
|
||||
return fmt.Errorf("invalid instance configuration")
|
||||
}
|
||||
|
||||
if val, ok := instanceMap["name"].(string); ok && val == name {
|
||||
instanceMap["default"] = true
|
||||
} else {
|
||||
delete(instanceMap, "default")
|
||||
}
|
||||
}
|
||||
|
||||
viper.Set("instances", instances)
|
||||
if err := viper.WriteConfig(); err != nil {
|
||||
return fmt.Errorf("failed to write config: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("Switched to context '%s'.\n", name)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package context
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/coollabsio/coolify-cli/internal/cli"
|
||||
)
|
||||
|
||||
// NewVerifyCommand creates the verify command for contexts
|
||||
func NewVerifyCommand() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "verify",
|
||||
Short: "Verify current context connection and authentication",
|
||||
Long: `Verify that the current context is properly configured by testing the connection
|
||||
to the Coolify instance and validating the API token.`,
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
ctx := cmd.Context()
|
||||
|
||||
// Get API client - this will use the current default context
|
||||
client, err := cli.GetAPIClient(cmd)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get API client: %w", err)
|
||||
}
|
||||
|
||||
// Try to get version - this verifies both connection and authentication
|
||||
version, err := client.GetVersion(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("verification failed: %w", err)
|
||||
}
|
||||
|
||||
// If we got here, connection and authentication are working
|
||||
fmt.Printf("✓ Connection successful\n")
|
||||
fmt.Printf("✓ Authentication valid\n")
|
||||
fmt.Printf("✓ Coolify version: %s\n", version)
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package context
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/coollabsio/coolify-cli/internal/api"
|
||||
)
|
||||
|
||||
// TestVerifyCommand_APIIntegration tests the verify logic using the API client directly
|
||||
// This tests the core functionality that the verify command relies on
|
||||
func TestVerifyCommand_APIIntegration(t *testing.T) {
|
||||
t.Run("successful verification", func(t *testing.T) {
|
||||
// Create a test HTTP server that responds to /api/v1/version
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "/api/v1/version", r.URL.Path)
|
||||
assert.Equal(t, "Bearer test-token", r.Header.Get("Authorization"))
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte("4.0.0-beta.383"))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
// Create API client and verify connection
|
||||
client := api.NewClient(server.URL, "test-token")
|
||||
version, err := client.GetVersion(context.Background())
|
||||
|
||||
// Verify results
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "4.0.0-beta.383", version)
|
||||
})
|
||||
|
||||
t.Run("unauthorized - invalid token", func(t *testing.T) {
|
||||
// Create a test HTTP server that returns 401
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{
|
||||
"message": "Invalid token",
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
// Create API client with invalid token
|
||||
client := api.NewClient(server.URL, "invalid-token")
|
||||
_, err := client.GetVersion(context.Background())
|
||||
|
||||
// Verify error
|
||||
require.Error(t, err)
|
||||
assert.True(t, api.IsUnauthorized(err))
|
||||
})
|
||||
|
||||
t.Run("server error", func(t *testing.T) {
|
||||
// Create a test HTTP server that returns 500
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{
|
||||
"error": "Internal server error",
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
// Create API client
|
||||
client := api.NewClient(server.URL, "test-token", api.WithRetries(0))
|
||||
_, err := client.GetVersion(context.Background())
|
||||
|
||||
// Verify error
|
||||
require.Error(t, err)
|
||||
var apiErr *api.Error
|
||||
require.ErrorAs(t, err, &apiErr)
|
||||
assert.Equal(t, 500, apiErr.StatusCode)
|
||||
})
|
||||
|
||||
t.Run("not found", func(t *testing.T) {
|
||||
// Create a test HTTP server that returns 404
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{
|
||||
"message": "Endpoint not found",
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
// Create API client
|
||||
client := api.NewClient(server.URL, "test-token")
|
||||
_, err := client.GetVersion(context.Background())
|
||||
|
||||
// Verify error
|
||||
require.Error(t, err)
|
||||
assert.True(t, api.IsNotFound(err))
|
||||
})
|
||||
}
|
||||
|
||||
// TestNewVerifyCommand tests that the command is properly configured
|
||||
func TestNewVerifyCommand(t *testing.T) {
|
||||
cmd := NewVerifyCommand()
|
||||
|
||||
assert.Equal(t, "verify", cmd.Use)
|
||||
assert.NotEmpty(t, cmd.Short)
|
||||
assert.NotEmpty(t, cmd.Long)
|
||||
assert.NotNil(t, cmd.RunE)
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package context
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/coollabsio/coolify-cli/internal/cli"
|
||||
)
|
||||
|
||||
// NewVersionCommand creates the version command for contexts
|
||||
func NewVersionCommand() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "version",
|
||||
Short: "Get current context's Coolify version",
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
ctx := cmd.Context()
|
||||
|
||||
// Get API client
|
||||
client, err := cli.GetAPIClient(cmd)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get API client: %w", err)
|
||||
}
|
||||
|
||||
// Get version using API client
|
||||
version, err := client.GetVersion(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get version: %w", err)
|
||||
}
|
||||
|
||||
fmt.Println(version)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
package backup
|
||||
|
||||
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/output"
|
||||
"github.com/coollabsio/coolify-cli/internal/service"
|
||||
)
|
||||
|
||||
// NewCreateCommand creates a new database
|
||||
func NewCreateCommand() *cobra.Command {
|
||||
createBackupCmd := &cobra.Command{
|
||||
Use: "create <database_uuid>",
|
||||
Short: "Create a new scheduled backup configuration",
|
||||
Long: `Create a new scheduled backup configuration for a database. Configure frequency, retention, S3 storage, and other backup options.
|
||||
|
||||
Example: coolify database backup create abc123 --frequency "0 0 * * *" --enabled`,
|
||||
Args: cli.ExactArgs(1, "<database_uuid>"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := cmd.Context()
|
||||
dbUUID := args[0]
|
||||
|
||||
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.436"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
req := &models.DatabaseBackupCreateRequest{}
|
||||
|
||||
// Apply flags if provided
|
||||
if cmd.Flags().Changed("frequency") {
|
||||
frequency, _ := cmd.Flags().GetString("frequency")
|
||||
req.Frequency = &frequency
|
||||
}
|
||||
if cmd.Flags().Changed("enabled") {
|
||||
enabled, _ := cmd.Flags().GetBool("enabled")
|
||||
req.Enabled = &enabled
|
||||
}
|
||||
if cmd.Flags().Changed("save-s3") {
|
||||
saveS3, _ := cmd.Flags().GetBool("save-s3")
|
||||
req.SaveS3 = &saveS3
|
||||
}
|
||||
if cmd.Flags().Changed("s3-storage-uuid") {
|
||||
s3UUID, _ := cmd.Flags().GetString("s3-storage-uuid")
|
||||
req.S3StorageUUID = &s3UUID
|
||||
}
|
||||
if cmd.Flags().Changed("databases") {
|
||||
databases, _ := cmd.Flags().GetString("databases")
|
||||
req.DatabasesToBackup = &databases
|
||||
}
|
||||
if cmd.Flags().Changed("dump-all") {
|
||||
dumpAll, _ := cmd.Flags().GetBool("dump-all")
|
||||
req.DumpAll = &dumpAll
|
||||
}
|
||||
if cmd.Flags().Changed("retention-amount-locally") {
|
||||
amount, _ := cmd.Flags().GetInt("retention-amount-locally")
|
||||
req.DatabaseBackupRetentionAmountLocally = &amount
|
||||
}
|
||||
if cmd.Flags().Changed("retention-days-locally") {
|
||||
days, _ := cmd.Flags().GetInt("retention-days-locally")
|
||||
req.DatabaseBackupRetentionDaysLocally = &days
|
||||
}
|
||||
if cmd.Flags().Changed("retention-storage-locally") {
|
||||
storage, _ := cmd.Flags().GetString("retention-storage-locally")
|
||||
req.DatabaseBackupRetentionMaxStorageLocally = &storage
|
||||
}
|
||||
if cmd.Flags().Changed("retention-amount-s3") {
|
||||
amount, _ := cmd.Flags().GetInt("retention-amount-s3")
|
||||
req.DatabaseBackupRetentionAmountS3 = &amount
|
||||
}
|
||||
if cmd.Flags().Changed("retention-days-s3") {
|
||||
days, _ := cmd.Flags().GetInt("retention-days-s3")
|
||||
req.DatabaseBackupRetentionDaysS3 = &days
|
||||
}
|
||||
if cmd.Flags().Changed("retention-storage-s3") {
|
||||
storage, _ := cmd.Flags().GetString("retention-storage-s3")
|
||||
req.DatabaseBackupRetentionMaxStorageS3 = &storage
|
||||
}
|
||||
if cmd.Flags().Changed("timeout") {
|
||||
timeout, _ := cmd.Flags().GetInt("timeout")
|
||||
req.Timeout = &timeout
|
||||
}
|
||||
if cmd.Flags().Changed("disable-local") {
|
||||
disableLocal, _ := cmd.Flags().GetBool("disable-local")
|
||||
req.DisableLocalBackup = &disableLocal
|
||||
}
|
||||
|
||||
dbService := service.NewDatabaseService(client)
|
||||
backup, err := dbService.CreateBackup(ctx, dbUUID, req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create backup: %w", err)
|
||||
}
|
||||
|
||||
format, _ := cmd.Flags().GetString("format")
|
||||
formatter, err := output.NewFormatter(format, output.Options{})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create formatter: %w", err)
|
||||
}
|
||||
|
||||
return formatter.Format(backup)
|
||||
},
|
||||
}
|
||||
|
||||
createBackupCmd.Flags().String("frequency", "", "Backup frequency (cron expression, e.g., '0 0 * * *' for daily)")
|
||||
createBackupCmd.Flags().Bool("enabled", false, "Enable backup schedule")
|
||||
createBackupCmd.Flags().Bool("save-s3", false, "Save backups to S3")
|
||||
createBackupCmd.Flags().String("s3-storage-uuid", "", "S3 storage UUID")
|
||||
createBackupCmd.Flags().String("databases-to-backup", "", "Comma-separated list of databases to backup")
|
||||
createBackupCmd.Flags().Bool("dump-all", false, "Dump all databases")
|
||||
createBackupCmd.Flags().Int("retention-amount-locally", 0, "Number of backups to retain locally")
|
||||
createBackupCmd.Flags().Int("retention-days-locally", 0, "Days to retain backups locally")
|
||||
createBackupCmd.Flags().String("retention-max-storage-locally", "", "Max storage for local backups (e.g., '1GB', '500MB')")
|
||||
createBackupCmd.Flags().Int("retention-amount-s3", 0, "Number of backups to retain in S3")
|
||||
createBackupCmd.Flags().Int("retention-days-s3", 0, "Days to retain backups in S3")
|
||||
createBackupCmd.Flags().String("retention-max-storage-s3", "", "Max storage for S3 backups (e.g., '1GB', '500MB')")
|
||||
createBackupCmd.Flags().Int("timeout", 0, "Backup timeout in seconds")
|
||||
createBackupCmd.Flags().Bool("disable-local-backup", false, "Disable local backup storage")
|
||||
|
||||
return createBackupCmd
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package backup
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/coollabsio/coolify-cli/internal/cli"
|
||||
"github.com/coollabsio/coolify-cli/internal/service"
|
||||
)
|
||||
|
||||
// NewDeleteExecutionCommand lists all databases
|
||||
func NewDeleteExecutionCommand() *cobra.Command {
|
||||
deleteBackupExecutionCmd := &cobra.Command{
|
||||
Use: "delete-execution <database_uuid> <backup_uuid> <execution_uuid>",
|
||||
Short: "Delete backup execution",
|
||||
Long: `Delete a specific backup execution and optionally from S3. First UUID is the database, second is the backup configuration, third is the specific execution.`,
|
||||
Args: cli.ExactArgs(3, "<database_uuid> <backup_uuid> <execution_uuid>"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := cmd.Context()
|
||||
dbUUID := args[0]
|
||||
backupUUID := args[1]
|
||||
executionUUID := args[2]
|
||||
|
||||
force, _ := cmd.Flags().GetBool("force")
|
||||
deleteS3, _ := cmd.Flags().GetBool("delete-s3")
|
||||
|
||||
if !force {
|
||||
fmt.Printf("Are you sure you want to delete backup execution %s? (y/N): ", executionUUID)
|
||||
reader := bufio.NewReader(os.Stdin)
|
||||
response, err := reader.ReadString('\n')
|
||||
if err != nil {
|
||||
return fmt.Errorf("error reading input: %w", err)
|
||||
}
|
||||
response = strings.TrimSpace(strings.ToLower(response))
|
||||
if response != "y" && response != "yes" {
|
||||
fmt.Println("Delete cancelled")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
client, err := cli.GetAPIClient(cmd)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get API client: %w", err)
|
||||
}
|
||||
|
||||
dbService := service.NewDatabaseService(client)
|
||||
err = dbService.DeleteBackupExecution(ctx, dbUUID, backupUUID, executionUUID, deleteS3)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete backup execution: %w", err)
|
||||
}
|
||||
|
||||
fmt.Println("Backup execution deleted successfully")
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
deleteBackupExecutionCmd.Flags().Bool("delete-s3", false, "Delete backup file from S3")
|
||||
return deleteBackupExecutionCmd
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package backup
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/coollabsio/coolify-cli/internal/cli"
|
||||
"github.com/coollabsio/coolify-cli/internal/service"
|
||||
)
|
||||
|
||||
// NewDeleteCommand deletes a database
|
||||
func NewDeleteCommand() *cobra.Command {
|
||||
deleteBackupCmd := &cobra.Command{
|
||||
Use: "delete <database_uuid> <backup_uuid>",
|
||||
Short: "Delete backup configuration",
|
||||
Long: `Delete a backup configuration and optionally all its executions from S3. First UUID is the database, second is the specific backup configuration.`,
|
||||
Args: cli.ExactArgs(2, "<database_uuid> <backup_uuid>"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := cmd.Context()
|
||||
dbUUID := args[0]
|
||||
backupUUID := args[1]
|
||||
|
||||
force, _ := cmd.Flags().GetBool("force")
|
||||
deleteS3, _ := cmd.Flags().GetBool("delete-s3")
|
||||
|
||||
if !force {
|
||||
fmt.Printf("Are you sure you want to delete backup configuration %s? (y/N): ", backupUUID)
|
||||
reader := bufio.NewReader(os.Stdin)
|
||||
response, err := reader.ReadString('\n')
|
||||
if err != nil {
|
||||
return fmt.Errorf("error reading input: %w", err)
|
||||
}
|
||||
response = strings.TrimSpace(strings.ToLower(response))
|
||||
if response != "y" && response != "yes" {
|
||||
fmt.Println("Delete cancelled")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
client, err := cli.GetAPIClient(cmd)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get API client: %w", err)
|
||||
}
|
||||
|
||||
dbService := service.NewDatabaseService(client)
|
||||
err = dbService.DeleteBackup(ctx, dbUUID, backupUUID, deleteS3)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete backup: %w", err)
|
||||
}
|
||||
|
||||
fmt.Println("Backup configuration deleted successfully")
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
deleteBackupCmd.Flags().Bool("delete-s3", false, "Delete backup files from S3")
|
||||
return deleteBackupCmd
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package backup
|
||||
|
||||
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"
|
||||
)
|
||||
|
||||
// NewExecutionCommand lists all databases
|
||||
func NewExecutionCommand() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "executions <database_uuid> <backup_uuid>",
|
||||
Short: "List backup executions",
|
||||
Long: `List all executions for a backup configuration. First UUID is the database, second is the specific backup configuration.`,
|
||||
Args: cli.ExactArgs(2, "<database_uuid> <backup_uuid>"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := cmd.Context()
|
||||
dbUUID := args[0]
|
||||
backupUUID := args[1]
|
||||
|
||||
client, err := cli.GetAPIClient(cmd)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get API client: %w", err)
|
||||
}
|
||||
|
||||
dbService := service.NewDatabaseService(client)
|
||||
executions, err := dbService.ListBackupExecutions(ctx, dbUUID, backupUUID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to list backup executions: %w", err)
|
||||
}
|
||||
|
||||
format, _ := cmd.Flags().GetString("format")
|
||||
formatter, err := output.NewFormatter(format, output.Options{})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create formatter: %w", err)
|
||||
}
|
||||
|
||||
return formatter.Format(executions)
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package backup
|
||||
|
||||
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 lists all databases
|
||||
func NewListCommand() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "list <database_uuid>",
|
||||
Short: "List all backup configurations for a database",
|
||||
Long: `List all backup configurations for a specific database.`,
|
||||
Args: cli.ExactArgs(1, "<database_uuid>"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := cmd.Context()
|
||||
dbUUID := args[0]
|
||||
|
||||
client, err := cli.GetAPIClient(cmd)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get API client: %w", err)
|
||||
}
|
||||
|
||||
dbService := service.NewDatabaseService(client)
|
||||
backups, err := dbService.ListBackups(ctx, dbUUID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to list backups: %w", err)
|
||||
}
|
||||
|
||||
format, _ := cmd.Flags().GetString("format")
|
||||
formatter, err := output.NewFormatter(format, output.Options{})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create formatter: %w", err)
|
||||
}
|
||||
|
||||
return formatter.Format(backups)
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package backup
|
||||
|
||||
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"
|
||||
)
|
||||
|
||||
// NewTriggerCommand triggers a database backup
|
||||
func NewTriggerCommand() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "trigger <database_uuid> <backup_uuid>",
|
||||
Short: "Trigger immediate backup",
|
||||
Long: `Trigger an immediate backup for a specific backup configuration. First UUID is the database, second is the specific backup configuration to trigger.`,
|
||||
Args: cli.ExactArgs(2, "<database_uuid> <backup_uuid>"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := cmd.Context()
|
||||
dbUUID := args[0]
|
||||
backupUUID := args[1]
|
||||
|
||||
client, err := cli.GetAPIClient(cmd)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get API client: %w", err)
|
||||
}
|
||||
|
||||
dbService := service.NewDatabaseService(client)
|
||||
|
||||
// Trigger immediate backup by updating with backup_now flag
|
||||
req := &models.DatabaseBackupUpdateRequest{
|
||||
BackupNow: cli.BoolPtr(true),
|
||||
}
|
||||
|
||||
err = dbService.UpdateBackup(ctx, dbUUID, backupUUID, req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to trigger backup: %w", err)
|
||||
}
|
||||
|
||||
fmt.Println("Immediate backup triggered successfully")
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package backup
|
||||
|
||||
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 updates a database
|
||||
func NewUpdateCommand() *cobra.Command {
|
||||
updateBackupCmd := &cobra.Command{
|
||||
Use: "update <database_uuid> <backup_uuid>",
|
||||
Short: "Update backup configuration",
|
||||
Long: `Update a backup configuration settings (frequency, retention, S3, etc.). First UUID is the database, second is the specific backup configuration.`,
|
||||
Args: cli.ExactArgs(2, "<database_uuid> <backup_uuid>"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := cmd.Context()
|
||||
dbUUID := args[0]
|
||||
backupUUID := args[1]
|
||||
|
||||
req := &models.DatabaseBackupUpdateRequest{}
|
||||
hasChanges := false
|
||||
|
||||
if cmd.Flags().Changed("enabled") {
|
||||
enabled, _ := cmd.Flags().GetBool("enabled")
|
||||
req.Enabled = &enabled
|
||||
hasChanges = true
|
||||
}
|
||||
if cmd.Flags().Changed("frequency") {
|
||||
freq, _ := cmd.Flags().GetString("frequency")
|
||||
req.Frequency = &freq
|
||||
hasChanges = true
|
||||
}
|
||||
if cmd.Flags().Changed("save-s3") {
|
||||
saveS3, _ := cmd.Flags().GetBool("save-s3")
|
||||
req.SaveS3 = &saveS3
|
||||
hasChanges = true
|
||||
}
|
||||
if cmd.Flags().Changed("s3-storage-uuid") {
|
||||
s3UUID, _ := cmd.Flags().GetString("s3-storage-uuid")
|
||||
req.S3StorageUUID = &s3UUID
|
||||
hasChanges = true
|
||||
}
|
||||
if cmd.Flags().Changed("databases-to-backup") {
|
||||
dbs, _ := cmd.Flags().GetString("databases-to-backup")
|
||||
req.DatabasesToBackup = &dbs
|
||||
hasChanges = true
|
||||
}
|
||||
if cmd.Flags().Changed("dump-all") {
|
||||
dumpAll, _ := cmd.Flags().GetBool("dump-all")
|
||||
req.DumpAll = &dumpAll
|
||||
hasChanges = true
|
||||
}
|
||||
|
||||
// Retention settings
|
||||
if cmd.Flags().Changed("retention-amount-locally") {
|
||||
amount, _ := cmd.Flags().GetInt("retention-amount-locally")
|
||||
req.DatabaseBackupRetentionAmountLocally = &amount
|
||||
hasChanges = true
|
||||
}
|
||||
if cmd.Flags().Changed("retention-days-locally") {
|
||||
days, _ := cmd.Flags().GetInt("retention-days-locally")
|
||||
req.DatabaseBackupRetentionDaysLocally = &days
|
||||
hasChanges = true
|
||||
}
|
||||
if cmd.Flags().Changed("retention-max-storage-locally") {
|
||||
storage, _ := cmd.Flags().GetInt("retention-max-storage-locally")
|
||||
req.DatabaseBackupRetentionMaxStorageLocally = &storage
|
||||
hasChanges = true
|
||||
}
|
||||
if cmd.Flags().Changed("retention-amount-s3") {
|
||||
amount, _ := cmd.Flags().GetInt("retention-amount-s3")
|
||||
req.DatabaseBackupRetentionAmountS3 = &amount
|
||||
hasChanges = true
|
||||
}
|
||||
if cmd.Flags().Changed("retention-days-s3") {
|
||||
days, _ := cmd.Flags().GetInt("retention-days-s3")
|
||||
req.DatabaseBackupRetentionDaysS3 = &days
|
||||
hasChanges = true
|
||||
}
|
||||
if cmd.Flags().Changed("retention-max-storage-s3") {
|
||||
storage, _ := cmd.Flags().GetInt("retention-max-storage-s3")
|
||||
req.DatabaseBackupRetentionMaxStorageS3 = &storage
|
||||
hasChanges = true
|
||||
}
|
||||
|
||||
if !hasChanges {
|
||||
return fmt.Errorf("no fields to update")
|
||||
}
|
||||
|
||||
client, err := cli.GetAPIClient(cmd)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get API client: %w", err)
|
||||
}
|
||||
|
||||
dbService := service.NewDatabaseService(client)
|
||||
err = dbService.UpdateBackup(ctx, dbUUID, backupUUID, req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to update backup: %w", err)
|
||||
}
|
||||
|
||||
fmt.Println("Backup configuration updated successfully")
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
updateBackupCmd.Flags().Bool("enabled", false, "Enable or disable backup")
|
||||
updateBackupCmd.Flags().String("frequency", "", "Backup frequency (cron expression)")
|
||||
updateBackupCmd.Flags().Bool("save-s3", false, "Save backups to S3")
|
||||
updateBackupCmd.Flags().String("s3-storage-uuid", "", "S3 storage UUID")
|
||||
updateBackupCmd.Flags().String("databases-to-backup", "", "Comma-separated list of databases to backup")
|
||||
updateBackupCmd.Flags().Bool("dump-all", false, "Dump all databases")
|
||||
updateBackupCmd.Flags().Int("retention-amount-locally", 0, "Number of backups to retain locally")
|
||||
updateBackupCmd.Flags().Int("retention-days-locally", 0, "Days to retain backups locally")
|
||||
updateBackupCmd.Flags().Int("retention-max-storage-locally", 0, "Max storage for local backups (MB)")
|
||||
updateBackupCmd.Flags().Int("retention-amount-s3", 0, "Number of backups to retain in S3")
|
||||
updateBackupCmd.Flags().Int("retention-days-s3", 0, "Days to retain backups in S3")
|
||||
updateBackupCmd.Flags().Int("retention-max-storage-s3", 0, "Max storage for S3 backups (MB)")
|
||||
|
||||
return updateBackupCmd
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
package database
|
||||
|
||||
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/output"
|
||||
"github.com/coollabsio/coolify-cli/internal/service"
|
||||
)
|
||||
|
||||
func NewCreateCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "create <type>",
|
||||
Short: "Create a new database",
|
||||
Long: `Create a new database of the specified type.
|
||||
|
||||
Supported types: postgresql, mysql, mariadb, mongodb, redis, keydb, clickhouse, dragonfly
|
||||
|
||||
Examples:
|
||||
coolify databases create postgresql --server-uuid=<uuid> --project-uuid=<uuid> --environment-name=production
|
||||
coolify databases create mysql --server-uuid=<uuid> --project-uuid=<uuid> --environment-name=production --name="My MySQL"`,
|
||||
Args: cli.ExactArgs(1, "<type>"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := cmd.Context()
|
||||
dbType := args[0]
|
||||
validTypes := []string{"postgresql", "mysql", "mariadb", "mongodb", "redis", "keydb", "clickhouse", "dragonfly"}
|
||||
isValid := false
|
||||
for _, t := range validTypes {
|
||||
if t == dbType {
|
||||
isValid = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !isValid {
|
||||
return fmt.Errorf("invalid database type '%s'. Valid types: %s", dbType, strings.Join(validTypes, ", "))
|
||||
}
|
||||
|
||||
serverUUID, _ := cmd.Flags().GetString("server-uuid")
|
||||
projectUUID, _ := cmd.Flags().GetString("project-uuid")
|
||||
environmentName, _ := cmd.Flags().GetString("environment-name")
|
||||
environmentUUID, _ := cmd.Flags().GetString("environment-uuid")
|
||||
|
||||
if serverUUID == "" || projectUUID == "" {
|
||||
return fmt.Errorf("--server-uuid and --project-uuid are required")
|
||||
}
|
||||
|
||||
if environmentName == "" && environmentUUID == "" {
|
||||
return fmt.Errorf("either --environment-name or --environment-uuid must be provided")
|
||||
}
|
||||
|
||||
req := &models.DatabaseCreateRequest{
|
||||
ServerUUID: serverUUID,
|
||||
ProjectUUID: projectUUID,
|
||||
}
|
||||
|
||||
if environmentName != "" {
|
||||
req.EnvironmentName = &environmentName
|
||||
}
|
||||
if environmentUUID != "" {
|
||||
req.EnvironmentUUID = &environmentUUID
|
||||
}
|
||||
|
||||
// Common flags
|
||||
if cmd.Flags().Changed("name") {
|
||||
name, _ := cmd.Flags().GetString("name")
|
||||
req.Name = &name
|
||||
}
|
||||
if cmd.Flags().Changed("description") {
|
||||
desc, _ := cmd.Flags().GetString("description")
|
||||
req.Description = &desc
|
||||
}
|
||||
if cmd.Flags().Changed("image") {
|
||||
image, _ := cmd.Flags().GetString("image")
|
||||
req.Image = &image
|
||||
}
|
||||
if cmd.Flags().Changed("destination-uuid") {
|
||||
dest, _ := cmd.Flags().GetString("destination-uuid")
|
||||
req.DestinationUUID = &dest
|
||||
}
|
||||
if cmd.Flags().Changed("instant-deploy") {
|
||||
instant, _ := cmd.Flags().GetBool("instant-deploy")
|
||||
req.InstantDeploy = &instant
|
||||
}
|
||||
if cmd.Flags().Changed("is-public") {
|
||||
isPublic, _ := cmd.Flags().GetBool("is-public")
|
||||
req.IsPublic = &isPublic
|
||||
}
|
||||
if cmd.Flags().Changed("public-port") {
|
||||
port, _ := cmd.Flags().GetInt("public-port")
|
||||
req.PublicPort = &port
|
||||
}
|
||||
|
||||
// Resource limits
|
||||
if cmd.Flags().Changed("limits-memory") {
|
||||
mem, _ := cmd.Flags().GetString("limits-memory")
|
||||
req.LimitsMemory = &mem
|
||||
}
|
||||
if cmd.Flags().Changed("limits-cpus") {
|
||||
cpus, _ := cmd.Flags().GetString("limits-cpus")
|
||||
req.LimitsCpus = &cpus
|
||||
}
|
||||
|
||||
// PostgreSQL specific
|
||||
if dbType == "postgresql" {
|
||||
if cmd.Flags().Changed("postgres-user") {
|
||||
user, _ := cmd.Flags().GetString("postgres-user")
|
||||
req.PostgresUser = &user
|
||||
}
|
||||
if cmd.Flags().Changed("postgres-password") {
|
||||
pass, _ := cmd.Flags().GetString("postgres-password")
|
||||
req.PostgresPassword = &pass
|
||||
}
|
||||
if cmd.Flags().Changed("postgres-db") {
|
||||
db, _ := cmd.Flags().GetString("postgres-db")
|
||||
req.PostgresDB = &db
|
||||
}
|
||||
}
|
||||
|
||||
// MySQL specific
|
||||
if dbType == "mysql" {
|
||||
if cmd.Flags().Changed("mysql-root-password") {
|
||||
pass, _ := cmd.Flags().GetString("mysql-root-password")
|
||||
req.MysqlRootPassword = &pass
|
||||
}
|
||||
if cmd.Flags().Changed("mysql-user") {
|
||||
user, _ := cmd.Flags().GetString("mysql-user")
|
||||
req.MysqlUser = &user
|
||||
}
|
||||
if cmd.Flags().Changed("mysql-password") {
|
||||
pass, _ := cmd.Flags().GetString("mysql-password")
|
||||
req.MysqlPassword = &pass
|
||||
}
|
||||
if cmd.Flags().Changed("mysql-database") {
|
||||
db, _ := cmd.Flags().GetString("mysql-database")
|
||||
req.MysqlDatabase = &db
|
||||
}
|
||||
}
|
||||
|
||||
// MariaDB specific
|
||||
if dbType == "mariadb" {
|
||||
if cmd.Flags().Changed("mariadb-root-password") {
|
||||
pass, _ := cmd.Flags().GetString("mariadb-root-password")
|
||||
req.MariadbRootPassword = &pass
|
||||
}
|
||||
if cmd.Flags().Changed("mariadb-user") {
|
||||
user, _ := cmd.Flags().GetString("mariadb-user")
|
||||
req.MariadbUser = &user
|
||||
}
|
||||
if cmd.Flags().Changed("mariadb-password") {
|
||||
pass, _ := cmd.Flags().GetString("mariadb-password")
|
||||
req.MariadbPassword = &pass
|
||||
}
|
||||
if cmd.Flags().Changed("mariadb-database") {
|
||||
db, _ := cmd.Flags().GetString("mariadb-database")
|
||||
req.MariadbDatabase = &db
|
||||
}
|
||||
}
|
||||
|
||||
// MongoDB specific
|
||||
if dbType == "mongodb" {
|
||||
if cmd.Flags().Changed("mongo-root-username") {
|
||||
user, _ := cmd.Flags().GetString("mongo-root-username")
|
||||
req.MongoInitdbRootUsername = &user
|
||||
}
|
||||
if cmd.Flags().Changed("mongo-root-password") {
|
||||
pass, _ := cmd.Flags().GetString("mongo-root-password")
|
||||
req.MongoInitdbRootPassword = &pass
|
||||
}
|
||||
if cmd.Flags().Changed("mongo-database") {
|
||||
db, _ := cmd.Flags().GetString("mongo-database")
|
||||
req.MongoInitdbDatabase = &db
|
||||
}
|
||||
}
|
||||
|
||||
// Redis specific
|
||||
if dbType == "redis" {
|
||||
if cmd.Flags().Changed("redis-password") {
|
||||
pass, _ := cmd.Flags().GetString("redis-password")
|
||||
req.RedisPassword = &pass
|
||||
}
|
||||
}
|
||||
|
||||
// KeyDB specific
|
||||
if dbType == "keydb" {
|
||||
if cmd.Flags().Changed("keydb-password") {
|
||||
pass, _ := cmd.Flags().GetString("keydb-password")
|
||||
req.KeydbPassword = &pass
|
||||
}
|
||||
}
|
||||
|
||||
// Clickhouse specific
|
||||
if dbType == "clickhouse" {
|
||||
if cmd.Flags().Changed("clickhouse-admin-user") {
|
||||
user, _ := cmd.Flags().GetString("clickhouse-admin-user")
|
||||
req.ClickhouseAdminUser = &user
|
||||
}
|
||||
if cmd.Flags().Changed("clickhouse-admin-password") {
|
||||
pass, _ := cmd.Flags().GetString("clickhouse-admin-password")
|
||||
req.ClickhouseAdminPassword = &pass
|
||||
}
|
||||
}
|
||||
|
||||
// Dragonfly specific
|
||||
if dbType == "dragonfly" {
|
||||
if cmd.Flags().Changed("dragonfly-password") {
|
||||
pass, _ := cmd.Flags().GetString("dragonfly-password")
|
||||
req.DragonflyPassword = &pass
|
||||
}
|
||||
}
|
||||
|
||||
client, err := cli.GetAPIClient(cmd)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get API client: %w", err)
|
||||
}
|
||||
|
||||
dbService := service.NewDatabaseService(client)
|
||||
database, err := dbService.Create(ctx, dbType, req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create database: %w", err)
|
||||
}
|
||||
|
||||
format, _ := cmd.Flags().GetString("format")
|
||||
formatter, err := output.NewFormatter(format, output.Options{})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create formatter: %w", err)
|
||||
}
|
||||
|
||||
return formatter.Format(database)
|
||||
},
|
||||
}
|
||||
|
||||
// Common flags
|
||||
cmd.Flags().String("server-uuid", "", "Server UUID (required)")
|
||||
cmd.Flags().String("project-uuid", "", "Project UUID (required)")
|
||||
cmd.Flags().String("environment-name", "", "Environment name")
|
||||
cmd.Flags().String("environment-uuid", "", "Environment UUID")
|
||||
cmd.Flags().String("destination-uuid", "", "Destination UUID if server has multiple destinations")
|
||||
cmd.Flags().String("name", "", "Database name")
|
||||
cmd.Flags().String("description", "", "Database description")
|
||||
cmd.Flags().String("image", "", "Docker image")
|
||||
cmd.Flags().Bool("instant-deploy", false, "Deploy immediately after creation")
|
||||
cmd.Flags().Bool("is-public", false, "Make database publicly accessible")
|
||||
cmd.Flags().Int("public-port", 0, "Public port")
|
||||
cmd.Flags().String("limits-memory", "", "Memory limit (e.g., '512m', '2g')")
|
||||
cmd.Flags().String("limits-cpus", "", "CPU limit (e.g., '0.5', '2')")
|
||||
|
||||
// PostgreSQL flags
|
||||
cmd.Flags().String("postgres-user", "", "PostgreSQL user")
|
||||
cmd.Flags().String("postgres-password", "", "PostgreSQL password")
|
||||
cmd.Flags().String("postgres-db", "", "PostgreSQL database name")
|
||||
|
||||
// MySQL flags
|
||||
cmd.Flags().String("mysql-root-password", "", "MySQL root password")
|
||||
cmd.Flags().String("mysql-user", "", "MySQL user")
|
||||
cmd.Flags().String("mysql-password", "", "MySQL password")
|
||||
cmd.Flags().String("mysql-database", "", "MySQL database name")
|
||||
|
||||
// MariaDB flags
|
||||
cmd.Flags().String("mariadb-root-password", "", "MariaDB root password")
|
||||
cmd.Flags().String("mariadb-user", "", "MariaDB user")
|
||||
cmd.Flags().String("mariadb-password", "", "MariaDB password")
|
||||
cmd.Flags().String("mariadb-database", "", "MariaDB database name")
|
||||
|
||||
// MongoDB flags
|
||||
cmd.Flags().String("mongo-root-username", "", "MongoDB root username")
|
||||
cmd.Flags().String("mongo-root-password", "", "MongoDB root password")
|
||||
cmd.Flags().String("mongo-database", "", "MongoDB database name")
|
||||
|
||||
// Redis flags
|
||||
cmd.Flags().String("redis-password", "", "Redis password")
|
||||
|
||||
// KeyDB flags
|
||||
cmd.Flags().String("keydb-password", "", "KeyDB password")
|
||||
|
||||
// Clickhouse flags
|
||||
cmd.Flags().String("clickhouse-admin-user", "", "Clickhouse admin user")
|
||||
cmd.Flags().String("clickhouse-admin-password", "", "Clickhouse admin password")
|
||||
|
||||
// Dragonfly flags
|
||||
cmd.Flags().String("dragonfly-password", "", "Dragonfly password")
|
||||
|
||||
return cmd
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package database
|
||||
|
||||
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
|
||||
func NewDatabaseCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "database",
|
||||
Aliases: []string{"databases", "db", "dbs"},
|
||||
Short: "Manage Coolify databases",
|
||||
Long: `Manage Coolify databases (PostgreSQL, MySQL, MongoDB, Redis, MariaDB, KeyDB, Clickhouse, Dragonfly).`,
|
||||
}
|
||||
|
||||
// Add main database commands
|
||||
cmd.AddCommand(NewListCommand())
|
||||
cmd.AddCommand(NewGetCommand())
|
||||
cmd.AddCommand(NewStartCommand())
|
||||
cmd.AddCommand(NewStopCommand())
|
||||
cmd.AddCommand(NewRestartCommand())
|
||||
cmd.AddCommand(NewCreateCommand())
|
||||
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",
|
||||
Short: "Manage database backups",
|
||||
}
|
||||
backupCmd.AddCommand(backup.NewCreateCommand())
|
||||
backupCmd.AddCommand(backup.NewListCommand())
|
||||
backupCmd.AddCommand(backup.NewDeleteCommand())
|
||||
backupCmd.AddCommand(backup.NewUpdateCommand())
|
||||
backupCmd.AddCommand(backup.NewTriggerCommand())
|
||||
backupCmd.AddCommand(backup.NewExecutionCommand())
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/coollabsio/coolify-cli/internal/cli"
|
||||
"github.com/coollabsio/coolify-cli/internal/service"
|
||||
)
|
||||
|
||||
// NewDeleteCommand deletes a database
|
||||
func NewDeleteCommand() *cobra.Command {
|
||||
deleteDatabaseCmd := &cobra.Command{
|
||||
Use: "delete <uuid>",
|
||||
Short: "Delete a database",
|
||||
Long: `Delete a database and optionally clean up its configurations, volumes, and networks.`,
|
||||
Args: cli.ExactArgs(1, "<uuid>"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := cmd.Context()
|
||||
uuid := args[0]
|
||||
|
||||
force, _ := cmd.Flags().GetBool("force")
|
||||
deleteConfigurations, _ := cmd.Flags().GetBool("delete-configurations")
|
||||
deleteVolumes, _ := cmd.Flags().GetBool("delete-volumes")
|
||||
dockerCleanup, _ := cmd.Flags().GetBool("docker-cleanup")
|
||||
deleteConnectedNetworks, _ := cmd.Flags().GetBool("delete-connected-networks")
|
||||
|
||||
if !force {
|
||||
fmt.Printf("Are you sure you want to delete database %s? (y/N): ", uuid)
|
||||
reader := bufio.NewReader(os.Stdin)
|
||||
response, err := reader.ReadString('\n')
|
||||
if err != nil {
|
||||
return fmt.Errorf("error reading input: %w", err)
|
||||
}
|
||||
response = strings.TrimSpace(strings.ToLower(response))
|
||||
if response != "y" && response != "yes" {
|
||||
fmt.Println("Delete cancelled")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
client, err := cli.GetAPIClient(cmd)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get API client: %w", err)
|
||||
}
|
||||
|
||||
dbService := service.NewDatabaseService(client)
|
||||
err = dbService.Delete(ctx, uuid, deleteConfigurations, deleteVolumes, dockerCleanup, deleteConnectedNetworks)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete database: %w", err)
|
||||
}
|
||||
|
||||
fmt.Println("Database deleted successfully")
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
deleteDatabaseCmd.Flags().Bool("delete-configurations", true, "Delete configurations")
|
||||
deleteDatabaseCmd.Flags().Bool("delete-volumes", true, "Delete volumes")
|
||||
deleteDatabaseCmd.Flags().Bool("docker-cleanup", true, "Run docker cleanup")
|
||||
deleteDatabaseCmd.Flags().Bool("delete-connected-networks", true, "Delete connected networks")
|
||||
|
||||
return deleteDatabaseCmd
|
||||
}
|
||||
Vendored
+79
@@ -0,0 +1,79 @@
|
||||
package env
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/coollabsio/coolify-cli/internal/cli"
|
||||
"github.com/coollabsio/coolify-cli/internal/models"
|
||||
"github.com/coollabsio/coolify-cli/internal/service"
|
||||
)
|
||||
|
||||
func NewCreateCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "create <database_uuid>",
|
||||
Short: "Create an environment variable for a database",
|
||||
Long: `Create a new environment variable for a specific database. Use --key and --value flags to specify the variable.`,
|
||||
Args: cli.ExactArgs(1, "<database_uuid>"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := cmd.Context()
|
||||
uuid := args[0]
|
||||
|
||||
client, err := cli.GetAPIClient(cmd)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get API client: %w", err)
|
||||
}
|
||||
|
||||
key, _ := cmd.Flags().GetString("key")
|
||||
value, _ := cmd.Flags().GetString("value")
|
||||
|
||||
if key == "" {
|
||||
return fmt.Errorf("--key is required")
|
||||
}
|
||||
if value == "" {
|
||||
return fmt.Errorf("--value is required")
|
||||
}
|
||||
|
||||
req := &models.DatabaseEnvironmentVariableCreateRequest{
|
||||
Key: key,
|
||||
Value: value,
|
||||
}
|
||||
|
||||
if cmd.Flags().Changed("is-literal") {
|
||||
isLiteral, _ := cmd.Flags().GetBool("is-literal")
|
||||
req.IsLiteral = &isLiteral
|
||||
}
|
||||
if cmd.Flags().Changed("is-multiline") {
|
||||
isMultiline, _ := cmd.Flags().GetBool("is-multiline")
|
||||
req.IsMultiline = &isMultiline
|
||||
}
|
||||
if cmd.Flags().Changed("is-shown-once") {
|
||||
isShownOnce, _ := cmd.Flags().GetBool("is-shown-once")
|
||||
req.IsShownOnce = &isShownOnce
|
||||
}
|
||||
if cmd.Flags().Changed("comment") {
|
||||
comment, _ := cmd.Flags().GetString("comment")
|
||||
req.Comment = &comment
|
||||
}
|
||||
|
||||
dbSvc := service.NewDatabaseService(client)
|
||||
_, err = dbSvc.CreateEnv(ctx, uuid, req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create environment variable: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("Environment variable '%s' created successfully.\n", key)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().String("key", "", "Environment variable key (required)")
|
||||
cmd.Flags().String("value", "", "Environment variable value (required)")
|
||||
cmd.Flags().Bool("is-literal", false, "Treat value as literal (don't interpolate variables)")
|
||||
cmd.Flags().Bool("is-multiline", false, "Value is multiline")
|
||||
cmd.Flags().Bool("is-shown-once", false, "Only show value once")
|
||||
cmd.Flags().String("comment", "", "Comment for the environment variable")
|
||||
|
||||
return cmd
|
||||
}
|
||||
Vendored
+56
@@ -0,0 +1,56 @@
|
||||
package env
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/coollabsio/coolify-cli/internal/cli"
|
||||
"github.com/coollabsio/coolify-cli/internal/service"
|
||||
)
|
||||
|
||||
func NewDeleteCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "delete <database_uuid> <env_uuid>",
|
||||
Short: "Delete an environment variable",
|
||||
Long: `Delete an environment variable from a database. First UUID is the database, second is the specific environment variable to delete.`,
|
||||
Args: cli.ExactArgs(2, "<uuid1> <uuid2>"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := cmd.Context()
|
||||
dbUUID := args[0]
|
||||
envUUID := args[1]
|
||||
|
||||
client, err := cli.GetAPIClient(cmd)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get API client: %w", err)
|
||||
}
|
||||
|
||||
force, _ := cmd.Flags().GetBool("force")
|
||||
|
||||
// Prompt for confirmation unless --force is used
|
||||
if !force {
|
||||
var response string
|
||||
fmt.Printf("Are you sure you want to delete this environment variable? (yes/no): ")
|
||||
_, _ = fmt.Scanln(&response)
|
||||
|
||||
if response != "yes" && response != "y" {
|
||||
fmt.Println("Delete cancelled.")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
dbSvc := service.NewDatabaseService(client)
|
||||
err = dbSvc.DeleteEnv(ctx, dbUUID, envUUID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete environment variable: %w", err)
|
||||
}
|
||||
|
||||
fmt.Println("Environment variable deleted successfully.")
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().Bool("force", false, "Skip confirmation prompt")
|
||||
|
||||
return cmd
|
||||
}
|
||||
Vendored
+57
@@ -0,0 +1,57 @@
|
||||
package env
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/coollabsio/coolify-cli/internal/cli"
|
||||
"github.com/coollabsio/coolify-cli/internal/output"
|
||||
"github.com/coollabsio/coolify-cli/internal/service"
|
||||
)
|
||||
|
||||
func NewGetCommand() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "get <database_uuid> <env_uuid_or_key>",
|
||||
Short: "Get environment variable details",
|
||||
Long: `Get detailed information about a specific environment variable. First UUID is the database, second is the environment variable UUID or key name.`,
|
||||
Args: cli.ExactArgs(2, "<uuid1> <uuid2>"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := cmd.Context()
|
||||
dbUUID := args[0]
|
||||
envUUID := args[1]
|
||||
|
||||
client, err := cli.GetAPIClient(cmd)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get API client: %w", err)
|
||||
}
|
||||
|
||||
dbSvc := service.NewDatabaseService(client)
|
||||
env, err := dbSvc.GetEnv(ctx, dbUUID, envUUID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get environment variable: %w", err)
|
||||
}
|
||||
|
||||
showSensitive, _ := cmd.Flags().GetBool("show-sensitive")
|
||||
|
||||
// Mask sensitive value unless --show-sensitive is used
|
||||
if !showSensitive {
|
||||
env.Value = "********"
|
||||
if env.RealValue != nil {
|
||||
masked := "********"
|
||||
env.RealValue = &masked
|
||||
}
|
||||
}
|
||||
|
||||
format, _ := cmd.Flags().GetString("format")
|
||||
formatter, err := output.NewFormatter(format, output.Options{
|
||||
ShowSensitive: showSensitive,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create formatter: %w", err)
|
||||
}
|
||||
|
||||
return formatter.Format(env)
|
||||
},
|
||||
}
|
||||
}
|
||||
Vendored
+58
@@ -0,0 +1,58 @@
|
||||
package env
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/coollabsio/coolify-cli/internal/cli"
|
||||
"github.com/coollabsio/coolify-cli/internal/output"
|
||||
"github.com/coollabsio/coolify-cli/internal/service"
|
||||
)
|
||||
|
||||
func NewListCommand() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "list <database_uuid>",
|
||||
Short: "List all environment variables for a database",
|
||||
Long: `List all environment variables for a specific database.`,
|
||||
Args: cli.ExactArgs(1, "<uuid>"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := cmd.Context()
|
||||
uuid := args[0]
|
||||
|
||||
client, err := cli.GetAPIClient(cmd)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get API client: %w", err)
|
||||
}
|
||||
|
||||
dbSvc := service.NewDatabaseService(client)
|
||||
envs, err := dbSvc.ListEnvs(ctx, uuid)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to list environment variables: %w", err)
|
||||
}
|
||||
|
||||
showSensitive, _ := cmd.Flags().GetBool("show-sensitive")
|
||||
|
||||
// Mask sensitive values unless --show-sensitive is used
|
||||
if !showSensitive {
|
||||
for i := range envs {
|
||||
envs[i].Value = "********"
|
||||
if envs[i].RealValue != nil {
|
||||
masked := "********"
|
||||
envs[i].RealValue = &masked
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
format, _ := cmd.Flags().GetString("format")
|
||||
formatter, err := output.NewFormatter(format, output.Options{
|
||||
ShowSensitive: showSensitive,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create formatter: %w", err)
|
||||
}
|
||||
|
||||
return formatter.Format(envs)
|
||||
},
|
||||
}
|
||||
}
|
||||
Vendored
+145
@@ -0,0 +1,145 @@
|
||||
package env
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/coollabsio/coolify-cli/internal/cli"
|
||||
"github.com/coollabsio/coolify-cli/internal/models"
|
||||
"github.com/coollabsio/coolify-cli/internal/parser"
|
||||
"github.com/coollabsio/coolify-cli/internal/service"
|
||||
)
|
||||
|
||||
func NewSyncCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "sync <database_uuid>",
|
||||
Short: "Sync environment variables from a .env file",
|
||||
Long: `Sync environment variables from a .env file. This command intelligently:
|
||||
- Updates existing environment variables with new values
|
||||
- Creates new environment variables that don't exist yet
|
||||
- Uses efficient bulk operations where possible
|
||||
|
||||
Example: coolify db env sync abc123 --file .env.production`,
|
||||
Args: cli.ExactArgs(1, "<uuid>"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := cmd.Context()
|
||||
uuid := args[0]
|
||||
|
||||
client, err := cli.GetAPIClient(cmd)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get API client: %w", err)
|
||||
}
|
||||
|
||||
filePath, _ := cmd.Flags().GetString("file")
|
||||
if filePath == "" {
|
||||
return fmt.Errorf("--file is required")
|
||||
}
|
||||
|
||||
isLiteral, _ := cmd.Flags().GetBool("is-literal")
|
||||
|
||||
// Parse the .env file
|
||||
envVars, err := parser.ParseEnvFile(filePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse .env file: %w", err)
|
||||
}
|
||||
|
||||
if len(envVars) == 0 {
|
||||
fmt.Println("No environment variables found in file.")
|
||||
return nil
|
||||
}
|
||||
|
||||
fmt.Printf("Found %d environment variables in file. Syncing...\n", len(envVars))
|
||||
|
||||
// Fetch existing environment variables
|
||||
dbSvc := service.NewDatabaseService(client)
|
||||
existingEnvs, err := dbSvc.ListEnvs(ctx, uuid)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to list existing environment variables: %w", err)
|
||||
}
|
||||
|
||||
// Build a map of existing env vars by key
|
||||
existingMap := make(map[string]models.DatabaseEnvironmentVariable)
|
||||
for _, env := range existingEnvs {
|
||||
existingMap[env.Key] = env
|
||||
}
|
||||
|
||||
// Separate into updates and creates
|
||||
var toUpdate []models.DatabaseEnvironmentVariableCreateRequest
|
||||
var toCreate []models.DatabaseEnvironmentVariableCreateRequest
|
||||
|
||||
for _, envVar := range envVars {
|
||||
req := models.DatabaseEnvironmentVariableCreateRequest{
|
||||
Key: envVar.Key,
|
||||
Value: envVar.Value,
|
||||
}
|
||||
|
||||
// Apply flags if explicitly provided
|
||||
if cmd.Flags().Changed("is-literal") {
|
||||
req.IsLiteral = &isLiteral
|
||||
}
|
||||
|
||||
// Auto-detect multiline values
|
||||
if strings.Contains(envVar.Value, "\n") {
|
||||
multiline := true
|
||||
req.IsMultiline = &multiline
|
||||
}
|
||||
|
||||
if _, exists := existingMap[envVar.Key]; exists {
|
||||
toUpdate = append(toUpdate, req)
|
||||
} else {
|
||||
toCreate = append(toCreate, req)
|
||||
}
|
||||
}
|
||||
|
||||
updateCount := 0
|
||||
createCount := 0
|
||||
failCount := 0
|
||||
|
||||
// Perform bulk update if there are vars to update
|
||||
if len(toUpdate) > 0 {
|
||||
fmt.Printf("Updating %d existing variables...\n", len(toUpdate))
|
||||
bulkReq := &models.DatabaseEnvBulkUpdateRequest{
|
||||
Data: toUpdate,
|
||||
}
|
||||
_, err := dbSvc.BulkUpdateEnvs(ctx, uuid, bulkReq)
|
||||
if err != nil {
|
||||
fmt.Printf(" ✗ Bulk update failed: %v\n", err)
|
||||
failCount += len(toUpdate)
|
||||
} else {
|
||||
updateCount = len(toUpdate)
|
||||
fmt.Printf(" ✓ Successfully updated %d variables\n", updateCount)
|
||||
}
|
||||
}
|
||||
|
||||
// Create new variables one by one
|
||||
if len(toCreate) > 0 {
|
||||
fmt.Printf("Creating %d new variables...\n", len(toCreate))
|
||||
for _, req := range toCreate {
|
||||
_, err := dbSvc.CreateEnv(ctx, uuid, &req)
|
||||
if err != nil {
|
||||
fmt.Printf(" ✗ Failed to create '%s': %v\n", req.Key, err)
|
||||
failCount++
|
||||
} else {
|
||||
fmt.Printf(" ✓ Created '%s'\n", req.Key)
|
||||
createCount++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Printf("\nSync complete: %d updated, %d created, %d failed\n", updateCount, createCount, failCount)
|
||||
|
||||
if failCount > 0 {
|
||||
return fmt.Errorf("some environment variables failed to sync")
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringP("file", "f", "", "Path to .env file (required)")
|
||||
cmd.Flags().Bool("is-literal", false, "Treat all values as literal (don't interpolate variables)")
|
||||
|
||||
return cmd
|
||||
}
|
||||
Vendored
+95
@@ -0,0 +1,95 @@
|
||||
package env
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/coollabsio/coolify-cli/internal/cli"
|
||||
"github.com/coollabsio/coolify-cli/internal/models"
|
||||
"github.com/coollabsio/coolify-cli/internal/service"
|
||||
)
|
||||
|
||||
func NewUpdateCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "update <database_uuid> <env_uuid_or_key>",
|
||||
Short: "Update an environment variable",
|
||||
Long: `Update an existing environment variable. Identify it by UUID or key name.`,
|
||||
Args: cli.ExactArgs(2, "<database_uuid> <env_uuid_or_key>"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := cmd.Context()
|
||||
dbUUID := args[0]
|
||||
envIdentifier := args[1]
|
||||
|
||||
client, err := cli.GetAPIClient(cmd)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get API client: %w", err)
|
||||
}
|
||||
|
||||
// Check minimum version requirement
|
||||
if err := cli.CheckMinimumVersion(ctx, client, "4.0.0-beta.469"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
dbSvc := service.NewDatabaseService(client)
|
||||
|
||||
// Look up the env var to resolve its key
|
||||
existingEnv, err := dbSvc.GetEnv(ctx, dbUUID, envIdentifier)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to find environment variable '%s': %w", envIdentifier, err)
|
||||
}
|
||||
|
||||
req := &models.DatabaseEnvironmentVariableUpdateRequest{}
|
||||
|
||||
// Use existing key unless --key flag explicitly provides a new one
|
||||
if cmd.Flags().Changed("key") {
|
||||
key, _ := cmd.Flags().GetString("key")
|
||||
req.Key = &key
|
||||
} else {
|
||||
req.Key = &existingEnv.Key
|
||||
}
|
||||
|
||||
if cmd.Flags().Changed("value") {
|
||||
value, _ := cmd.Flags().GetString("value")
|
||||
req.Value = &value
|
||||
}
|
||||
if cmd.Flags().Changed("is-literal") {
|
||||
isLiteral, _ := cmd.Flags().GetBool("is-literal")
|
||||
req.IsLiteral = &isLiteral
|
||||
}
|
||||
if cmd.Flags().Changed("is-multiline") {
|
||||
isMultiline, _ := cmd.Flags().GetBool("is-multiline")
|
||||
req.IsMultiline = &isMultiline
|
||||
}
|
||||
if cmd.Flags().Changed("is-shown-once") {
|
||||
isShownOnce, _ := cmd.Flags().GetBool("is-shown-once")
|
||||
req.IsShownOnce = &isShownOnce
|
||||
}
|
||||
if cmd.Flags().Changed("comment") {
|
||||
comment, _ := cmd.Flags().GetString("comment")
|
||||
req.Comment = &comment
|
||||
}
|
||||
|
||||
if req.Value == nil {
|
||||
return fmt.Errorf("--value is required")
|
||||
}
|
||||
|
||||
env, err := dbSvc.UpdateEnv(ctx, dbUUID, req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to update environment variable: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("Environment variable '%s' updated successfully.\n", env.Key)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().String("key", "", "New environment variable key (rename)")
|
||||
cmd.Flags().String("value", "", "New environment variable value (required)")
|
||||
cmd.Flags().Bool("is-literal", false, "Treat value as literal")
|
||||
cmd.Flags().Bool("is-multiline", false, "Value is multiline")
|
||||
cmd.Flags().Bool("is-shown-once", false, "Only show value once")
|
||||
cmd.Flags().String("comment", "", "Comment for the environment variable")
|
||||
|
||||
return cmd
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package database
|
||||
|
||||
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"
|
||||
)
|
||||
|
||||
// NewGetCommand gets database details
|
||||
func NewGetCommand() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "get <uuid>",
|
||||
Short: "Get database details",
|
||||
Long: `Get detailed information about a specific database by UUID.`,
|
||||
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)
|
||||
}
|
||||
|
||||
dbService := service.NewDatabaseService(client)
|
||||
database, err := dbService.Get(ctx, uuid)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get database: %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 fmt.Errorf("failed to create formatter: %w", err)
|
||||
}
|
||||
|
||||
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
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package database
|
||||
|
||||
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 lists all databases
|
||||
func NewListCommand() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List all databases",
|
||||
Long: `List all databases in Coolify.`,
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
ctx := cmd.Context()
|
||||
|
||||
client, err := cli.GetAPIClient(cmd)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get API client: %w", err)
|
||||
}
|
||||
|
||||
dbService := service.NewDatabaseService(client)
|
||||
databases, err := dbService.List(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to list databases: %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 fmt.Errorf("failed to create formatter: %w", err)
|
||||
}
|
||||
|
||||
if err := formatter.Format(databases); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !showSensitive && format == output.FormatTable {
|
||||
fmt.Println("\nNote: Use -s to show sensitive information.")
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/coollabsio/coolify-cli/internal/cli"
|
||||
"github.com/coollabsio/coolify-cli/internal/service"
|
||||
)
|
||||
|
||||
// NewRestartCommand restarts a database
|
||||
func NewRestartCommand() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "restart <uuid>",
|
||||
Short: "Restart a database",
|
||||
Long: `Restart a database by UUID.`,
|
||||
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)
|
||||
}
|
||||
|
||||
dbService := service.NewDatabaseService(client)
|
||||
response, err := dbService.Restart(ctx, uuid)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to restart database: %w", err)
|
||||
}
|
||||
|
||||
fmt.Println(response.Message)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/coollabsio/coolify-cli/internal/cli"
|
||||
"github.com/coollabsio/coolify-cli/internal/service"
|
||||
)
|
||||
|
||||
// NewStartCommand starts a database
|
||||
func NewStartCommand() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "start <uuid>",
|
||||
Short: "Start a database",
|
||||
Long: `Start a database by UUID.`,
|
||||
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)
|
||||
}
|
||||
|
||||
dbService := service.NewDatabaseService(client)
|
||||
response, err := dbService.Start(ctx, uuid)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to start database: %w", err)
|
||||
}
|
||||
|
||||
fmt.Println(response.Message)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/coollabsio/coolify-cli/internal/cli"
|
||||
"github.com/coollabsio/coolify-cli/internal/service"
|
||||
)
|
||||
|
||||
// NewStopCommand stops a database
|
||||
func NewStopCommand() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "stop <uuid>",
|
||||
Short: "Stop a database",
|
||||
Long: `Stop a database by UUID.`,
|
||||
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)
|
||||
}
|
||||
|
||||
dbService := service.NewDatabaseService(client)
|
||||
response, err := dbService.Stop(ctx, uuid)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to stop database: %w", err)
|
||||
}
|
||||
|
||||
fmt.Println(response.Message)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/coollabsio/coolify-cli/internal/cli"
|
||||
"github.com/coollabsio/coolify-cli/internal/models"
|
||||
"github.com/coollabsio/coolify-cli/internal/service"
|
||||
)
|
||||
|
||||
// NewCreateCommand returns the database storage create command
|
||||
func NewCreateCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "create <db_uuid>",
|
||||
Short: "Create a storage for a database",
|
||||
Long: `Create a persistent volume or file storage for a database.
|
||||
|
||||
Examples:
|
||||
coolify db storage create <db_uuid> --type persistent --name my-volume --mount-path /data
|
||||
coolify db storage create <db_uuid> --type file --mount-path /app/config.yml --content "key: value"`,
|
||||
Args: cli.ExactArgs(1, "<db_uuid>"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := cmd.Context()
|
||||
|
||||
storageType, _ := cmd.Flags().GetString("type")
|
||||
mountPath, _ := cmd.Flags().GetString("mount-path")
|
||||
|
||||
if storageType == "" {
|
||||
return fmt.Errorf("--type is required (persistent or file)")
|
||||
}
|
||||
if storageType != "persistent" && storageType != "file" {
|
||||
return fmt.Errorf("--type must be 'persistent' or 'file'")
|
||||
}
|
||||
if mountPath == "" {
|
||||
return fmt.Errorf("--mount-path is required")
|
||||
}
|
||||
|
||||
req := &models.StorageCreateRequest{
|
||||
Type: storageType,
|
||||
MountPath: mountPath,
|
||||
}
|
||||
|
||||
if cmd.Flags().Changed("name") {
|
||||
val, _ := cmd.Flags().GetString("name")
|
||||
req.Name = &val
|
||||
}
|
||||
if cmd.Flags().Changed("host-path") {
|
||||
val, _ := cmd.Flags().GetString("host-path")
|
||||
req.HostPath = &val
|
||||
}
|
||||
if cmd.Flags().Changed("content") {
|
||||
val, _ := cmd.Flags().GetString("content")
|
||||
req.Content = &val
|
||||
}
|
||||
if cmd.Flags().Changed("is-directory") {
|
||||
val, _ := cmd.Flags().GetBool("is-directory")
|
||||
req.IsDirectory = &val
|
||||
}
|
||||
if cmd.Flags().Changed("fs-path") {
|
||||
val, _ := cmd.Flags().GetString("fs-path")
|
||||
req.FsPath = &val
|
||||
}
|
||||
|
||||
client, err := cli.GetAPIClient(cmd)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get API client: %w", err)
|
||||
}
|
||||
|
||||
if err := cli.CheckMinimumVersion(ctx, client, "4.0.0-beta.470"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
dbSvc := service.NewDatabaseService(client)
|
||||
if err := dbSvc.CreateStorage(ctx, args[0], req); err != nil {
|
||||
return fmt.Errorf("failed to create storage: %w", err)
|
||||
}
|
||||
|
||||
fmt.Println("Storage created successfully.")
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().String("type", "", "Storage type: 'persistent' or 'file' (required)")
|
||||
cmd.Flags().String("mount-path", "", "Mount path inside the container (required)")
|
||||
cmd.Flags().String("name", "", "Volume name (persistent only)")
|
||||
cmd.Flags().String("host-path", "", "Host path (persistent only)")
|
||||
cmd.Flags().String("content", "", "File content (file only)")
|
||||
cmd.Flags().Bool("is-directory", false, "Whether this is a directory mount (file only)")
|
||||
cmd.Flags().String("fs-path", "", "Host directory path (file only, required when --is-directory is set)")
|
||||
|
||||
return cmd
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/coollabsio/coolify-cli/internal/cli"
|
||||
"github.com/coollabsio/coolify-cli/internal/service"
|
||||
)
|
||||
|
||||
// NewDeleteCommand returns the database storage delete command
|
||||
func NewDeleteCommand() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "delete <db_uuid> <storage_uuid>",
|
||||
Short: "Delete a storage from a database",
|
||||
Long: `Delete a persistent volume or file storage from a database.
|
||||
|
||||
Examples:
|
||||
coolify db storage delete <db_uuid> <storage_uuid>`,
|
||||
Args: cli.ExactArgs(2, "<db_uuid> <storage_uuid>"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := cmd.Context()
|
||||
|
||||
client, err := cli.GetAPIClient(cmd)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get API client: %w", err)
|
||||
}
|
||||
|
||||
if err := cli.CheckMinimumVersion(ctx, client, "4.0.0-beta.470"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
dbSvc := service.NewDatabaseService(client)
|
||||
if err := dbSvc.DeleteStorage(ctx, args[0], args[1]); err != nil {
|
||||
return fmt.Errorf("failed to delete storage: %w", err)
|
||||
}
|
||||
|
||||
fmt.Println("Storage deleted successfully.")
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/coollabsio/coolify-cli/internal/cli"
|
||||
"github.com/coollabsio/coolify-cli/internal/output"
|
||||
"github.com/coollabsio/coolify-cli/internal/service"
|
||||
)
|
||||
|
||||
// NewListCommand returns the database storage list command
|
||||
func NewListCommand() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "list <db_uuid>",
|
||||
Short: "List all storages for a database",
|
||||
Long: `List all persistent volumes and file storages for a specific database.`,
|
||||
Args: cli.ExactArgs(1, "<db_uuid>"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := cmd.Context()
|
||||
|
||||
client, err := cli.GetAPIClient(cmd)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get API client: %w", err)
|
||||
}
|
||||
|
||||
if err := cli.CheckMinimumVersion(ctx, client, "4.0.0-beta.470"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
dbSvc := service.NewDatabaseService(client)
|
||||
storages, err := dbSvc.ListStorages(ctx, args[0])
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to list storages: %w", err)
|
||||
}
|
||||
|
||||
format, _ := cmd.Flags().GetString("format")
|
||||
showSensitive, _ := cmd.Flags().GetBool("show-sensitive")
|
||||
|
||||
formatter, err := output.NewFormatter(format, output.Options{
|
||||
ShowSensitive: showSensitive,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return formatter.Format(storages)
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/coollabsio/coolify-cli/internal/cli"
|
||||
"github.com/coollabsio/coolify-cli/internal/models"
|
||||
"github.com/coollabsio/coolify-cli/internal/service"
|
||||
)
|
||||
|
||||
// NewUpdateCommand returns the database storage update command
|
||||
func NewUpdateCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "update <db_uuid>",
|
||||
Short: "Update a storage for a database",
|
||||
Long: `Update a persistent volume or file storage for a database.
|
||||
|
||||
The --uuid and --type flags are required. Use 'coolify db storage list' to find storage UUIDs.
|
||||
|
||||
Examples:
|
||||
coolify db storage update <db_uuid> --uuid <storage_uuid> --type persistent --name my-volume
|
||||
coolify db storage update <db_uuid> --uuid <storage_uuid> --type persistent --is-preview-suffix-enabled`,
|
||||
Args: cli.ExactArgs(1, "<db_uuid>"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := cmd.Context()
|
||||
|
||||
storageUUID, _ := cmd.Flags().GetString("uuid")
|
||||
storageID, _ := cmd.Flags().GetInt("id")
|
||||
storageType, _ := cmd.Flags().GetString("type")
|
||||
|
||||
if storageUUID == "" && storageID == 0 {
|
||||
return fmt.Errorf("--uuid is required (or --id as deprecated fallback)")
|
||||
}
|
||||
if storageType == "" {
|
||||
return fmt.Errorf("--type is required (persistent or file)")
|
||||
}
|
||||
if storageType != "persistent" && storageType != "file" {
|
||||
return fmt.Errorf("--type must be 'persistent' or 'file'")
|
||||
}
|
||||
|
||||
req := &models.StorageUpdateRequest{
|
||||
Type: storageType,
|
||||
}
|
||||
|
||||
if storageUUID != "" {
|
||||
req.UUID = &storageUUID
|
||||
} else {
|
||||
req.ID = &storageID
|
||||
}
|
||||
|
||||
hasUpdates := false
|
||||
|
||||
if cmd.Flags().Changed("is-preview-suffix-enabled") {
|
||||
val, _ := cmd.Flags().GetBool("is-preview-suffix-enabled")
|
||||
req.IsPreviewSuffixEnabled = &val
|
||||
hasUpdates = true
|
||||
}
|
||||
if cmd.Flags().Changed("name") {
|
||||
val, _ := cmd.Flags().GetString("name")
|
||||
req.Name = &val
|
||||
hasUpdates = true
|
||||
}
|
||||
if cmd.Flags().Changed("mount-path") {
|
||||
val, _ := cmd.Flags().GetString("mount-path")
|
||||
req.MountPath = &val
|
||||
hasUpdates = true
|
||||
}
|
||||
if cmd.Flags().Changed("host-path") {
|
||||
val, _ := cmd.Flags().GetString("host-path")
|
||||
req.HostPath = &val
|
||||
hasUpdates = true
|
||||
}
|
||||
if cmd.Flags().Changed("content") {
|
||||
val, _ := cmd.Flags().GetString("content")
|
||||
req.Content = &val
|
||||
hasUpdates = true
|
||||
}
|
||||
|
||||
if !hasUpdates {
|
||||
return fmt.Errorf("no fields to update. Use --help to see available flags")
|
||||
}
|
||||
|
||||
client, err := cli.GetAPIClient(cmd)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get API client: %w", err)
|
||||
}
|
||||
|
||||
if err := cli.CheckMinimumVersion(ctx, client, "4.0.0-beta.470"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
dbSvc := service.NewDatabaseService(client)
|
||||
if err := dbSvc.UpdateStorage(ctx, args[0], req); err != nil {
|
||||
return fmt.Errorf("failed to update storage: %w", err)
|
||||
}
|
||||
|
||||
fmt.Println("Storage updated successfully.")
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().String("uuid", "", "Storage UUID (required, use 'storage list' to find)")
|
||||
cmd.Flags().Int("id", 0, "Storage ID (deprecated, use --uuid instead)")
|
||||
cmd.Flags().String("type", "", "Storage type: 'persistent' or 'file' (required)")
|
||||
cmd.Flags().Bool("is-preview-suffix-enabled", false, "Enable preview suffix for this storage")
|
||||
cmd.Flags().String("name", "", "Storage name (persistent only)")
|
||||
cmd.Flags().String("mount-path", "", "Mount path inside the container")
|
||||
cmd.Flags().String("host-path", "", "Host path (persistent only)")
|
||||
cmd.Flags().String("content", "", "File content (file only)")
|
||||
|
||||
return cmd
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package database
|
||||
|
||||
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 updates a database
|
||||
func NewUpdateCommand() *cobra.Command {
|
||||
updateDatabaseCmd := &cobra.Command{
|
||||
Use: "update <uuid>",
|
||||
Short: "Update a database",
|
||||
Long: `Update a database's configuration by UUID.`,
|
||||
Args: cli.ExactArgs(1, "<uuid>"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := cmd.Context()
|
||||
uuid := args[0]
|
||||
|
||||
req := &models.DatabaseUpdateRequest{}
|
||||
hasChanges := false
|
||||
|
||||
if cmd.Flags().Changed("name") {
|
||||
name, _ := cmd.Flags().GetString("name")
|
||||
req.Name = &name
|
||||
hasChanges = true
|
||||
}
|
||||
if cmd.Flags().Changed("description") {
|
||||
desc, _ := cmd.Flags().GetString("description")
|
||||
req.Description = &desc
|
||||
hasChanges = true
|
||||
}
|
||||
if cmd.Flags().Changed("image") {
|
||||
image, _ := cmd.Flags().GetString("image")
|
||||
req.Image = &image
|
||||
hasChanges = true
|
||||
}
|
||||
if cmd.Flags().Changed("is-public") {
|
||||
isPublic, _ := cmd.Flags().GetBool("is-public")
|
||||
req.IsPublic = &isPublic
|
||||
hasChanges = true
|
||||
}
|
||||
if cmd.Flags().Changed("public-port") {
|
||||
port, _ := cmd.Flags().GetInt("public-port")
|
||||
req.PublicPort = &port
|
||||
hasChanges = true
|
||||
}
|
||||
|
||||
// Resource limits
|
||||
if cmd.Flags().Changed("limits-memory") {
|
||||
mem, _ := cmd.Flags().GetString("limits-memory")
|
||||
req.LimitsMemory = &mem
|
||||
hasChanges = true
|
||||
}
|
||||
if cmd.Flags().Changed("limits-cpus") {
|
||||
cpus, _ := cmd.Flags().GetString("limits-cpus")
|
||||
req.LimitsCpus = &cpus
|
||||
hasChanges = true
|
||||
}
|
||||
|
||||
if !hasChanges {
|
||||
return fmt.Errorf("no fields to update")
|
||||
}
|
||||
|
||||
// Validate is-public requires public-port
|
||||
if req.IsPublic != nil && *req.IsPublic {
|
||||
// If setting to public, check if port is provided or fetch current database to check existing port
|
||||
if req.PublicPort == nil || *req.PublicPort == 0 {
|
||||
client, err := cli.GetAPIClient(cmd)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get API client: %w", err)
|
||||
}
|
||||
|
||||
dbService := service.NewDatabaseService(client)
|
||||
currentDB, err := dbService.Get(ctx, uuid)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get current database: %w", err)
|
||||
}
|
||||
|
||||
// Check if database already has a public port
|
||||
if currentDB.PublicPort == nil || *currentDB.PublicPort == 0 {
|
||||
return fmt.Errorf("cannot set database as public without a public port. Please provide --public-port")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
client, err := cli.GetAPIClient(cmd)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get API client: %w", err)
|
||||
}
|
||||
|
||||
dbService := service.NewDatabaseService(client)
|
||||
err = dbService.Update(ctx, uuid, req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to update database: %w", err)
|
||||
}
|
||||
|
||||
fmt.Println("Database updated successfully")
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
updateDatabaseCmd.Flags().String("name", "", "Database name")
|
||||
updateDatabaseCmd.Flags().String("description", "", "Database description")
|
||||
updateDatabaseCmd.Flags().String("image", "", "Docker image")
|
||||
updateDatabaseCmd.Flags().Bool("is-public", false, "Make database publicly accessible")
|
||||
updateDatabaseCmd.Flags().Int("public-port", 0, "Public port")
|
||||
updateDatabaseCmd.Flags().String("limits-memory", "", "Memory limit")
|
||||
updateDatabaseCmd.Flags().String("limits-cpus", "", "CPU limit")
|
||||
|
||||
return updateDatabaseCmd
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type Deploy struct {
|
||||
Deployments []Deployment `json:"deployments"`
|
||||
}
|
||||
|
||||
type Deployment struct {
|
||||
Message string `json:"message"`
|
||||
ResourceUuid string `json:"resource_uuid"`
|
||||
DeploymentUuid string `json:"deployment_uuid"`
|
||||
}
|
||||
|
||||
var deployCmd = &cobra.Command{
|
||||
Use: "deploy",
|
||||
Short: "Deploy related commands",
|
||||
}
|
||||
|
||||
var deployByUuidCmd = &cobra.Command{
|
||||
Use: "uuid <uuid>",
|
||||
Short: "Deploy by uuid",
|
||||
Args: cobra.MinimumNArgs(1),
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
CheckDefaultThings(nil)
|
||||
var CsvUuids = ""
|
||||
for _, uuid := range args {
|
||||
CsvUuids += uuid + ","
|
||||
}
|
||||
CsvUuids = CsvUuids[:len(CsvUuids)-1]
|
||||
data, err := Fetch("deploy?uuid=" + CsvUuids)
|
||||
if err != nil {
|
||||
log.Println(err)
|
||||
return
|
||||
}
|
||||
|
||||
if PrettyMode {
|
||||
var prettyJSON bytes.Buffer
|
||||
err := json.Indent(&prettyJSON, []byte(data), "", "\t")
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
fmt.Println(string(prettyJSON.String()))
|
||||
return
|
||||
}
|
||||
if JsonMode {
|
||||
fmt.Println(data)
|
||||
return
|
||||
}
|
||||
var jsondata Deploy
|
||||
err = json.Unmarshal([]byte(data), &jsondata)
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Fprintln(w, "Message\tResource Uuid\tDeployment Uuid")
|
||||
for _, resource := range jsondata.Deployments {
|
||||
fmt.Fprintf(w, "%s\t%s\t%s\n", resource.Message, resource.ResourceUuid, resource.DeploymentUuid)
|
||||
}
|
||||
w.Flush()
|
||||
|
||||
},
|
||||
}
|
||||
|
||||
// TODO deployByTagCmd
|
||||
|
||||
func init() {
|
||||
rootCmd.AddCommand(deployCmd)
|
||||
deployCmd.AddCommand(deployByUuidCmd)
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
package deployment
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/coollabsio/coolify-cli/internal/cli"
|
||||
"github.com/coollabsio/coolify-cli/internal/service"
|
||||
)
|
||||
|
||||
// NewBatchCommand deploys multiple resources by name
|
||||
func NewBatchCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "batch <name1,name2,...>",
|
||||
Short: "Deploy multiple resources by name",
|
||||
Long: `Deploy multiple resources at once.
|
||||
Provide resource names as comma-separated values.
|
||||
Example: coolify deploy batch app1,app2,app3`,
|
||||
Args: cli.ExactArgs(1, "<names>"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := cmd.Context()
|
||||
namesStr := args[0]
|
||||
|
||||
client, err := cli.GetAPIClient(cmd)
|
||||
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)
|
||||
for _, name := range strings.Split(namesStr, ",") {
|
||||
name = strings.TrimSpace(name)
|
||||
if name != "" {
|
||||
names = append(names, name)
|
||||
}
|
||||
}
|
||||
|
||||
if len(names) == 0 {
|
||||
return fmt.Errorf("no resource names provided")
|
||||
}
|
||||
|
||||
// Find resources by name
|
||||
resourceSvc := service.NewResourceService(client)
|
||||
resources, err := resourceSvc.List(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to list resources: %w", err)
|
||||
}
|
||||
|
||||
// Build map of name -> UUID
|
||||
nameToUUID := make(map[string]string)
|
||||
for _, r := range resources {
|
||||
nameToUUID[r.Name] = r.UUID
|
||||
}
|
||||
|
||||
// Validate all names exist
|
||||
var notFound []string
|
||||
for _, name := range names {
|
||||
if _, exists := nameToUUID[name]; !exists {
|
||||
notFound = append(notFound, name)
|
||||
}
|
||||
}
|
||||
if len(notFound) > 0 {
|
||||
return fmt.Errorf("resources not found: %v", notFound)
|
||||
}
|
||||
|
||||
// Deploy all resources
|
||||
deploySvc := service.NewDeploymentService(client)
|
||||
|
||||
type result struct {
|
||||
Name string
|
||||
UUID string
|
||||
Success bool
|
||||
Message string
|
||||
Error string
|
||||
}
|
||||
|
||||
results := make([]result, 0, len(names))
|
||||
|
||||
for _, name := range names {
|
||||
uuid := nameToUUID[name]
|
||||
fmt.Printf("Deploying %s...\n", name)
|
||||
|
||||
res, err := deploySvc.Deploy(ctx, getDeployRequest(cmd, uuid))
|
||||
if err != nil {
|
||||
results = append(results, result{
|
||||
Name: name,
|
||||
UUID: uuid,
|
||||
Success: false,
|
||||
Error: err.Error(),
|
||||
})
|
||||
fmt.Printf(" ❌ Failed: %v\n", err)
|
||||
} else {
|
||||
// Get first deployment message from the array
|
||||
message := ""
|
||||
if len(res.Deployments) > 0 {
|
||||
message = res.Deployments[0].Message
|
||||
}
|
||||
results = append(results, result{
|
||||
Name: name,
|
||||
UUID: uuid,
|
||||
Success: true,
|
||||
Message: message,
|
||||
})
|
||||
fmt.Printf(" ✅ Success: %s\n", message)
|
||||
}
|
||||
}
|
||||
|
||||
// Summary
|
||||
successCount := 0
|
||||
for _, r := range results {
|
||||
if r.Success {
|
||||
successCount++
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Printf("\nBatch deployment complete: %d/%d succeeded\n", successCount, len(results))
|
||||
|
||||
if successCount < len(results) {
|
||||
return fmt.Errorf("some deployments failed")
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
addDeployFlags(cmd)
|
||||
return cmd
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package deployment
|
||||
|
||||
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"
|
||||
)
|
||||
|
||||
// NewCancelCommand cancels a deployment
|
||||
func NewCancelCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "cancel <uuid>",
|
||||
Short: "Cancel a deployment by UUID",
|
||||
Long: `Cancel an in-progress deployment. This will stop the deployment process and clean up any temporary resources.`,
|
||||
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)
|
||||
}
|
||||
|
||||
// Check minimum version requirement
|
||||
if err := cli.CheckMinimumVersion(ctx, client, "4.0.0-beta.436"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
force, err := cmd.Flags().GetBool("force")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse force flag: %w", err)
|
||||
}
|
||||
|
||||
// Prompt for confirmation unless --force is used
|
||||
if !force {
|
||||
fmt.Printf("Are you sure you want to cancel deployment %s? (yes/no): ", uuid)
|
||||
var response string
|
||||
if _, err := fmt.Scanln(&response); err != nil {
|
||||
return fmt.Errorf("failed to read confirmation: %w", err)
|
||||
}
|
||||
|
||||
if response != "yes" && response != "y" {
|
||||
fmt.Println("Cancel aborted.")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
deploySvc := service.NewDeploymentService(client)
|
||||
result, err := deploySvc.Cancel(ctx, uuid)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to cancel deployment: %w", err)
|
||||
}
|
||||
|
||||
format, err := cmd.Flags().GetString("format")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get format flag: %w", err)
|
||||
}
|
||||
|
||||
formatter, err := output.NewFormatter(format, output.Options{})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create formatter: %w", err)
|
||||
}
|
||||
|
||||
return formatter.Format(result)
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().BoolP("force", "f", false, "Skip confirmation prompt")
|
||||
return cmd
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package deployment
|
||||
|
||||
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 {
|
||||
cmd := &cobra.Command{
|
||||
Use: "deploy",
|
||||
Short: "Deploy related commands",
|
||||
}
|
||||
|
||||
// Add all deployment subcommands
|
||||
cmd.AddCommand(NewUUIDCommand())
|
||||
cmd.AddCommand(NewNameCommand())
|
||||
cmd.AddCommand(NewBatchCommand())
|
||||
cmd.AddCommand(NewListCommand())
|
||||
cmd.AddCommand(NewGetCommand())
|
||||
cmd.AddCommand(NewCancelCommand())
|
||||
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package deployment
|
||||
|
||||
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"
|
||||
)
|
||||
|
||||
// NewGetCommand gets deployment details
|
||||
func NewGetCommand() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "get <uuid>",
|
||||
Short: "Get deployment details by UUID",
|
||||
Long: `Get detailed information about a specific deployment by its UUID.`,
|
||||
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)
|
||||
}
|
||||
|
||||
deploySvc := service.NewDeploymentService(client)
|
||||
deployment, err := deploySvc.Get(ctx, uuid)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get deployment: %w", err)
|
||||
}
|
||||
|
||||
format, _ := cmd.Flags().GetString("format")
|
||||
formatter, err := output.NewFormatter(format, output.Options{})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create formatter: %w", err)
|
||||
}
|
||||
|
||||
return formatter.Format(deployment)
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package deployment
|
||||
|
||||
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 lists all deployments
|
||||
func NewListCommand() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List all deployments",
|
||||
Long: `List all currently running deployments across all resources.`,
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
ctx := cmd.Context()
|
||||
|
||||
client, err := cli.GetAPIClient(cmd)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get API client: %w", err)
|
||||
}
|
||||
|
||||
deploySvc := service.NewDeploymentService(client)
|
||||
deployments, err := deploySvc.List(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to list deployments: %w", err)
|
||||
}
|
||||
|
||||
format, _ := cmd.Flags().GetString("format")
|
||||
formatter, err := output.NewFormatter(format, output.Options{})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create formatter: %w", err)
|
||||
}
|
||||
|
||||
return formatter.Format(deployments)
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package deployment
|
||||
|
||||
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"
|
||||
)
|
||||
|
||||
// NewNameCommand deploys a resource by name
|
||||
func NewNameCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "name <resource_name>",
|
||||
Short: "Deploy by resource name",
|
||||
Args: cli.ExactArgs(1, "<resource_name>"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := cmd.Context()
|
||||
name := args[0]
|
||||
|
||||
client, err := cli.GetAPIClient(cmd)
|
||||
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)
|
||||
resources, err := resourceSvc.List(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to list resources: %w", err)
|
||||
}
|
||||
|
||||
var matchedUUID string
|
||||
for _, r := range resources {
|
||||
if r.Name == name {
|
||||
matchedUUID = r.UUID
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if matchedUUID == "" {
|
||||
return fmt.Errorf("resource with name '%s' not found", name)
|
||||
}
|
||||
|
||||
// Deploy using the found UUID
|
||||
deploySvc := service.NewDeploymentService(client)
|
||||
result, err := deploySvc.Deploy(ctx, getDeployRequest(cmd, matchedUUID))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to deploy resource: %w", err)
|
||||
}
|
||||
|
||||
format, _ := cmd.Flags().GetString("format")
|
||||
formatter, err := output.NewFormatter(format, output.Options{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// For table format, convert deployment info array to display format
|
||||
if format == output.FormatTable {
|
||||
displays := make([]ResultDisplay, len(result.Deployments))
|
||||
for i, dep := range result.Deployments {
|
||||
displays[i] = ResultDisplay{
|
||||
Message: dep.Message,
|
||||
DeploymentUUID: dep.DeploymentUUID,
|
||||
}
|
||||
}
|
||||
return formatter.Format(displays)
|
||||
}
|
||||
|
||||
return formatter.Format(result)
|
||||
},
|
||||
}
|
||||
|
||||
addDeployFlags(cmd)
|
||||
return cmd
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package deployment
|
||||
|
||||
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"
|
||||
)
|
||||
|
||||
// ResultDisplay represents a deploy result for table display
|
||||
type ResultDisplay struct {
|
||||
Message string `json:"message"`
|
||||
DeploymentUUID string `json:"deployment_uuid"`
|
||||
}
|
||||
|
||||
// NewUUIDCommand deploys a resource by UUID
|
||||
func NewUUIDCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "uuid <uuid>",
|
||||
Short: "Deploy by uuid",
|
||||
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)
|
||||
}
|
||||
if err := validateDeployFlags(ctx, cmd, client); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
deploySvc := service.NewDeploymentService(client)
|
||||
result, err := deploySvc.Deploy(ctx, getDeployRequest(cmd, uuid))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to deploy resource: %w", err)
|
||||
}
|
||||
|
||||
format, _ := cmd.Flags().GetString("format")
|
||||
formatter, err := output.NewFormatter(format, output.Options{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// For table format, convert deployment info array to display format
|
||||
if format == output.FormatTable {
|
||||
displays := make([]ResultDisplay, len(result.Deployments))
|
||||
for i, dep := range result.Deployments {
|
||||
displays[i] = ResultDisplay{
|
||||
Message: dep.Message,
|
||||
DeploymentUUID: dep.DeploymentUUID,
|
||||
}
|
||||
}
|
||||
return formatter.Format(displays)
|
||||
}
|
||||
|
||||
return formatter.Format(result)
|
||||
},
|
||||
}
|
||||
|
||||
addDeployFlags(cmd)
|
||||
return cmd
|
||||
}
|
||||
+627
@@ -0,0 +1,627 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/cobra/doc"
|
||||
"github.com/spf13/pflag"
|
||||
)
|
||||
|
||||
var docsCmd = &cobra.Command{
|
||||
Use: "docs",
|
||||
Short: "Generate documentation",
|
||||
Hidden: true,
|
||||
}
|
||||
|
||||
var manCmd = &cobra.Command{
|
||||
Use: "man",
|
||||
Short: "Generate man pages",
|
||||
Long: `Generate man pages for all Coolify CLI commands.
|
||||
|
||||
The man pages will be written to the specified directory (default: ./man).`,
|
||||
Example: ` coolify docs man
|
||||
coolify docs man --output-dir=/usr/local/share/man/man1`,
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
outputDir, _ := cmd.Flags().GetString("output-dir")
|
||||
|
||||
// Create output directory if it doesn't exist
|
||||
if err := os.MkdirAll(outputDir, 0750); err != nil {
|
||||
return fmt.Errorf("failed to create output directory: %w", err)
|
||||
}
|
||||
|
||||
// Generate man pages
|
||||
header := &doc.GenManHeader{
|
||||
Title: "COOLIFY",
|
||||
Section: "1",
|
||||
Source: "Coolify CLI",
|
||||
}
|
||||
|
||||
if err := doc.GenManTree(rootCmd, header, outputDir); err != nil {
|
||||
return fmt.Errorf("failed to generate man pages: %w", err)
|
||||
}
|
||||
|
||||
absPath, _ := filepath.Abs(outputDir)
|
||||
fmt.Printf("Man pages generated successfully in: %s\n", absPath)
|
||||
fmt.Println("\nTo install the man pages system-wide:")
|
||||
fmt.Println(" sudo cp man/*.1 /usr/local/share/man/man1/")
|
||||
fmt.Println(" sudo mandb")
|
||||
fmt.Println("\nTo view a man page:")
|
||||
fmt.Println(" man coolify")
|
||||
fmt.Println(" man coolify-servers")
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var markdownCmd = &cobra.Command{
|
||||
Use: "markdown",
|
||||
Aliases: []string{"md"},
|
||||
|
||||
Short: "Generate markdown documentation",
|
||||
Long: `Generate markdown documentation for all Coolify CLI commands.
|
||||
|
||||
The markdown files will be written to the specified directory (default: ./docs).`,
|
||||
Example: ` coolify docs markdown
|
||||
coolify docs markdown --output-dir=./documentation`,
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
outputDir, _ := cmd.Flags().GetString("output-dir")
|
||||
|
||||
// Create output directory if it doesn't exist
|
||||
if err := os.MkdirAll(outputDir, 0750); err != nil {
|
||||
return fmt.Errorf("failed to create output directory: %w", err)
|
||||
}
|
||||
|
||||
// Generate markdown docs
|
||||
if err := doc.GenMarkdownTree(rootCmd, outputDir); err != nil {
|
||||
return fmt.Errorf("failed to generate markdown docs: %w", err)
|
||||
}
|
||||
|
||||
absPath, _ := filepath.Abs(outputDir)
|
||||
fmt.Printf("Markdown documentation generated successfully in: %s\n", absPath)
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var llmsCmd = &cobra.Command{
|
||||
Use: "llms",
|
||||
Short: "Generate llms.txt and llms-full.txt for AI agents",
|
||||
Long: `Generate AI-friendly documentation files for the Coolify CLI.
|
||||
|
||||
This creates a concise llms.txt quick reference plus a complete llms-full.txt command catalog.
|
||||
The output files will be written to the specified paths (defaults: ./llms.txt and ./llms-full.txt).`,
|
||||
Example: ` coolify docs llms
|
||||
coolify docs llms --output=./llms.txt --full-output=./llms-full.txt`,
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
outputFile, _ := cmd.Flags().GetString("output")
|
||||
fullOutputFile, _ := cmd.Flags().GetString("full-output")
|
||||
|
||||
return writeLLMsArtifacts(outputFile, fullOutputFile)
|
||||
},
|
||||
}
|
||||
|
||||
const llmsQuickTemplate = `# Coolify CLI - llms.txt
|
||||
|
||||
> Quick AI/LLM instructions for the Coolify CLI.
|
||||
> Source: https://github.com/coollabsio/coolify-cli
|
||||
> API Spec: https://github.com/coollabsio/coolify/blob/v4.x/openapi.json
|
||||
|
||||
## Operating Rules
|
||||
|
||||
- Prefer ` + "`--format json`" + ` for automation and parsing.
|
||||
- Use Coolify UUIDs for resources; do not use internal numeric IDs.
|
||||
- Team commands are the exception: they use numeric team IDs.
|
||||
- Authenticate with a saved context when possible; use ` + "`--token`" + ` only for overrides.
|
||||
- Use ` + "`llms-full.txt`" + ` for the exhaustive command/flag catalog.
|
||||
|
||||
## Installation
|
||||
|
||||
` + "```bash" + `
|
||||
# Linux/macOS (recommended)
|
||||
curl -fsSL https://raw.githubusercontent.com/coollabsio/coolify-cli/main/scripts/install.sh | bash
|
||||
|
||||
# Homebrew (macOS/Linux)
|
||||
brew install coollabsio/coolify-cli/coolify-cli
|
||||
|
||||
# Windows (PowerShell)
|
||||
irm https://raw.githubusercontent.com/coollabsio/coolify-cli/main/scripts/install.ps1 | iex
|
||||
|
||||
# Go install
|
||||
go install github.com/coollabsio/coolify-cli/coolify@latest
|
||||
` + "```" + `
|
||||
|
||||
## Authentication
|
||||
|
||||
1. Get an API token from your Coolify dashboard at ` + "`/security/api-tokens`" + `
|
||||
2. For Coolify Cloud: ` + "`coolify context set-token cloud <token>`" + `
|
||||
3. For self-hosted: ` + "`coolify context add -d <context_name> <url> <token>`" + `
|
||||
4. Switch contexts with ` + "`coolify context use <context_name>`" + `
|
||||
|
||||
## Configuration
|
||||
|
||||
Config file location:
|
||||
- Linux/macOS: ` + "`~/.config/coolify/config.json`" + `
|
||||
- Windows: ` + "`%%APPDATA%%\\coolify\\config.json`" + `
|
||||
|
||||
Supports multiple contexts (instances) with ` + "`coolify context`" + ` commands.
|
||||
|
||||
## Output Formats
|
||||
|
||||
All commands support ` + "`--format`" + ` flag:
|
||||
- ` + "`table`" + ` (default) - human-readable tabular output
|
||||
- ` + "`json`" + ` - compact JSON for scripting
|
||||
- ` + "`pretty`" + ` - indented JSON for debugging
|
||||
|
||||
## Global Flags
|
||||
|
||||
- ` + "`--context <name>`" + ` - use a specific saved context
|
||||
- ` + "`--token <token>`" + ` - override token from config
|
||||
- ` + "`--format table|json|pretty`" + ` - choose output format
|
||||
- ` + "`--show-sensitive`" + ` - reveal sensitive values
|
||||
- ` + "`--debug`" + ` - enable debug output
|
||||
|
||||
## Common Workflows
|
||||
|
||||
### Contexts
|
||||
|
||||
` + "```bash" + `
|
||||
coolify context list
|
||||
coolify context verify
|
||||
coolify context version
|
||||
coolify context use prod
|
||||
` + "```" + `
|
||||
|
||||
### Inventory
|
||||
|
||||
` + "```bash" + `
|
||||
coolify server list
|
||||
coolify project list
|
||||
coolify resource list
|
||||
coolify app list
|
||||
coolify service list
|
||||
coolify database list
|
||||
` + "```" + `
|
||||
|
||||
### Applications
|
||||
|
||||
` + "```bash" + `
|
||||
coolify app get <uuid>
|
||||
coolify app start <uuid>
|
||||
coolify app stop <uuid>
|
||||
coolify app restart <uuid>
|
||||
coolify app logs <uuid> --follow
|
||||
coolify app deployments list <app-uuid>
|
||||
coolify app deployments logs <app-uuid> --follow
|
||||
` + "```" + `
|
||||
|
||||
### Environment Variables
|
||||
|
||||
` + "```bash" + `
|
||||
coolify app env list <app-uuid>
|
||||
coolify app env create <app-uuid> --key API_KEY --value secret123
|
||||
coolify app env update <app-uuid> <env-uuid-or-key> --value new-secret
|
||||
coolify app env sync <app-uuid> --file .env.production --build-time --preview
|
||||
` + "```" + `
|
||||
|
||||
### Deployments
|
||||
|
||||
` + "```bash" + `
|
||||
coolify deploy list
|
||||
coolify deploy name my-application
|
||||
coolify deploy batch api,worker,frontend --force
|
||||
coolify deploy cancel <deployment-uuid>
|
||||
` + "```" + `
|
||||
|
||||
### Databases and Services
|
||||
|
||||
` + "```bash" + `
|
||||
coolify database get <uuid>
|
||||
coolify database create postgresql --server-uuid <uuid> --project-uuid <uuid> --environment-name production
|
||||
coolify database backup list <database-uuid>
|
||||
coolify service get <uuid>
|
||||
coolify service create <type> --project-uuid <uuid> --server-uuid <uuid> --instant-deploy
|
||||
` + "```" + `
|
||||
|
||||
## Common Aliases
|
||||
|
||||
- ` + "`coolify app`" + ` | ` + "`coolify apps`" + ` | ` + "`coolify application`" + ` | ` + "`coolify applications`" + `
|
||||
- ` + "`coolify service`" + ` | ` + "`coolify services`" + ` | ` + "`coolify svc`" + `
|
||||
- ` + "`coolify database`" + ` | ` + "`coolify databases`" + ` | ` + "`coolify db`" + ` | ` + "`coolify dbs`" + `
|
||||
- ` + "`coolify teams`" + ` | ` + "`coolify team`" + `
|
||||
|
||||
## Full Reference
|
||||
|
||||
- Full command and parameter catalog: %s
|
||||
- Regenerate docs: ` + "`go run ./coolify docs llms`" + `
|
||||
`
|
||||
|
||||
const llmsFullIntro = `# Coolify CLI - llms-full.txt
|
||||
|
||||
> Full AI/LLM command catalog for the Coolify CLI.
|
||||
> Manage Coolify instances (cloud and self-hosted), servers, projects, applications, databases, services, deployments, domains, and private keys.
|
||||
> Source: https://github.com/coollabsio/coolify-cli
|
||||
> API Spec: https://github.com/coollabsio/coolify/blob/v4.x/openapi.json
|
||||
|
||||
## Companion Files
|
||||
|
||||
- Quick instructions: %s
|
||||
- Regenerate docs: ` + "`go run ./coolify docs llms`" + `
|
||||
|
||||
## Installation
|
||||
|
||||
` + "```bash" + `
|
||||
# Linux/macOS (recommended)
|
||||
curl -fsSL https://raw.githubusercontent.com/coollabsio/coolify-cli/main/scripts/install.sh | bash
|
||||
|
||||
# Homebrew (macOS/Linux)
|
||||
brew install coollabsio/coolify-cli/coolify-cli
|
||||
|
||||
# Windows (PowerShell)
|
||||
irm https://raw.githubusercontent.com/coollabsio/coolify-cli/main/scripts/install.ps1 | iex
|
||||
|
||||
# Go install
|
||||
go install github.com/coollabsio/coolify-cli/coolify@latest
|
||||
` + "```" + `
|
||||
|
||||
## Authentication
|
||||
|
||||
1. Get an API token from your Coolify dashboard at ` + "`/security/api-tokens`" + `
|
||||
2. For Coolify Cloud: ` + "`coolify context set-token cloud <token>`" + `
|
||||
3. For self-hosted: ` + "`coolify context add -d <context_name> <url> <token>`" + `
|
||||
|
||||
## Configuration
|
||||
|
||||
Config file location:
|
||||
- Linux/macOS: ` + "`~/.config/coolify/config.json`" + `
|
||||
- Windows: ` + "`%%APPDATA%%\\coolify\\config.json`" + `
|
||||
|
||||
Supports multiple contexts (instances) with ` + "`coolify context`" + ` commands.
|
||||
|
||||
## Output Formats
|
||||
|
||||
All commands support ` + "`--format`" + ` flag:
|
||||
- ` + "`table`" + ` (default) - human-readable tabular output
|
||||
- ` + "`json`" + ` - compact JSON for scripting
|
||||
- ` + "`pretty`" + ` - indented JSON for debugging
|
||||
`
|
||||
|
||||
const llmsFullBody = `
|
||||
|
||||
## Supported Database Types
|
||||
|
||||
When using ` + "`coolify database create <type>`" + `:
|
||||
- ` + "`postgresql`" + `
|
||||
- ` + "`mysql`" + `
|
||||
- ` + "`mariadb`" + `
|
||||
- ` + "`mongodb`" + `
|
||||
- ` + "`redis`" + `
|
||||
- ` + "`keydb`" + `
|
||||
- ` + "`clickhouse`" + `
|
||||
- ` + "`dragonfly`" + `
|
||||
|
||||
## Usage Examples
|
||||
|
||||
` + "```bash" + `
|
||||
# Multi-context workflow
|
||||
coolify context add prod https://prod.coolify.io <token>
|
||||
coolify context add staging https://staging.coolify.io <token>
|
||||
coolify context use prod
|
||||
coolify --context=staging server list
|
||||
|
||||
# Application lifecycle
|
||||
coolify app list
|
||||
coolify app get <uuid>
|
||||
coolify app start <uuid>
|
||||
coolify app stop <uuid>
|
||||
coolify app restart <uuid>
|
||||
coolify app logs <uuid> --follow
|
||||
|
||||
# Environment variable management
|
||||
coolify app env list <uuid>
|
||||
coolify app env create <uuid> --key API_KEY --value secret123
|
||||
coolify app env sync <uuid> --file .env.production --build-time --preview
|
||||
|
||||
# Deploy workflows
|
||||
coolify deploy name my-application
|
||||
coolify deploy batch api,worker,frontend --force
|
||||
coolify deploy list
|
||||
coolify deploy cancel <uuid>
|
||||
|
||||
# Database backup
|
||||
coolify database backup create <db-uuid> --frequency "0 2 * * *" --enabled --save-s3
|
||||
coolify database backup trigger <db-uuid> <backup-uuid>
|
||||
|
||||
# Application creation
|
||||
coolify app create public --project-uuid <uuid> --server-uuid <uuid> --git-repository https://github.com/user/repo --git-branch main --build-pack nixpacks --ports-exposes 3000
|
||||
coolify app create dockerfile --project-uuid <uuid> --server-uuid <uuid> --dockerfile "FROM node:18\nCOPY . .\nRUN npm install\nCMD [\"node\", \"index.js\"]"
|
||||
coolify app create dockerimage --project-uuid <uuid> --server-uuid <uuid> --docker-registry-image-name nginx --ports-exposes 80
|
||||
|
||||
# Service creation (one-click services)
|
||||
coolify service create <type> --project-uuid <uuid> --server-uuid <uuid> --instant-deploy
|
||||
coolify service create --list-types # list all available service types
|
||||
|
||||
# Storage management
|
||||
coolify app storage create <app-uuid> --type persistent --mount-path /data --name my-volume
|
||||
coolify app storage create <app-uuid> --type file --mount-path /app/config.yml --content "key: value"
|
||||
|
||||
# GitHub App integration
|
||||
coolify github list
|
||||
coolify github repos <app-uuid>
|
||||
coolify github branches <app-uuid> owner/repo
|
||||
|
||||
# Team management
|
||||
coolify team list
|
||||
coolify team current
|
||||
coolify team members list
|
||||
` + "```" + `
|
||||
|
||||
## API Notes
|
||||
|
||||
- All resource identifiers use UUIDs (not internal database IDs)
|
||||
- API base path: ` + "`/api/v1/`" + `
|
||||
- Authentication: Bearer token via ` + "`--token`" + ` flag or context configuration
|
||||
- ` + "`app env sync`" + ` behavior: updates existing variables, creates missing ones, does NOT delete variables not in the file
|
||||
- ` + "`app start`" + ` aliases to ` + "`app deploy`" + ` and also accepts ` + "`--force`" + ` and ` + "`--instant-deploy`" + ` flags
|
||||
- Deployment logs support ` + "`--follow`" + ` for real-time streaming and ` + "`--debuglogs`" + ` for internal operations
|
||||
- ` + "`app logs`" + ` defaults to 100 lines; ` + "`app deployments logs`" + ` defaults to 0 (all lines)
|
||||
- Short flag ` + "`-n`" + ` can be used instead of ` + "`--lines`" + ` for log commands
|
||||
- ` + "`completion`" + ` command supports shells: ` + "`bash`" + `, ` + "`zsh`" + `, ` + "`fish`" + `, ` + "`powershell`" + `
|
||||
- Resource statuses: ` + "`running`" + `, ` + "`stopped`" + `, ` + "`error`" + `
|
||||
- Teams use numeric IDs (not UUIDs) - this is the only resource that uses IDs
|
||||
- Fields marked ` + "`sensitive:\"true\"`" + ` (tokens, passwords, IPs, emails) are hidden by default; use ` + "`--show-sensitive`" + ` to reveal
|
||||
|
||||
---
|
||||
|
||||
## Command Reference
|
||||
|
||||
`
|
||||
|
||||
func buildQuickLLMSText(fullReferencePath string) string {
|
||||
return fmt.Sprintf(llmsQuickTemplate, fullReferencePath)
|
||||
}
|
||||
|
||||
func buildFullLLMSText(quickReferencePath string) string {
|
||||
var sb strings.Builder
|
||||
fmt.Fprintf(&sb, llmsFullIntro, quickReferencePath)
|
||||
writeLLMsAliases(&sb, rootCmd, "coolify")
|
||||
sb.WriteString(llmsFullBody)
|
||||
writeLLMsCommand(&sb, rootCmd, "coolify")
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func writeLLMsArtifacts(outputFile, fullOutputFile string) error {
|
||||
if filepath.Clean(outputFile) == filepath.Clean(fullOutputFile) {
|
||||
return fmt.Errorf("output and full-output must be different files")
|
||||
}
|
||||
|
||||
if err := ensureParentDir(outputFile); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ensureParentDir(fullOutputFile); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
quickReferencePath := llmsReferencePath(fullOutputFile, outputFile)
|
||||
fullReferencePath := llmsReferencePath(outputFile, fullOutputFile)
|
||||
|
||||
if err := os.WriteFile(outputFile, []byte(buildQuickLLMSText(fullReferencePath)), 0600); err != nil {
|
||||
return fmt.Errorf("failed to write llms.txt: %w", err)
|
||||
}
|
||||
if err := os.WriteFile(fullOutputFile, []byte(buildFullLLMSText(quickReferencePath)), 0600); err != nil {
|
||||
return fmt.Errorf("failed to write llms-full.txt: %w", err)
|
||||
}
|
||||
|
||||
absQuickPath, _ := filepath.Abs(outputFile)
|
||||
absFullPath, _ := filepath.Abs(fullOutputFile)
|
||||
fmt.Printf("llms.txt generated successfully: %s\n", absQuickPath)
|
||||
fmt.Printf("llms-full.txt generated successfully: %s\n", absFullPath)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func ensureParentDir(path string) error {
|
||||
parentDir := filepath.Dir(path)
|
||||
if err := os.MkdirAll(parentDir, 0750); err != nil {
|
||||
return fmt.Errorf("failed to create output directory: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func llmsReferencePath(fromFile, toFile string) string {
|
||||
referencePath, err := filepath.Rel(filepath.Dir(fromFile), toFile)
|
||||
if err != nil {
|
||||
return filepath.ToSlash(toFile)
|
||||
}
|
||||
referencePath = filepath.ToSlash(referencePath)
|
||||
if strings.HasPrefix(referencePath, ".") || strings.HasPrefix(referencePath, "/") {
|
||||
return referencePath
|
||||
}
|
||||
return "./" + referencePath
|
||||
}
|
||||
|
||||
// writeLLMsAliases writes aliases derived from the Cobra command tree.
|
||||
func writeLLMsAliases(sb *strings.Builder, cmd *cobra.Command, parentPath string) {
|
||||
aliases := collectLLMsAliases(cmd, parentPath)
|
||||
if len(aliases) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
sb.WriteString("\n## Command Aliases\n\n")
|
||||
sb.WriteString("Aliases are derived from the CLI command tree:\n")
|
||||
for _, aliasLine := range aliases {
|
||||
fmt.Fprintf(sb, "- %s\n", aliasLine)
|
||||
}
|
||||
}
|
||||
|
||||
func collectLLMsAliases(cmd *cobra.Command, parentPath string) []string {
|
||||
var aliases []string
|
||||
if cmd.Name() != "docs" && cmd.Name() != "help" {
|
||||
if len(cmd.Aliases) > 0 {
|
||||
aliasNames := append([]string{cmd.Name()}, cmd.Aliases...)
|
||||
for i := range aliasNames {
|
||||
aliasNames[i] = fmt.Sprintf("`%s`", commandPathPrefix(parentPath, cmd)+aliasNames[i])
|
||||
}
|
||||
aliases = append(aliases, strings.Join(aliasNames, " | "))
|
||||
}
|
||||
}
|
||||
|
||||
for _, child := range cmd.Commands() {
|
||||
if child.Hidden || child.Name() == "help" {
|
||||
continue
|
||||
}
|
||||
aliases = append(aliases, collectLLMsAliases(child, llmsCommandName(parentPath, cmd))...)
|
||||
}
|
||||
|
||||
slices.Sort(aliases)
|
||||
return slices.Compact(aliases)
|
||||
}
|
||||
|
||||
func llmsCommandName(parentPath string, cmd *cobra.Command) string {
|
||||
if !cmd.HasParent() {
|
||||
return parentPath
|
||||
}
|
||||
|
||||
parts := strings.Fields(cmd.Use)
|
||||
commandPath := parentPath + " " + parts[0]
|
||||
if len(parts) > 1 {
|
||||
commandPath += " " + strings.Join(parts[1:], " ")
|
||||
}
|
||||
return commandPath
|
||||
}
|
||||
|
||||
func commandPathPrefix(parentPath string, cmd *cobra.Command) string {
|
||||
if cmd.HasParent() {
|
||||
return parentPath + " "
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// writeLLMsCommand recursively writes command documentation in llms.txt format.
|
||||
func writeLLMsCommand(sb *strings.Builder, cmd *cobra.Command, parentPath string) {
|
||||
// Build the full command path including args from Use field
|
||||
commandPath := llmsCommandName(parentPath, cmd)
|
||||
|
||||
// Skip the docs command itself and help command
|
||||
if cmd.Name() == "docs" || cmd.Name() == "help" {
|
||||
return
|
||||
}
|
||||
|
||||
// Determine if this command should be written
|
||||
isRoot := !cmd.HasParent()
|
||||
isRunnable := cmd.RunE != nil || cmd.Run != nil
|
||||
hasVisibleChildren := false
|
||||
for _, child := range cmd.Commands() {
|
||||
if !child.Hidden && child.Name() != "help" {
|
||||
hasVisibleChildren = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Write the root command, runnable commands, and leaf commands (no children)
|
||||
if isRoot || isRunnable || !hasVisibleChildren {
|
||||
// Get description - prefer Long if it's a single clean sentence, otherwise use Short
|
||||
description := cmd.Short
|
||||
if cmd.Long != "" {
|
||||
longLines := strings.Split(strings.TrimSpace(cmd.Long), "\n")
|
||||
if len(longLines) == 1 && len(longLines[0]) < 200 {
|
||||
description = longLines[0]
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Fprintf(sb, "Command: %s\n", commandPath)
|
||||
fmt.Fprintf(sb, "Description: %s\n", description)
|
||||
|
||||
// For root command, show persistent flags; for others, show local flags
|
||||
var flags []*pflag.Flag
|
||||
if isRoot {
|
||||
cmd.PersistentFlags().VisitAll(func(f *pflag.Flag) {
|
||||
if f.Name == "help" {
|
||||
return
|
||||
}
|
||||
flags = append(flags, f)
|
||||
})
|
||||
} else {
|
||||
cmd.LocalFlags().VisitAll(func(f *pflag.Flag) {
|
||||
if f.Name == "help" {
|
||||
return
|
||||
}
|
||||
flags = append(flags, f)
|
||||
})
|
||||
}
|
||||
|
||||
if len(flags) == 0 {
|
||||
sb.WriteString("Parameters: (None)\n")
|
||||
} else {
|
||||
sb.WriteString("Parameters:\n")
|
||||
for _, f := range flags {
|
||||
flagType := f.Value.Type()
|
||||
// Normalize type names
|
||||
switch flagType {
|
||||
case "int", "int32", "int64":
|
||||
flagType = "integer"
|
||||
case "bool":
|
||||
flagType = "boolean"
|
||||
}
|
||||
|
||||
// Check if the flag is marked as required via cobra annotation
|
||||
// or via "(required)" in the usage string
|
||||
required := isFlagRequired(f)
|
||||
|
||||
if f.Shorthand != "" {
|
||||
fmt.Fprintf(sb, " - name: --%s (-%s)\n", f.Name, f.Shorthand)
|
||||
} else {
|
||||
fmt.Fprintf(sb, " - name: --%s\n", f.Name)
|
||||
}
|
||||
fmt.Fprintf(sb, " type: %s\n", flagType)
|
||||
fmt.Fprintf(sb, " description: %s\n", f.Usage)
|
||||
fmt.Fprintf(sb, " required: %t\n", required)
|
||||
if f.DefValue != "" && f.DefValue != "[]" {
|
||||
fmt.Fprintf(sb, " default: %s\n", f.DefValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
// Recurse into subcommands
|
||||
for _, child := range cmd.Commands() {
|
||||
if child.Hidden || child.Name() == "help" {
|
||||
continue
|
||||
}
|
||||
childPath := parentPath
|
||||
if cmd.HasParent() {
|
||||
childPath = llmsCommandName(parentPath, cmd)
|
||||
}
|
||||
writeLLMsCommand(sb, child, childPath)
|
||||
}
|
||||
}
|
||||
|
||||
// isFlagRequired checks if a flag is required by looking at cobra annotations
|
||||
// and the "(required)" convention in usage strings.
|
||||
func isFlagRequired(f *pflag.Flag) bool {
|
||||
// Check cobra's MarkFlagRequired annotation
|
||||
if ann, ok := f.Annotations[cobra.BashCompOneRequiredFlag]; ok && len(ann) > 0 && ann[0] == "true" {
|
||||
return true
|
||||
}
|
||||
// Check for "(required)" in usage string (convention used in this codebase)
|
||||
return strings.Contains(strings.ToLower(f.Usage), "(required)")
|
||||
}
|
||||
|
||||
func NewDocsCommand() *cobra.Command {
|
||||
docsCmd.AddCommand(manCmd)
|
||||
docsCmd.AddCommand(markdownCmd)
|
||||
docsCmd.AddCommand(llmsCmd)
|
||||
|
||||
manCmd.Flags().StringP("output-dir", "o", "./man", "Output directory for man pages")
|
||||
markdownCmd.Flags().StringP("output-dir", "o", "./docs", "Output directory for markdown files")
|
||||
llmsCmd.Flags().StringP("output", "o", "./llms.txt", "Output file path")
|
||||
llmsCmd.Flags().String("full-output", "./llms-full.txt", "Full output file path")
|
||||
|
||||
return docsCmd
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestWriteLLMsCommandIncludesShorthandAndDefaults(t *testing.T) {
|
||||
root := &cobra.Command{Use: "coolify"}
|
||||
child := &cobra.Command{
|
||||
Use: "logs <uuid>",
|
||||
Short: "Show logs",
|
||||
Run: func(_ *cobra.Command, _ []string) {},
|
||||
}
|
||||
child.Flags().IntP("lines", "n", 0, "Number of log lines to display (0 = all)")
|
||||
child.Flags().Bool("verbose", false, "Verbose output")
|
||||
child.Flags().Bool("enabled", true, "Enabled by default")
|
||||
root.AddCommand(child)
|
||||
|
||||
var sb strings.Builder
|
||||
writeLLMsCommand(&sb, child, "coolify")
|
||||
got := sb.String()
|
||||
|
||||
for _, want := range []string{
|
||||
"Command: coolify logs <uuid>",
|
||||
" - name: --lines (-n)",
|
||||
" default: 0",
|
||||
" - name: --verbose",
|
||||
" default: false",
|
||||
" - name: --enabled",
|
||||
" default: true",
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("expected output to contain %q\nfull output:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteLLMsAliasesUsesCommandTree(t *testing.T) {
|
||||
root := &cobra.Command{Use: "coolify"}
|
||||
teams := &cobra.Command{Use: "teams", Aliases: []string{"team"}}
|
||||
members := &cobra.Command{Use: "members", Aliases: []string{"member"}}
|
||||
start := &cobra.Command{
|
||||
Use: "start <uuid>",
|
||||
Aliases: []string{"deploy"},
|
||||
}
|
||||
|
||||
root.AddCommand(teams)
|
||||
root.AddCommand(start)
|
||||
teams.AddCommand(members)
|
||||
|
||||
var sb strings.Builder
|
||||
writeLLMsAliases(&sb, root, "coolify")
|
||||
got := sb.String()
|
||||
|
||||
for _, want := range []string{
|
||||
"## Command Aliases",
|
||||
"`coolify start` | `coolify deploy`",
|
||||
"`coolify teams` | `coolify team`",
|
||||
"`coolify teams members` | `coolify teams member`",
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("expected alias output to contain %q\nfull output:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildQuickLLMSTextIncludesCoreGuidance(t *testing.T) {
|
||||
got := buildQuickLLMSText("./llms-full.txt")
|
||||
|
||||
for _, want := range []string{
|
||||
"# Coolify CLI - llms.txt",
|
||||
"Prefer `--format json` for automation and parsing.",
|
||||
"coolify context verify",
|
||||
"coolify app logs <uuid> --follow",
|
||||
"Full command and parameter catalog: ./llms-full.txt",
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("expected quick llms output to contain %q\nfull output:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteLLMsArtifactsWritesQuickAndFullFiles(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
quickPath := filepath.Join(tempDir, "llms.txt")
|
||||
fullPath := filepath.Join(tempDir, "nested", "llms-full.txt")
|
||||
|
||||
if err := writeLLMsArtifacts(quickPath, fullPath); err != nil {
|
||||
t.Fatalf("writeLLMsArtifacts() error = %v", err)
|
||||
}
|
||||
|
||||
quickContent, err := os.ReadFile(quickPath)
|
||||
if err != nil {
|
||||
t.Fatalf("failed reading quick file: %v", err)
|
||||
}
|
||||
fullContent, err := os.ReadFile(fullPath)
|
||||
if err != nil {
|
||||
t.Fatalf("failed reading full file: %v", err)
|
||||
}
|
||||
|
||||
for _, want := range []struct {
|
||||
content string
|
||||
substr string
|
||||
}{
|
||||
{string(quickContent), "./nested/llms-full.txt"},
|
||||
{string(fullContent), "../llms.txt"},
|
||||
{string(fullContent), "## Command Reference"},
|
||||
} {
|
||||
if !strings.Contains(want.content, want.substr) {
|
||||
t.Fatalf("expected generated content to contain %q", want.substr)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type Domain struct {
|
||||
IP string `json:"ip"`
|
||||
Domains []string `json:"domains"`
|
||||
}
|
||||
|
||||
var domainsCmd = &cobra.Command{
|
||||
Use: "domains",
|
||||
Short: "Domain related commands",
|
||||
}
|
||||
|
||||
var listDomainsCmd = &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List all domains",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
CheckDefaultThings(nil)
|
||||
data, err := Fetch("domains")
|
||||
if err != nil {
|
||||
log.Println(err)
|
||||
return
|
||||
}
|
||||
if PrettyMode {
|
||||
var prettyJSON bytes.Buffer
|
||||
err := json.Indent(&prettyJSON, []byte(data), "", "\t")
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
fmt.Println(string(prettyJSON.String()))
|
||||
return
|
||||
}
|
||||
if JsonMode {
|
||||
fmt.Println(data)
|
||||
return
|
||||
}
|
||||
var jsondata []Domain
|
||||
err = json.Unmarshal([]byte(data), &jsondata)
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Fprintln(w, "IP Address\tDomains")
|
||||
for _, resource := range jsondata {
|
||||
for _, domain := range resource.Domains {
|
||||
fmt.Fprintf(w, "%s\t%s\n", resource.IP, domain)
|
||||
}
|
||||
|
||||
}
|
||||
w.Flush()
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
// rootCmd.AddCommand(domainsCmd)
|
||||
// domainsCmd.AddCommand(listDomainsCmd)
|
||||
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
package firewall
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/coollabsio/coolify-cli/cmd/common"
|
||||
ifw "github.com/coollabsio/coolify-cli/internal/firewall"
|
||||
"github.com/coollabsio/coolify-cli/internal/models"
|
||||
"github.com/coollabsio/coolify-cli/internal/output"
|
||||
"github.com/coollabsio/coolify-cli/internal/ssh"
|
||||
)
|
||||
|
||||
// allowRevokeFlags are the per-subcommand flags for `allow` / `revoke`.
|
||||
type allowRevokeFlags struct {
|
||||
From string
|
||||
To string
|
||||
Port int
|
||||
Proto string
|
||||
Bidirectional bool
|
||||
}
|
||||
|
||||
// newAllowCommand builds `coolify firewall allow`.
|
||||
func newAllowCommand(parent *Flags) *cobra.Command {
|
||||
local := &allowRevokeFlags{}
|
||||
cmd := &cobra.Command{
|
||||
Use: "allow",
|
||||
Short: "Add an allow rule (from container → to container:port)",
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
return runAllowRevoke(cmd.Context(), cmd, parent, local, false)
|
||||
},
|
||||
}
|
||||
bindAllowRevokeFlags(cmd, local)
|
||||
return cmd
|
||||
}
|
||||
|
||||
// newRevokeCommand builds `coolify firewall revoke`.
|
||||
func newRevokeCommand(parent *Flags) *cobra.Command {
|
||||
local := &allowRevokeFlags{}
|
||||
cmd := &cobra.Command{
|
||||
Use: "revoke",
|
||||
Short: "Remove an allow rule",
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
return runAllowRevoke(cmd.Context(), cmd, parent, local, true)
|
||||
},
|
||||
}
|
||||
bindAllowRevokeFlags(cmd, local)
|
||||
return cmd
|
||||
}
|
||||
|
||||
func bindAllowRevokeFlags(cmd *cobra.Command, f *allowRevokeFlags) {
|
||||
pf := cmd.Flags()
|
||||
pf.StringVar(&f.From, "from", "",
|
||||
"Source container (name, short-id, raw IP, or host:name) — required")
|
||||
pf.StringVar(&f.To, "to", "",
|
||||
"Destination container (name, short-id, raw IP, or host:name) — required")
|
||||
pf.IntVar(&f.Port, "port", 0,
|
||||
"Destination port (required unless --proto is empty)")
|
||||
pf.StringVar(&f.Proto, "proto", "tcp",
|
||||
"Protocol (tcp, udp, or empty for any)")
|
||||
pf.BoolVar(&f.Bidirectional, "bidirectional", false,
|
||||
"Also install the reverse rule on the source host (default: one-way; conntrack handles replies)")
|
||||
}
|
||||
|
||||
func validateAllowRevokeFlags(f *allowRevokeFlags) error {
|
||||
if f.From == "" {
|
||||
return fmt.Errorf("--from is required")
|
||||
}
|
||||
if f.To == "" {
|
||||
return fmt.Errorf("--to is required")
|
||||
}
|
||||
if f.Proto != "" && f.Proto != "tcp" && f.Proto != "udp" {
|
||||
return fmt.Errorf("--proto must be tcp, udp, or empty (got %q)", f.Proto)
|
||||
}
|
||||
if f.Proto != "" && f.Port <= 0 {
|
||||
return fmt.Errorf("--port is required when --proto is set")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func runAllowRevoke(
|
||||
ctx context.Context,
|
||||
cmd *cobra.Command,
|
||||
parent *Flags,
|
||||
local *allowRevokeFlags,
|
||||
revoke bool,
|
||||
) error {
|
||||
if err := parent.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := common.ValidateNamespace(parent.Namespace); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateAllowRevokeFlags(local); err != nil {
|
||||
return err
|
||||
}
|
||||
runner, err := parent.BuildSSHClient()
|
||||
if err != nil {
|
||||
return fmt.Errorf("SSH client: %w", err)
|
||||
}
|
||||
return emitAllowRevoke(ctx, cmd, parent, local, runner, revoke)
|
||||
}
|
||||
|
||||
// emitAllowRevoke is the core path: discover → resolve → build rule → apply.
|
||||
// Split from the cobra wrapper so tests inject a fake ssh.Runner.
|
||||
func emitAllowRevoke(
|
||||
ctx context.Context,
|
||||
cmd *cobra.Command,
|
||||
parent *Flags,
|
||||
local *allowRevokeFlags,
|
||||
runner ssh.Runner,
|
||||
revoke bool,
|
||||
) error {
|
||||
all, results := discoverAllViaPkg(ctx, runner, parent)
|
||||
for _, r := range results {
|
||||
if r.Err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Warning: discover %s: %v\n", r.Host, r.Err)
|
||||
}
|
||||
}
|
||||
|
||||
from, err := resolveEndpoint(local.From, all)
|
||||
if err != nil {
|
||||
return fmt.Errorf("--from: %w", err)
|
||||
}
|
||||
to, err := resolveEndpoint(local.To, all)
|
||||
if err != nil {
|
||||
return fmt.Errorf("--to: %w", err)
|
||||
}
|
||||
if from.IP == nil || to.IP == nil {
|
||||
return fmt.Errorf("failed to resolve endpoint IPs (from=%s to=%s)", local.From, local.To)
|
||||
}
|
||||
|
||||
// Determine destination host (rule owner). If `to` was resolved from a
|
||||
// raw IP with no container match, try to map it via discovery first.
|
||||
dstHost := to.Host
|
||||
if dstHost == "" {
|
||||
if h, ok := findHostForIP(to.IP, all); ok {
|
||||
dstHost = h
|
||||
}
|
||||
}
|
||||
if dstHost == "" {
|
||||
return fmt.Errorf("cannot determine destination host for IP %s — no container on the mesh owns it", to.IP)
|
||||
}
|
||||
|
||||
srcHost := from.Host
|
||||
if srcHost == "" {
|
||||
if h, ok := findHostForIP(from.IP, all); ok {
|
||||
srcHost = h
|
||||
}
|
||||
}
|
||||
|
||||
ns := parent.Namespace
|
||||
primary := ifw.AllowRule{
|
||||
Host: dstHost,
|
||||
Namespace: ns,
|
||||
Src: from.IP,
|
||||
Dst: to.IP,
|
||||
Proto: local.Proto,
|
||||
Port: local.Port,
|
||||
Comment: "cid:" + ifw.ComputeID(ns, from.IP, to.IP, local.Proto, local.Port),
|
||||
}
|
||||
rules := []ifw.AllowRule{primary}
|
||||
|
||||
if local.Bidirectional {
|
||||
if srcHost == "" {
|
||||
return fmt.Errorf("--bidirectional requires the source endpoint to belong to a mesh host")
|
||||
}
|
||||
reverse := ifw.AllowRule{
|
||||
Host: srcHost,
|
||||
Namespace: ns,
|
||||
Src: to.IP,
|
||||
Dst: from.IP,
|
||||
Proto: local.Proto,
|
||||
Port: local.Port,
|
||||
Comment: "cid:" + ifw.ComputeID(ns, to.IP, from.IP, local.Proto, local.Port),
|
||||
}
|
||||
rules = append(rules, reverse)
|
||||
}
|
||||
|
||||
action := "allow"
|
||||
past := "allowed"
|
||||
if revoke {
|
||||
action = "revoke"
|
||||
past = "revoked"
|
||||
}
|
||||
tokenFor := tokenResolver(ctx, runner, parent)
|
||||
for _, r := range rules {
|
||||
token, terr := tokenFor(r.Host)
|
||||
if terr != nil {
|
||||
return fmt.Errorf("%s on %s: %w", action, r.Host, terr)
|
||||
}
|
||||
var rerr error
|
||||
if revoke {
|
||||
// Revoke by id — coold is idempotent (204 even on unknown id).
|
||||
id := strings.TrimPrefix(r.Comment, "cid:")
|
||||
rerr = ifw.CooldRevoke(ctx, runner, r.Host, parent.SSHUser,
|
||||
parent.SSHPort, parent.CooldPort, parent.WGInterface, token, id)
|
||||
} else {
|
||||
rerr = ifw.CooldApply(ctx, runner, r.Host, parent.SSHUser,
|
||||
parent.SSHPort, parent.CooldPort, parent.WGInterface, token, r)
|
||||
}
|
||||
if rerr != nil {
|
||||
return fmt.Errorf("%s on %s: %w", action, r.Host, rerr)
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "%s on %s: %s → %s %s/%d\n",
|
||||
past, r.Host, ipOrAny(r.Src), ipOrAny(r.Dst),
|
||||
protoOrAny(r.Proto), r.Port)
|
||||
}
|
||||
|
||||
rows := make([]models.AllowRuleRow, 0, len(rules))
|
||||
for _, r := range rules {
|
||||
rows = append(rows, models.AllowRuleRow{
|
||||
Host: r.Host,
|
||||
Namespace: r.Namespace,
|
||||
ID: r.Comment,
|
||||
Src: r.Src.String(),
|
||||
Dst: r.Dst.String(),
|
||||
Proto: r.Proto,
|
||||
Port: r.Port,
|
||||
Comment: r.Comment,
|
||||
})
|
||||
}
|
||||
|
||||
format, _ := cmd.Root().PersistentFlags().GetString("format")
|
||||
if format == "" {
|
||||
format = output.FormatTable
|
||||
}
|
||||
formatter, err := output.NewFormatter(format, output.Options{Writer: os.Stdout})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if format == output.FormatJSON || format == output.FormatPretty {
|
||||
return formatter.Format(models.FirewallAllowOutput{Rules: rows})
|
||||
}
|
||||
return formatter.Format(rows)
|
||||
}
|
||||
|
||||
func ipOrAny(ip net.IP) string {
|
||||
if ip == nil {
|
||||
return "any"
|
||||
}
|
||||
return ip.String()
|
||||
}
|
||||
|
||||
func protoOrAny(p string) string {
|
||||
if p == "" {
|
||||
return "any"
|
||||
}
|
||||
return p
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
package firewall
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/coollabsio/coolify-cli/cmd/common"
|
||||
"github.com/coollabsio/coolify-cli/internal/ssh"
|
||||
)
|
||||
|
||||
func TestValidateAllowRevokeFlags(t *testing.T) {
|
||||
t.Run("missing from", func(t *testing.T) {
|
||||
err := validateAllowRevokeFlags(&allowRevokeFlags{To: "x", Port: 80, Proto: "tcp"})
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "--from")
|
||||
})
|
||||
t.Run("missing to", func(t *testing.T) {
|
||||
err := validateAllowRevokeFlags(&allowRevokeFlags{From: "x", Port: 80, Proto: "tcp"})
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "--to")
|
||||
})
|
||||
t.Run("missing port with proto", func(t *testing.T) {
|
||||
err := validateAllowRevokeFlags(&allowRevokeFlags{From: "a", To: "b", Proto: "tcp"})
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "--port")
|
||||
})
|
||||
t.Run("bad proto", func(t *testing.T) {
|
||||
err := validateAllowRevokeFlags(&allowRevokeFlags{From: "a", To: "b", Proto: "icmp", Port: 1})
|
||||
require.Error(t, err)
|
||||
})
|
||||
t.Run("ok tcp", func(t *testing.T) {
|
||||
err := validateAllowRevokeFlags(&allowRevokeFlags{From: "a", To: "b", Proto: "tcp", Port: 80})
|
||||
require.NoError(t, err)
|
||||
})
|
||||
t.Run("ok no-proto no-port", func(t *testing.T) {
|
||||
err := validateAllowRevokeFlags(&allowRevokeFlags{From: "a", To: "b", Proto: "", Port: 0})
|
||||
require.NoError(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
// cmdFakeRunner matches a Runner call against substrings in its response map
|
||||
// and returns the first hit. Mirrors cmd/init/plan_test.go's pattern.
|
||||
type cmdFakeRunner struct {
|
||||
responses map[string]string
|
||||
calls []string
|
||||
}
|
||||
|
||||
func (f *cmdFakeRunner) Run(_ context.Context, _, _ string, _ int, cmd string) (string, string, error) {
|
||||
f.calls = append(f.calls, cmd)
|
||||
for sub, resp := range f.responses {
|
||||
if strings.Contains(cmd, sub) {
|
||||
return resp, "", nil
|
||||
}
|
||||
}
|
||||
return "", "", nil
|
||||
}
|
||||
|
||||
var _ ssh.Runner = (*cmdFakeRunner)(nil)
|
||||
|
||||
func rootCmdFor(cmd *cobra.Command) {
|
||||
root := &cobra.Command{Use: "coolify"}
|
||||
root.PersistentFlags().String("format", "table", "")
|
||||
root.AddCommand(cmd)
|
||||
}
|
||||
|
||||
// parentWithToken builds a Flags pre-wired for the REST path:
|
||||
// single test host, coold port 8443, non-empty bearer token.
|
||||
func parentWithToken() *Flags {
|
||||
return &Flags{
|
||||
SSHMeshFlags: common.SSHMeshFlags{
|
||||
Servers: []string{"h1"}, SSHUser: "root", SSHPort: 22, Concurrency: 1,
|
||||
},
|
||||
Namespace: common.DefaultNamespace,
|
||||
CooldToken: "test-token",
|
||||
CooldPort: 8443,
|
||||
WGInterface: "wg0",
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmitAllowRevoke_PostsOneAllowToCoold(t *testing.T) {
|
||||
fr := &cmdFakeRunner{responses: map[string]string{
|
||||
"podman ps": "aaa111111111|web|10.210.0.10",
|
||||
}}
|
||||
parent := parentWithToken()
|
||||
local := &allowRevokeFlags{
|
||||
From: "10.210.1.5", To: "web", Proto: "tcp", Port: 80,
|
||||
}
|
||||
inner := &cobra.Command{Use: "allow"}
|
||||
rootCmdFor(inner)
|
||||
|
||||
err := emitAllowRevoke(context.Background(), inner, parent, local, fr, false)
|
||||
require.NoError(t, err)
|
||||
|
||||
var posts []string
|
||||
for _, c := range fr.calls {
|
||||
if strings.Contains(c, "-X POST") && strings.Contains(c, "/api/v1/firewall/allow") {
|
||||
posts = append(posts, c)
|
||||
}
|
||||
}
|
||||
assert.Len(t, posts, 1)
|
||||
// Token carried in Authorization header.
|
||||
assert.Contains(t, posts[0], "Authorization: Bearer test-token")
|
||||
// JSON body carries namespace + src/dst/port.
|
||||
assert.Contains(t, posts[0], `"namespace":"default"`)
|
||||
assert.Contains(t, posts[0], `"src":"10.210.1.5"`)
|
||||
assert.Contains(t, posts[0], `"dst":"10.210.0.10"`)
|
||||
assert.Contains(t, posts[0], `"port":80`)
|
||||
// Discovers mgmt IP via wg0 before curl.
|
||||
assert.Contains(t, posts[0], "ip -4 -o addr show wg0")
|
||||
}
|
||||
|
||||
// TestEmitAllowRevoke_CarriesNonDefaultNamespace verifies that the user's
|
||||
// chosen namespace propagates into the JSON body (and therefore into the
|
||||
// cid hash coold will compute).
|
||||
func TestEmitAllowRevoke_CarriesNonDefaultNamespace(t *testing.T) {
|
||||
fr := &cmdFakeRunner{responses: map[string]string{
|
||||
"podman ps": "aaa111111111|web|10.220.0.10",
|
||||
}}
|
||||
parent := parentWithToken()
|
||||
parent.Namespace = "alpha"
|
||||
local := &allowRevokeFlags{
|
||||
From: "10.220.1.5", To: "web", Proto: "tcp", Port: 80,
|
||||
}
|
||||
inner := &cobra.Command{Use: "allow"}
|
||||
rootCmdFor(inner)
|
||||
|
||||
err := emitAllowRevoke(context.Background(), inner, parent, local, fr, false)
|
||||
require.NoError(t, err)
|
||||
var post string
|
||||
for _, c := range fr.calls {
|
||||
if strings.Contains(c, "-X POST") {
|
||||
post = c
|
||||
}
|
||||
}
|
||||
assert.NotEmpty(t, post)
|
||||
assert.Contains(t, post, `"namespace":"alpha"`)
|
||||
// Discovery targets the alpha-namespace bridge, not the default one.
|
||||
var psCalls []string
|
||||
for _, c := range fr.calls {
|
||||
if strings.Contains(c, "podman ps") {
|
||||
psCalls = append(psCalls, c)
|
||||
}
|
||||
}
|
||||
assert.NotEmpty(t, psCalls)
|
||||
assert.Contains(t, psCalls[0], "coolify-alpha-mesh")
|
||||
}
|
||||
|
||||
func TestEmitAllowRevoke_Bidirectional(t *testing.T) {
|
||||
fr := &cmdFakeRunner{responses: map[string]string{
|
||||
"podman ps": "aaa111111111|web|10.210.0.10\nbbb222222222|client|10.210.1.5",
|
||||
}}
|
||||
parent := parentWithToken()
|
||||
local := &allowRevokeFlags{
|
||||
From: "10.210.1.5", To: "10.210.0.10", Proto: "tcp", Port: 80, Bidirectional: true,
|
||||
}
|
||||
inner := &cobra.Command{Use: "allow"}
|
||||
rootCmdFor(inner)
|
||||
|
||||
err := emitAllowRevoke(context.Background(), inner, parent, local, fr, false)
|
||||
require.NoError(t, err)
|
||||
|
||||
var posts int
|
||||
for _, c := range fr.calls {
|
||||
if strings.Contains(c, "-X POST") && strings.Contains(c, "/api/v1/firewall/allow") {
|
||||
posts++
|
||||
}
|
||||
}
|
||||
assert.Equal(t, 2, posts)
|
||||
}
|
||||
|
||||
func TestEmitAllowRevoke_RevokeIssuesDelete(t *testing.T) {
|
||||
fr := &cmdFakeRunner{responses: map[string]string{
|
||||
"podman ps": "aaa111111111|web|10.210.0.10",
|
||||
}}
|
||||
parent := parentWithToken()
|
||||
local := &allowRevokeFlags{
|
||||
From: "10.210.1.5", To: "web", Proto: "tcp", Port: 80,
|
||||
}
|
||||
inner := &cobra.Command{Use: "revoke"}
|
||||
rootCmdFor(inner)
|
||||
|
||||
err := emitAllowRevoke(context.Background(), inner, parent, local, fr, true)
|
||||
require.NoError(t, err)
|
||||
|
||||
var deletes []string
|
||||
for _, c := range fr.calls {
|
||||
if strings.Contains(c, "-X DELETE") && strings.Contains(c, "/api/v1/firewall/allow/") {
|
||||
deletes = append(deletes, c)
|
||||
}
|
||||
}
|
||||
assert.Len(t, deletes, 1)
|
||||
assert.Contains(t, deletes[0], "Authorization: Bearer test-token")
|
||||
}
|
||||
|
||||
func TestEmitAllowRevoke_FetchesTokenPerHostWhenOverrideAbsent(t *testing.T) {
|
||||
// No --coold-token override → CLI SSHes `cat /etc/coolify/api-token`
|
||||
// on the destination host and uses the result as the bearer.
|
||||
fr := &cmdFakeRunner{responses: map[string]string{
|
||||
"podman ps": "aaa111111111|web|10.210.0.10",
|
||||
"/etc/coolify/api-token": "per-host-token\n",
|
||||
}}
|
||||
parent := parentWithToken()
|
||||
parent.CooldToken = ""
|
||||
t.Setenv("COOLIFY_COOLD_TOKEN", "")
|
||||
local := &allowRevokeFlags{
|
||||
From: "10.210.1.5", To: "web", Proto: "tcp", Port: 80,
|
||||
}
|
||||
inner := &cobra.Command{Use: "allow"}
|
||||
rootCmdFor(inner)
|
||||
|
||||
err := emitAllowRevoke(context.Background(), inner, parent, local, fr, false)
|
||||
require.NoError(t, err)
|
||||
var post string
|
||||
for _, c := range fr.calls {
|
||||
if strings.Contains(c, "-X POST") && strings.Contains(c, "/api/v1/firewall/allow") {
|
||||
post = c
|
||||
}
|
||||
}
|
||||
assert.NotEmpty(t, post)
|
||||
assert.Contains(t, post, "Authorization: Bearer per-host-token")
|
||||
}
|
||||
|
||||
func TestEmitAllowRevoke_FetchFailurePropagates(t *testing.T) {
|
||||
// Empty /etc/coolify/api-token on the host → FetchCooldToken errors,
|
||||
// and the error surfaces to the caller instead of silently proceeding.
|
||||
fr := &cmdFakeRunner{responses: map[string]string{
|
||||
"podman ps": "aaa111111111|web|10.210.0.10",
|
||||
// No token file → empty stdout → "token is empty" error.
|
||||
}}
|
||||
parent := parentWithToken()
|
||||
parent.CooldToken = ""
|
||||
t.Setenv("COOLIFY_COOLD_TOKEN", "")
|
||||
local := &allowRevokeFlags{
|
||||
From: "10.210.1.5", To: "web", Proto: "tcp", Port: 80,
|
||||
}
|
||||
inner := &cobra.Command{Use: "allow"}
|
||||
rootCmdFor(inner)
|
||||
|
||||
err := emitAllowRevoke(context.Background(), inner, parent, local, fr, false)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "coold token")
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package firewall
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
ifw "github.com/coollabsio/coolify-cli/internal/firewall"
|
||||
"github.com/coollabsio/coolify-cli/internal/models"
|
||||
"github.com/coollabsio/coolify-cli/internal/output"
|
||||
"github.com/coollabsio/coolify-cli/internal/ssh"
|
||||
)
|
||||
|
||||
// newContainersCommand builds `coolify firewall containers`.
|
||||
func newContainersCommand(flags *Flags) *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "containers",
|
||||
Short: "List containers on the Coolify mesh bridge across all servers",
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
return runContainers(cmd.Context(), cmd, flags)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func runContainers(ctx context.Context, cmd *cobra.Command, flags *Flags) error {
|
||||
if err := flags.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
runner, err := flags.BuildSSHClient()
|
||||
if err != nil {
|
||||
return fmt.Errorf("SSH client: %w", err)
|
||||
}
|
||||
return emitContainers(ctx, cmd, flags, runner)
|
||||
}
|
||||
|
||||
// emitContainers is factored out so tests can pass a fake ssh.Runner.
|
||||
func emitContainers(
|
||||
ctx context.Context,
|
||||
cmd *cobra.Command,
|
||||
flags *Flags,
|
||||
runner ssh.Runner,
|
||||
) error {
|
||||
var (
|
||||
all []ifw.Container
|
||||
results []ssh.ServerResult[[]ifw.Container]
|
||||
)
|
||||
if flags.AllNamespaces {
|
||||
// Discover across every managed network on each host.
|
||||
nsList, nsResults := discoverNamespacesOnHosts(ctx, runner, flags)
|
||||
for _, r := range nsResults {
|
||||
if r.Err != nil {
|
||||
results = append(results, ssh.ServerResult[[]ifw.Container]{
|
||||
Host: r.Host, Err: r.Err,
|
||||
})
|
||||
}
|
||||
}
|
||||
var containerResults []ssh.ServerResult[[]ifw.Container]
|
||||
all, containerResults = discoverAcrossNamespaces(ctx, runner, flags, nsList)
|
||||
results = append(results, containerResults...)
|
||||
} else {
|
||||
all, results = discoverAllViaPkg(ctx, runner, flags)
|
||||
}
|
||||
|
||||
rows := make([]models.ContainerRow, 0, len(all))
|
||||
for _, c := range all {
|
||||
rows = append(rows, models.ContainerRow{
|
||||
Host: c.Host, Namespace: c.Namespace, ID: c.ID, Name: c.Name, IP: c.IP.String(),
|
||||
})
|
||||
}
|
||||
|
||||
var errs []string
|
||||
for _, r := range results {
|
||||
if r.Err != nil {
|
||||
errs = append(errs, fmt.Sprintf("%s: %v", r.Host, r.Err))
|
||||
}
|
||||
}
|
||||
for _, e := range errs {
|
||||
fmt.Fprintln(os.Stderr, "Warning:", e)
|
||||
}
|
||||
|
||||
format, _ := cmd.Root().PersistentFlags().GetString("format")
|
||||
if format == "" {
|
||||
format = output.FormatTable
|
||||
}
|
||||
formatter, err := output.NewFormatter(format, output.Options{Writer: os.Stdout})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if format == output.FormatJSON || format == output.FormatPretty {
|
||||
return formatter.Format(models.FirewallContainersOutput{
|
||||
Containers: rows, Errors: errs,
|
||||
})
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
if flags.AllNamespaces {
|
||||
fmt.Fprintln(os.Stderr, "No containers found on any coolify-<ns>-mesh network.")
|
||||
} else {
|
||||
fmt.Fprintf(os.Stderr, "No containers found on %s network.\n", flags.PodmanNetworkName())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return formatter.Format(rows)
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package firewall
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/coollabsio/coolify-cli/cmd/common"
|
||||
)
|
||||
|
||||
func TestEmitContainers_RunsAndFormatsTable(t *testing.T) {
|
||||
fr := &cmdFakeRunner{responses: map[string]string{
|
||||
"podman ps": "aaa111111111|web|10.210.0.10",
|
||||
}}
|
||||
parent := &Flags{
|
||||
SSHMeshFlags: common.SSHMeshFlags{
|
||||
Servers: []string{"h1"}, SSHUser: "root", SSHPort: 22, Concurrency: 1,
|
||||
},
|
||||
Namespace: common.DefaultNamespace,
|
||||
}
|
||||
inner := &cobra.Command{Use: "containers"}
|
||||
rootCmdFor(inner)
|
||||
|
||||
err := emitContainers(context.Background(), inner, parent, fr)
|
||||
require.NoError(t, err)
|
||||
// Discovery command was issued, targeting the default-namespace bridge.
|
||||
assert.Len(t, fr.calls, 1)
|
||||
assert.Contains(t, fr.calls[0], "podman ps")
|
||||
assert.Contains(t, fr.calls[0], "coolify-default-mesh")
|
||||
}
|
||||
|
||||
func TestEmitContainers_EmptyOutput(t *testing.T) {
|
||||
fr := &cmdFakeRunner{responses: map[string]string{}}
|
||||
parent := &Flags{
|
||||
SSHMeshFlags: common.SSHMeshFlags{
|
||||
Servers: []string{"h1"}, SSHUser: "root", SSHPort: 22, Concurrency: 1,
|
||||
},
|
||||
Namespace: common.DefaultNamespace,
|
||||
}
|
||||
inner := &cobra.Command{Use: "containers"}
|
||||
rootCmdFor(inner)
|
||||
|
||||
err := emitContainers(context.Background(), inner, parent, fr)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// TestEmitContainers_AllNamespaces_FansOutAcrossNetworks verifies that with
|
||||
// --all-namespaces the CLI first enumerates managed networks on every host
|
||||
// and then issues one podman-ps per namespace it found.
|
||||
func TestEmitContainers_AllNamespaces_FansOutAcrossNetworks(t *testing.T) {
|
||||
fr := &cmdFakeRunner{responses: map[string]string{
|
||||
// Host reports two managed namespaces via label inspection.
|
||||
"podman network ls": "default\nalpha\n",
|
||||
// Every subsequent podman-ps returns the same container.
|
||||
"podman ps": "aaa111111111|web|10.210.0.10",
|
||||
}}
|
||||
parent := &Flags{
|
||||
SSHMeshFlags: common.SSHMeshFlags{
|
||||
Servers: []string{"h1"}, SSHUser: "root", SSHPort: 22, Concurrency: 1,
|
||||
},
|
||||
Namespace: common.DefaultNamespace,
|
||||
AllNamespaces: true,
|
||||
}
|
||||
inner := &cobra.Command{Use: "containers"}
|
||||
rootCmdFor(inner)
|
||||
|
||||
err := emitContainers(context.Background(), inner, parent, fr)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Expect one `podman network ls` discovery call + one `podman ps` per
|
||||
// discovered namespace (default + alpha = 2).
|
||||
var ls, ps int
|
||||
for _, c := range fr.calls {
|
||||
switch {
|
||||
case containsAll(c, "podman network ls", "io.coolify.managed=true"):
|
||||
ls++
|
||||
case containsAll(c, "podman ps"):
|
||||
ps++
|
||||
}
|
||||
}
|
||||
assert.Equal(t, 1, ls, "one namespace-discovery call per host")
|
||||
assert.Equal(t, 2, ps, "one container-discovery call per namespace per host")
|
||||
}
|
||||
|
||||
func containsAll(s string, subs ...string) bool {
|
||||
for _, sub := range subs {
|
||||
if !contains(s, sub) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func contains(s, sub string) bool {
|
||||
// Tiny local wrapper so tests stay readable without importing strings
|
||||
// twice — the test file already uses it elsewhere via cmdFakeRunner.
|
||||
for i := 0; i+len(sub) <= len(s); i++ {
|
||||
if s[i:i+len(sub)] == sub {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package firewall
|
||||
|
||||
import (
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// NewFirewallCommand creates the parent `coolify firewall` command.
|
||||
// On bare invocation (no subcommand) it prints help.
|
||||
func NewFirewallCommand() *cobra.Command {
|
||||
flags := &Flags{}
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "firewall",
|
||||
Short: "[ALPHA] Manage cross-host container allow rules (Coolify v5)",
|
||||
Long: `[ALPHA] Manage the COOLIFY-ALLOW iptables chain installed by
|
||||
"coolify init --podman --default-deny". This is a test harness for the v5
|
||||
control-plane firewall flow: it SSHes into every server, discovers running
|
||||
containers on the Coolify mesh bridge (override with --podman-network), and
|
||||
lets you add/remove cross-host allow rules.
|
||||
|
||||
Subcommands:
|
||||
containers List discovered containers across the mesh.
|
||||
list Show installed allow rules.
|
||||
allow Add an allow rule (src container → dst container:port).
|
||||
revoke Remove an allow rule.`,
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
return cmd.Help()
|
||||
},
|
||||
}
|
||||
|
||||
bindFlags(cmd, flags)
|
||||
|
||||
cmd.AddCommand(newContainersCommand(flags))
|
||||
cmd.AddCommand(newListCommand(flags))
|
||||
cmd.AddCommand(newAllowCommand(flags))
|
||||
cmd.AddCommand(newRevokeCommand(flags))
|
||||
|
||||
return cmd
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user