mirror of
https://github.com/coollabsio/coolify-cli.git
synced 2026-06-21 08:35:03 +00:00
Compare commits
19 Commits
chore/refactor
...
1.0.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 ."
|
||||
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'"
|
||||
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
|
||||
+8
-1
@@ -1,4 +1,11 @@
|
||||
coolify-cli
|
||||
coolify
|
||||
cli
|
||||
config.json
|
||||
config.json
|
||||
|
||||
# Generated documentation (can be regenerated)
|
||||
man/
|
||||
docs/cli/
|
||||
|
||||
# Test coverage
|
||||
coverage.out
|
||||
+584
@@ -0,0 +1,584 @@
|
||||
# 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
|
||||
- `instances.go` - 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 .
|
||||
|
||||
# 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,343 @@
|
||||
# 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
|
||||
- **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: `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/`:
|
||||
- `instances.go` - manage Coolify instances (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 .
|
||||
```
|
||||
|
||||
### Run locally
|
||||
```bash
|
||||
go run main.go [command]
|
||||
```
|
||||
|
||||
### Test a command
|
||||
```bash
|
||||
go run main.go instances list
|
||||
go run main.go servers list --debug
|
||||
```
|
||||
|
||||
### Install locally
|
||||
```bash
|
||||
go install
|
||||
```
|
||||
|
||||
### 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
|
||||
|
||||
## 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
|
||||
@@ -0,0 +1,141 @@
|
||||
# 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. Update Version Number
|
||||
|
||||
Edit `cmd/root.go` and update the `CliVersion` variable:
|
||||
|
||||
```go
|
||||
var CliVersion = "1.x.x" // Change to your new version
|
||||
```
|
||||
|
||||
**Version Format:** Use semantic versioning: `MAJOR.MINOR.PATCH` (e.g., `1.2.3`)
|
||||
- **MAJOR**: Breaking changes
|
||||
- **MINOR**: New features (backwards compatible)
|
||||
- **PATCH**: Bug fixes (backwards compatible)
|
||||
|
||||
### 2. Commit and Push Version Change
|
||||
|
||||
```bash
|
||||
git add cmd/root.go
|
||||
git commit -m "chore: bump version to 1.x.x"
|
||||
git push origin v4.x
|
||||
```
|
||||
|
||||
### 3. 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"
|
||||
|
||||
### 4. 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. Binaries are automatically uploaded to the release
|
||||
4. 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`
|
||||
|
||||
### 5. Verify the Release
|
||||
|
||||
After the workflow completes (usually 2-5 minutes):
|
||||
|
||||
1. Check the release page has all platform binaries
|
||||
2. Test the install script:
|
||||
```bash
|
||||
curl -fsSL https://cdn.coollabs.io/coolify/install.sh | bash
|
||||
coolify version
|
||||
```
|
||||
3. Test the auto-update functionality:
|
||||
```bash
|
||||
# If you have an older version installed
|
||||
coolify update
|
||||
coolify version # Should show the new version
|
||||
```
|
||||
4. Verify the version matches your release
|
||||
|
||||
## 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
|
||||
- Ensure you committed the version change in `cmd/root.go`
|
||||
- The tag must start with `v` (e.g., `v1.2.3`, not `1.2.3`)
|
||||
- Check that the workflow has write permissions
|
||||
|
||||
### 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 ./...`
|
||||
- [ ] Version updated in `cmd/root.go`
|
||||
- [ ] Changes merged to `v4.x` branch
|
||||
- [ ] Release notes prepared
|
||||
|
||||
After creating a release:
|
||||
|
||||
- [ ] GitHub Actions workflow completed successfully
|
||||
- [ ] All platform binaries are present on the release page
|
||||
- [ ] Install script downloads the new version
|
||||
- [ ] `coolify version` returns the correct version
|
||||
|
||||
## Configuration Files
|
||||
|
||||
The release process uses these configuration files:
|
||||
|
||||
- `.goreleaser.yml` - GoReleaser configuration (build matrix, archives, etc.)
|
||||
- `.github/workflows/release-cli.yml` - GitHub Actions workflow
|
||||
- `scripts/install.sh` - User-facing install script
|
||||
- `cmd/root.go` - Contains `CliVersion` variable (line 22)
|
||||
|
||||
## 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`
|
||||
- Releases are immutable - if you need to fix something, create a new patch version
|
||||
@@ -30,9 +30,10 @@ 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>`
|
||||
## 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
|
||||
@@ -43,5 +44,379 @@ You can change the default instance with `coolify instances set default <name>`
|
||||
|
||||
### Servers
|
||||
- `coolify servers list` - List all servers
|
||||
- `coolify servers get` - Get a server
|
||||
- `--resources` - Get the resources and their status of a server
|
||||
- `coolify servers get <uuid>` - Get a server by UUID
|
||||
- `--resources` - Get the resources and their status of a server
|
||||
- `coolify servers add <name> <ip> <private_key_uuid>` - Add a new server
|
||||
- `--port <port>` - SSH port (default: 22)
|
||||
- `--user <user>` - SSH user (default: root)
|
||||
- `--validate` - Validate server immediately after adding
|
||||
- `coolify servers remove <uuid>` - Remove a server
|
||||
- `coolify servers validate <uuid>` - Validate a server connection
|
||||
|
||||
### 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
|
||||
- `coolify app delete <uuid>` - Delete an application
|
||||
- `--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)
|
||||
- `--is-preview` - Set variable for preview environments
|
||||
- `--is-build-time` - Set variable as build-time variable
|
||||
- `--is-literal` - Treat value as literal (no variable expansion)
|
||||
- `--is-multiline` - Allow multiline values
|
||||
- `--is-shown-once` - Show value only once (for secrets)
|
||||
- `coolify app env update <app_uuid> <env_uuid>` - Update an environment variable
|
||||
- `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)
|
||||
|
||||
### 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)
|
||||
- `--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
|
||||
- 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)
|
||||
- `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
|
||||
- `--retention-amount-locally <n>` - Number of backups to retain locally
|
||||
- `--retention-days-locally <n>` - Days to retain backups locally
|
||||
- `--timeout <seconds>` - Backup timeout
|
||||
- `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>` - Update an environment variable
|
||||
- `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)
|
||||
|
||||
### Deployments
|
||||
- `coolify deploy uuid <uuid>` - Deploy a resource by UUID
|
||||
- `--force` - Force deployment
|
||||
- `coolify deploy name <name>` - Deploy a resource by name
|
||||
- `--force` - Force deployment
|
||||
- `coolify deploy batch <name1,name2,...>` - Deploy multiple resources at once
|
||||
- `--force` - Force all deployments
|
||||
- `coolify deploy list` - List all deployments
|
||||
- `coolify deploy get <uuid>` - Get deployment details
|
||||
- `coolify deploy cancel <uuid>` - Cancel a deployment
|
||||
- `--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)
|
||||
- `--html-url <url>` - GitHub HTML URL (required)
|
||||
- `--app-id <id>` - GitHub App ID (required)
|
||||
- `--installation-id <id>` - Installation ID (required)
|
||||
- `--client-id <id>` - OAuth Client ID (required)
|
||||
- `--client-secret <secret>` - OAuth Client Secret (required)
|
||||
- `--private-key-uuid <uuid>` - Private key UUID (required)
|
||||
- `--organization <org>` - GitHub organization
|
||||
- `--custom-user <user>` - Custom SSH user
|
||||
- `--custom-port <port>` - Custom SSH port
|
||||
- `--webhook-secret <secret>` - Webhook secret
|
||||
- `--system-wide` - System-wide installation
|
||||
- `coolify github update <app_uuid>` - Update a GitHub App
|
||||
- `coolify github delete <app_uuid>` - Delete a GitHub App
|
||||
- `--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 <id>` - Get team details
|
||||
- `coolify team current` - Get current team
|
||||
- `coolify team members list [team_id]` - List team members
|
||||
|
||||
### Domains
|
||||
- `coolify domains list` - List all domains
|
||||
|
||||
### Private Keys
|
||||
- `coolify privatekeys list` - List all private keys
|
||||
- `coolify privatekeys create <name> <private-key>` - Create a new private key
|
||||
- Use `@filename` to read from file: `coolify privatekeys create mykey @~/.ssh/id_rsa`
|
||||
- `coolify privatekeys delete <uuid>` - Delete a private key
|
||||
|
||||
## Global Flags
|
||||
|
||||
All commands support these global flags:
|
||||
|
||||
- `--instance <name>` - Use a specific instance profile instead of default (NEW)
|
||||
- `--host <fqdn>` - Override the Coolify instance hostname
|
||||
- `--token <token>` - Override the authentication token
|
||||
- `--format <format>` - Output format: `table` (default), `json`, or `pretty`
|
||||
- `--show-sensitive` / `-s` - Show sensitive information (tokens, IPs, etc.)
|
||||
- `--force` / `-f` - Force operation (skip confirmations)
|
||||
- `--debug` - Enable debug mode
|
||||
|
||||
## Examples
|
||||
|
||||
### Multi-Environment Workflows
|
||||
|
||||
```bash
|
||||
# Add multiple instances
|
||||
coolify instances add prod https://prod.coolify.io <prod-token>
|
||||
coolify instances add staging https://staging.coolify.io <staging-token>
|
||||
coolify instances add dev https://dev.coolify.io <dev-token>
|
||||
|
||||
# Set default
|
||||
coolify instances set default prod
|
||||
|
||||
# Use different profiles
|
||||
coolify --instance=staging servers list
|
||||
coolify --instance=prod deploy name api
|
||||
coolify --instance=dev resources list
|
||||
|
||||
# Default profile (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
|
||||
coolify app env sync <uuid> --file .env
|
||||
```
|
||||
|
||||
### 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 profile
|
||||
coolify --instance=prod deploy batch api,worker --force
|
||||
|
||||
# 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 --instance=prod servers list
|
||||
|
||||
# Add a server with validation
|
||||
coolify servers add myserver 192.168.1.100 <key-uuid> --validate
|
||||
|
||||
# Get server details with resources
|
||||
coolify servers get <uuid> --resources
|
||||
```
|
||||
|
||||
## Output Formats
|
||||
|
||||
The CLI supports three output formats:
|
||||
|
||||
```bash
|
||||
# Table format (default, human-readable)
|
||||
coolify servers list
|
||||
|
||||
# JSON format (for scripts)
|
||||
coolify servers list --format=json
|
||||
|
||||
# Pretty JSON (for debugging)
|
||||
coolify servers 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-instance configuration management
|
||||
- **Models Layer**: Type-safe data structures
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
# Build
|
||||
go build -o coolify .
|
||||
|
||||
# Run tests
|
||||
go test ./...
|
||||
|
||||
# Run with coverage
|
||||
go test -cover ./...
|
||||
|
||||
# Install locally
|
||||
go install
|
||||
```
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions are welcome! Please check the [restructure documentation](RESTRUCTURE_PLAN.md) for architecture guidelines.
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
|
||||
@@ -0,0 +1,910 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/coollabsio/coolify-cli/internal/models"
|
||||
"github.com/coollabsio/coolify-cli/internal/output"
|
||||
"github.com/coollabsio/coolify-cli/internal/parser"
|
||||
"github.com/coollabsio/coolify-cli/internal/service"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var applicationsCmd = &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.`,
|
||||
}
|
||||
|
||||
var listApplicationsCmd = &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List all applications",
|
||||
Long: `List all applications in your Coolify instance.`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := context.Background()
|
||||
|
||||
client, err := 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)
|
||||
},
|
||||
}
|
||||
|
||||
var getApplicationCmd = &cobra.Command{
|
||||
Use: "get <uuid>",
|
||||
Short: "Get application details by UUID",
|
||||
Long: `Retrieve detailed information about a specific application.`,
|
||||
Args: exactArgs(1, "<uuid>"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := context.Background()
|
||||
uuid := args[0]
|
||||
|
||||
client, err := 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)
|
||||
},
|
||||
}
|
||||
|
||||
var updateApplicationCmd = &cobra.Command{
|
||||
Use: "update <uuid>",
|
||||
Short: "Update application configuration",
|
||||
Long: `Update configuration for a specific application. Only specified fields will be updated.`,
|
||||
Args: exactArgs(1, "<uuid>"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := context.Background()
|
||||
uuid := args[0]
|
||||
|
||||
client, err := getAPIClient(cmd)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get API client: %w", err)
|
||||
}
|
||||
|
||||
// Build update request from flags
|
||||
req := models.ApplicationUpdateRequest{}
|
||||
hasUpdates := false
|
||||
|
||||
// Basic configuration
|
||||
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
|
||||
}
|
||||
|
||||
// Build configuration
|
||||
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
|
||||
}
|
||||
|
||||
// Docker configuration
|
||||
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
|
||||
}
|
||||
|
||||
// Ports
|
||||
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
|
||||
}
|
||||
|
||||
// Health check
|
||||
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)
|
||||
},
|
||||
}
|
||||
|
||||
var deleteApplicationCmd = &cobra.Command{
|
||||
Use: "delete <uuid>",
|
||||
Short: "Delete an application",
|
||||
Long: `Delete an application. This action cannot be undone.`,
|
||||
Args: exactArgs(1, "<uuid>"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := context.Background()
|
||||
uuid := args[0]
|
||||
|
||||
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 application %s? This cannot be undone. (yes/no): ", uuid)
|
||||
fmt.Scanln(&response)
|
||||
|
||||
if response != "yes" && response != "y" {
|
||||
fmt.Println("Delete cancelled.")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
client, err := 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
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
// Define update command flags (most common ones)
|
||||
updateApplicationCmd.Flags().String("name", "", "Application name")
|
||||
updateApplicationCmd.Flags().String("description", "", "Application description")
|
||||
updateApplicationCmd.Flags().String("git-branch", "", "Git branch")
|
||||
updateApplicationCmd.Flags().String("git-repository", "", "Git repository URL")
|
||||
updateApplicationCmd.Flags().String("domains", "", "Domains (comma-separated)")
|
||||
updateApplicationCmd.Flags().String("build-command", "", "Build command")
|
||||
updateApplicationCmd.Flags().String("start-command", "", "Start command")
|
||||
updateApplicationCmd.Flags().String("install-command", "", "Install command")
|
||||
updateApplicationCmd.Flags().String("base-directory", "", "Base directory")
|
||||
updateApplicationCmd.Flags().String("publish-directory", "", "Publish directory")
|
||||
updateApplicationCmd.Flags().String("dockerfile", "", "Dockerfile content")
|
||||
updateApplicationCmd.Flags().String("docker-image", "", "Docker image name")
|
||||
updateApplicationCmd.Flags().String("docker-tag", "", "Docker image tag")
|
||||
updateApplicationCmd.Flags().String("ports-exposes", "", "Exposed ports")
|
||||
updateApplicationCmd.Flags().String("ports-mappings", "", "Port mappings")
|
||||
updateApplicationCmd.Flags().Bool("health-check-enabled", false, "Enable health check")
|
||||
updateApplicationCmd.Flags().String("health-check-path", "", "Health check path")
|
||||
|
||||
// Define delete command flags
|
||||
deleteApplicationCmd.Flags().BoolP("force", "f", false, "Skip confirmation prompt")
|
||||
|
||||
// Define start command flags
|
||||
startApplicationCmd.Flags().Bool("force", false, "Force rebuild")
|
||||
startApplicationCmd.Flags().Bool("instant-deploy", false, "Instant deploy (skip queuing)")
|
||||
|
||||
// Define logs command flags
|
||||
logsApplicationCmd.Flags().IntP("lines", "n", 100, "Number of log lines to retrieve")
|
||||
logsApplicationCmd.Flags().BoolP("follow", "f", false, "Follow log output (like tail -f)")
|
||||
|
||||
// Define envs create command flags
|
||||
createEnvCmd.Flags().String("key", "", "Environment variable key (required)")
|
||||
createEnvCmd.Flags().String("value", "", "Environment variable value (required)")
|
||||
createEnvCmd.Flags().Bool("build-time", false, "Available at build time")
|
||||
createEnvCmd.Flags().Bool("preview", false, "Available in preview deployments")
|
||||
createEnvCmd.Flags().Bool("is-literal", false, "Treat value as literal (don't interpolate variables)")
|
||||
createEnvCmd.Flags().Bool("is-multiline", false, "Value is multiline")
|
||||
|
||||
// Define envs update command flags
|
||||
updateEnvCmd.Flags().String("key", "", "New environment variable key")
|
||||
updateEnvCmd.Flags().String("value", "", "New environment variable value")
|
||||
updateEnvCmd.Flags().Bool("build-time", false, "Available at build time")
|
||||
updateEnvCmd.Flags().Bool("preview", false, "Available in preview deployments")
|
||||
updateEnvCmd.Flags().Bool("is-literal", false, "Treat value as literal (don't interpolate variables)")
|
||||
updateEnvCmd.Flags().Bool("is-multiline", false, "Value is multiline")
|
||||
|
||||
// Define envs delete command flags
|
||||
deleteEnvCmd.Flags().Bool("force", false, "Skip confirmation prompt")
|
||||
|
||||
// Define envs sync command flags
|
||||
syncEnvCmd.Flags().StringP("file", "f", "", "Path to .env file (required)")
|
||||
syncEnvCmd.Flags().Bool("build-time", false, "Make all variables available at build time")
|
||||
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)")
|
||||
|
||||
rootCmd.AddCommand(applicationsCmd)
|
||||
applicationsCmd.AddCommand(listApplicationsCmd)
|
||||
applicationsCmd.AddCommand(getApplicationCmd)
|
||||
applicationsCmd.AddCommand(updateApplicationCmd)
|
||||
applicationsCmd.AddCommand(deleteApplicationCmd)
|
||||
applicationsCmd.AddCommand(startApplicationCmd)
|
||||
applicationsCmd.AddCommand(stopApplicationCmd)
|
||||
applicationsCmd.AddCommand(restartApplicationCmd)
|
||||
applicationsCmd.AddCommand(logsApplicationCmd)
|
||||
applicationsCmd.AddCommand(envsApplicationCmd)
|
||||
envsApplicationCmd.AddCommand(listEnvsCmd)
|
||||
envsApplicationCmd.AddCommand(getEnvCmd)
|
||||
envsApplicationCmd.AddCommand(createEnvCmd)
|
||||
envsApplicationCmd.AddCommand(updateEnvCmd)
|
||||
envsApplicationCmd.AddCommand(deleteEnvCmd)
|
||||
envsApplicationCmd.AddCommand(syncEnvCmd)
|
||||
}
|
||||
|
||||
var startApplicationCmd = &cobra.Command{
|
||||
Use: "start <uuid>",
|
||||
Aliases: []string{"deploy"},
|
||||
Short: "Start an application",
|
||||
Long: `Start an application (initiates a deployment).`,
|
||||
Args: exactArgs(1, "<uuid>"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := context.Background()
|
||||
uuid := args[0]
|
||||
|
||||
client, err := 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
|
||||
},
|
||||
}
|
||||
|
||||
var stopApplicationCmd = &cobra.Command{
|
||||
Use: "stop <uuid>",
|
||||
Short: "Stop an application",
|
||||
Long: `Stop a running application.`,
|
||||
Args: exactArgs(1, "<uuid>"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := context.Background()
|
||||
uuid := args[0]
|
||||
|
||||
client, err := 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
|
||||
},
|
||||
}
|
||||
|
||||
var restartApplicationCmd = &cobra.Command{
|
||||
Use: "restart <uuid>",
|
||||
Short: "Restart an application",
|
||||
Long: `Restart a running application.`,
|
||||
Args: exactArgs(1, "<uuid>"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := context.Background()
|
||||
uuid := args[0]
|
||||
|
||||
client, err := 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
|
||||
},
|
||||
}
|
||||
|
||||
var logsApplicationCmd = &cobra.Command{
|
||||
Use: "logs <uuid>",
|
||||
Short: "Get application logs",
|
||||
Long: `Retrieve logs for an application. Use --follow to continuously stream new logs.`,
|
||||
Args: exactArgs(1, "<uuid>"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := context.Background()
|
||||
uuid := args[0]
|
||||
|
||||
client, err := 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 {
|
||||
// One-time fetch
|
||||
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
|
||||
}
|
||||
|
||||
// Follow mode: poll for new logs
|
||||
ticker := time.NewTicker(2 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
// Set up signal handling for graceful shutdown
|
||||
sigChan := make(chan os.Signal, 1)
|
||||
signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)
|
||||
|
||||
// Track the last log content to avoid duplicates
|
||||
lastLogs := ""
|
||||
|
||||
// Fetch initial logs
|
||||
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
|
||||
|
||||
// Poll for new 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 {
|
||||
// Don't fail on transient errors in follow mode
|
||||
continue
|
||||
}
|
||||
// Only print if logs have changed
|
||||
if resp.Logs != lastLogs {
|
||||
// Print only the new content
|
||||
if len(resp.Logs) > len(lastLogs) && strings.HasPrefix(resp.Logs, lastLogs) {
|
||||
fmt.Print(resp.Logs[len(lastLogs):])
|
||||
} else {
|
||||
// Logs were truncated or changed, print all
|
||||
fmt.Print(resp.Logs)
|
||||
}
|
||||
lastLogs = resp.Logs
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
var envsApplicationCmd = &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.`,
|
||||
}
|
||||
|
||||
var listEnvsCmd = &cobra.Command{
|
||||
Use: "list <app_uuid>",
|
||||
Short: "List all environment variables for an application",
|
||||
Long: `List all environment variables for a specific application.`,
|
||||
Args: exactArgs(1, "<uuid>"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := context.Background()
|
||||
uuid := args[0]
|
||||
|
||||
client, err := 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)
|
||||
}
|
||||
|
||||
format, _ := cmd.Flags().GetString("format")
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
formatter, err := output.NewFormatter(format, output.Options{
|
||||
ShowSensitive: showSensitive,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return formatter.Format(envs)
|
||||
},
|
||||
}
|
||||
|
||||
var getEnvCmd = &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: exactArgs(2, "<uuid1> <uuid2>"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := context.Background()
|
||||
appUUID := args[0]
|
||||
envUUID := args[1]
|
||||
|
||||
client, err := getAPIClient(cmd)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get API client: %w", err)
|
||||
}
|
||||
|
||||
appSvc := service.NewApplicationService(client)
|
||||
env, err := appSvc.GetEnv(ctx, appUUID, 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)
|
||||
},
|
||||
}
|
||||
|
||||
var createEnvCmd = &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: exactArgs(1, "<uuid>"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := context.Background()
|
||||
uuid := args[0]
|
||||
|
||||
client, err := 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")
|
||||
|
||||
if key == "" {
|
||||
return fmt.Errorf("--key is required")
|
||||
}
|
||||
if value == "" {
|
||||
return fmt.Errorf("--value is required")
|
||||
}
|
||||
|
||||
req := &models.EnvironmentVariableCreateRequest{
|
||||
Key: key,
|
||||
Value: value,
|
||||
}
|
||||
|
||||
// Only set flags if they were 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("is-multiline") {
|
||||
req.IsMultiline = &isMultiline
|
||||
}
|
||||
|
||||
appSvc := service.NewApplicationService(client)
|
||||
env, err := appSvc.CreateEnv(ctx, uuid, req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create environment variable: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("Environment variable '%s' created successfully.\n", env.Key)
|
||||
fmt.Printf("UUID: %s\n", env.UUID)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var updateEnvCmd = &cobra.Command{
|
||||
Use: "update <app_uuid> <env_uuid>",
|
||||
Short: "Update an environment variable",
|
||||
Long: `Update an existing environment variable. First UUID is the application, second is the specific environment variable to update.`,
|
||||
Args: exactArgs(2, "<uuid1> <uuid2>"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error{
|
||||
ctx := context.Background()
|
||||
appUUID := args[0]
|
||||
envUUID := args[1]
|
||||
|
||||
client, err := getAPIClient(cmd)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get API client: %w", err)
|
||||
}
|
||||
|
||||
req := &models.EnvironmentVariableUpdateRequest{
|
||||
UUID: envUUID,
|
||||
}
|
||||
|
||||
// Only set fields that were provided
|
||||
if cmd.Flags().Changed("key") {
|
||||
key, _ := cmd.Flags().GetString("key")
|
||||
req.Key = &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
|
||||
}
|
||||
|
||||
// Check if at least one field is being updated
|
||||
if req.Key == nil && req.Value == nil && req.IsBuildTime == nil && req.IsPreview == nil && req.IsLiteral == nil && req.IsMultiline == nil {
|
||||
return fmt.Errorf("at least one field must be provided to update (--key, --value, --build-time, --preview, --is-literal, or --is-multiline)")
|
||||
}
|
||||
|
||||
appSvc := service.NewApplicationService(client)
|
||||
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
|
||||
},
|
||||
}
|
||||
|
||||
var 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: exactArgs(2, "<uuid1> <uuid2>"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := context.Background()
|
||||
appUUID := args[0]
|
||||
envUUID := args[1]
|
||||
|
||||
client, err := 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
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
},
|
||||
}
|
||||
|
||||
var 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: exactArgs(1, "<uuid>"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := context.Background()
|
||||
uuid := args[0]
|
||||
|
||||
client, err := 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")
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,466 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestApplicationsListCmd_Flags(t *testing.T) {
|
||||
cmd := listApplicationsCmd
|
||||
|
||||
// Verify command structure
|
||||
assert.Equal(t, "list", cmd.Use)
|
||||
assert.NotNil(t, cmd.RunE)
|
||||
}
|
||||
|
||||
func TestApplicationsGetCmd_Args(t *testing.T) {
|
||||
cmd := getApplicationCmd
|
||||
|
||||
// Test with no arguments - should fail
|
||||
err := cmd.Args(cmd, []string{})
|
||||
assert.Error(t, err, "should require exactly 1 argument")
|
||||
|
||||
// Test with correct number of arguments - should pass
|
||||
err = cmd.Args(cmd, []string{"app-uuid-123"})
|
||||
assert.NoError(t, err, "should accept 1 argument")
|
||||
|
||||
// Test with too many arguments - should fail
|
||||
err = cmd.Args(cmd, []string{"uuid1", "uuid2"})
|
||||
assert.Error(t, err, "should not accept more than 1 argument")
|
||||
}
|
||||
|
||||
func TestApplicationsGetCmd_Flags(t *testing.T) {
|
||||
cmd := getApplicationCmd
|
||||
|
||||
// Verify command structure
|
||||
assert.Equal(t, "get <uuid>", cmd.Use)
|
||||
assert.NotNil(t, cmd.RunE)
|
||||
assert.NotNil(t, cmd.Args)
|
||||
}
|
||||
|
||||
func TestApplicationsUpdateCmd_Args(t *testing.T) {
|
||||
cmd := updateApplicationCmd
|
||||
|
||||
// Test with no arguments - should fail
|
||||
err := cmd.Args(cmd, []string{})
|
||||
assert.Error(t, err, "should require exactly 1 argument")
|
||||
|
||||
// Test with correct number of arguments - should pass
|
||||
err = cmd.Args(cmd, []string{"app-uuid-123"})
|
||||
assert.NoError(t, err, "should accept 1 argument")
|
||||
|
||||
// Test with too many arguments - should fail
|
||||
err = cmd.Args(cmd, []string{"uuid1", "uuid2"})
|
||||
assert.Error(t, err, "should not accept more than 1 argument")
|
||||
}
|
||||
|
||||
func TestApplicationsUpdateCmd_Flags(t *testing.T) {
|
||||
cmd := updateApplicationCmd
|
||||
|
||||
// Verify command structure
|
||||
assert.Equal(t, "update <uuid>", cmd.Use)
|
||||
assert.NotNil(t, cmd.RunE)
|
||||
assert.NotNil(t, cmd.Args)
|
||||
|
||||
// Verify key flags exist
|
||||
assert.NotNil(t, cmd.Flags().Lookup("name"))
|
||||
assert.NotNil(t, cmd.Flags().Lookup("description"))
|
||||
assert.NotNil(t, cmd.Flags().Lookup("git-branch"))
|
||||
assert.NotNil(t, cmd.Flags().Lookup("domains"))
|
||||
assert.NotNil(t, cmd.Flags().Lookup("build-command"))
|
||||
assert.NotNil(t, cmd.Flags().Lookup("start-command"))
|
||||
assert.NotNil(t, cmd.Flags().Lookup("docker-image"))
|
||||
assert.NotNil(t, cmd.Flags().Lookup("health-check-enabled"))
|
||||
}
|
||||
|
||||
func TestApplicationsCmd_Structure(t *testing.T) {
|
||||
// Verify parent command exists
|
||||
assert.Equal(t, "applications", applicationsCmd.Use)
|
||||
assert.NotEmpty(t, applicationsCmd.Short)
|
||||
|
||||
// Verify subcommands are registered
|
||||
hasListCmd := false
|
||||
hasGetCmd := false
|
||||
hasUpdateCmd := false
|
||||
|
||||
for _, cmd := range applicationsCmd.Commands() {
|
||||
if cmd.Use == "list" {
|
||||
hasListCmd = true
|
||||
}
|
||||
if cmd.Use == "get <uuid>" {
|
||||
hasGetCmd = true
|
||||
}
|
||||
if cmd.Use == "update <uuid>" {
|
||||
hasUpdateCmd = true
|
||||
}
|
||||
}
|
||||
|
||||
assert.True(t, hasListCmd, "list subcommand should be registered")
|
||||
assert.True(t, hasGetCmd, "get subcommand should be registered")
|
||||
assert.True(t, hasUpdateCmd, "update subcommand should be registered")
|
||||
}
|
||||
|
||||
func TestApplicationsDeleteCmd_Args(t *testing.T) {
|
||||
cmd := deleteApplicationCmd
|
||||
|
||||
// Test with no arguments - should fail
|
||||
err := cmd.Args(cmd, []string{})
|
||||
assert.Error(t, err, "should require exactly 1 argument")
|
||||
|
||||
// Test with correct number of arguments - should pass
|
||||
err = cmd.Args(cmd, []string{"app-uuid-123"})
|
||||
assert.NoError(t, err, "should accept 1 argument")
|
||||
|
||||
// Test with too many arguments - should fail
|
||||
err = cmd.Args(cmd, []string{"uuid1", "uuid2"})
|
||||
assert.Error(t, err, "should not accept more than 1 argument")
|
||||
}
|
||||
|
||||
func TestApplicationsDeleteCmd_Flags(t *testing.T) {
|
||||
cmd := deleteApplicationCmd
|
||||
|
||||
// Verify command structure
|
||||
assert.Equal(t, "delete <uuid>", cmd.Use)
|
||||
assert.NotNil(t, cmd.RunE)
|
||||
assert.NotNil(t, cmd.Args)
|
||||
|
||||
// Verify force flag exists
|
||||
forceFlag := cmd.Flags().Lookup("force")
|
||||
assert.NotNil(t, forceFlag)
|
||||
assert.Equal(t, "false", forceFlag.DefValue)
|
||||
}
|
||||
|
||||
func TestApplicationsCmd_AllSubcommands(t *testing.T) {
|
||||
// Verify all subcommands are registered
|
||||
hasListCmd := false
|
||||
hasGetCmd := false
|
||||
hasUpdateCmd := false
|
||||
hasDeleteCmd := false
|
||||
|
||||
for _, cmd := range applicationsCmd.Commands() {
|
||||
switch cmd.Use {
|
||||
case "list":
|
||||
hasListCmd = true
|
||||
case "get <uuid>":
|
||||
hasGetCmd = true
|
||||
case "update <uuid>":
|
||||
hasUpdateCmd = true
|
||||
case "delete <uuid>":
|
||||
hasDeleteCmd = true
|
||||
}
|
||||
}
|
||||
|
||||
assert.True(t, hasListCmd, "list subcommand should be registered")
|
||||
assert.True(t, hasGetCmd, "get subcommand should be registered")
|
||||
assert.True(t, hasUpdateCmd, "update subcommand should be registered")
|
||||
assert.True(t, hasDeleteCmd, "delete subcommand should be registered")
|
||||
}
|
||||
|
||||
func TestApplicationsStartCmd_Args(t *testing.T) {
|
||||
cmd := startApplicationCmd
|
||||
|
||||
// Test with no arguments - should fail
|
||||
err := cmd.Args(cmd, []string{})
|
||||
assert.Error(t, err, "should require exactly 1 argument")
|
||||
|
||||
// Test with correct number of arguments - should pass
|
||||
err = cmd.Args(cmd, []string{"app-uuid-123"})
|
||||
assert.NoError(t, err, "should accept 1 argument")
|
||||
|
||||
// Test with too many arguments - should fail
|
||||
err = cmd.Args(cmd, []string{"uuid1", "uuid2"})
|
||||
assert.Error(t, err, "should not accept more than 1 argument")
|
||||
}
|
||||
|
||||
func TestApplicationsStartCmd_Structure(t *testing.T) {
|
||||
cmd := startApplicationCmd
|
||||
|
||||
assert.Equal(t, "start <uuid>", cmd.Use)
|
||||
assert.NotNil(t, cmd.RunE)
|
||||
assert.NotNil(t, cmd.Args)
|
||||
|
||||
// Verify aliases exist
|
||||
assert.Contains(t, cmd.Aliases, "deploy")
|
||||
|
||||
// Verify flags exist
|
||||
assert.NotNil(t, cmd.Flags().Lookup("force"))
|
||||
assert.NotNil(t, cmd.Flags().Lookup("instant-deploy"))
|
||||
}
|
||||
|
||||
func TestApplicationsStopCmd_Args(t *testing.T) {
|
||||
cmd := stopApplicationCmd
|
||||
|
||||
// Test with no arguments - should fail
|
||||
err := cmd.Args(cmd, []string{})
|
||||
assert.Error(t, err, "should require exactly 1 argument")
|
||||
|
||||
// Test with correct number of arguments - should pass
|
||||
err = cmd.Args(cmd, []string{"app-uuid-123"})
|
||||
assert.NoError(t, err, "should accept 1 argument")
|
||||
|
||||
// Test with too many arguments - should fail
|
||||
err = cmd.Args(cmd, []string{"uuid1", "uuid2"})
|
||||
assert.Error(t, err, "should not accept more than 1 argument")
|
||||
}
|
||||
|
||||
func TestApplicationsStopCmd_Structure(t *testing.T) {
|
||||
cmd := stopApplicationCmd
|
||||
|
||||
assert.Equal(t, "stop <uuid>", cmd.Use)
|
||||
assert.NotNil(t, cmd.RunE)
|
||||
assert.NotNil(t, cmd.Args)
|
||||
}
|
||||
|
||||
func TestApplicationsRestartCmd_Args(t *testing.T) {
|
||||
cmd := restartApplicationCmd
|
||||
|
||||
// Test with no arguments - should fail
|
||||
err := cmd.Args(cmd, []string{})
|
||||
assert.Error(t, err, "should require exactly 1 argument")
|
||||
|
||||
// Test with correct number of arguments - should pass
|
||||
err = cmd.Args(cmd, []string{"app-uuid-123"})
|
||||
assert.NoError(t, err, "should accept 1 argument")
|
||||
|
||||
// Test with too many arguments - should fail
|
||||
err = cmd.Args(cmd, []string{"uuid1", "uuid2"})
|
||||
assert.Error(t, err, "should not accept more than 1 argument")
|
||||
}
|
||||
|
||||
func TestApplicationsRestartCmd_Structure(t *testing.T) {
|
||||
cmd := restartApplicationCmd
|
||||
|
||||
assert.Equal(t, "restart <uuid>", cmd.Use)
|
||||
assert.NotNil(t, cmd.RunE)
|
||||
assert.NotNil(t, cmd.Args)
|
||||
}
|
||||
|
||||
func TestApplicationsLogsCmd_Args(t *testing.T) {
|
||||
cmd := logsApplicationCmd
|
||||
|
||||
// Test with no arguments - should fail
|
||||
err := cmd.Args(cmd, []string{})
|
||||
assert.Error(t, err, "should require exactly 1 argument")
|
||||
|
||||
// Test with correct number of arguments - should pass
|
||||
err = cmd.Args(cmd, []string{"app-uuid-123"})
|
||||
assert.NoError(t, err, "should accept 1 argument")
|
||||
|
||||
// Test with too many arguments - should fail
|
||||
err = cmd.Args(cmd, []string{"uuid1", "uuid2"})
|
||||
assert.Error(t, err, "should not accept more than 1 argument")
|
||||
}
|
||||
|
||||
func TestApplicationsLogsCmd_Structure(t *testing.T) {
|
||||
cmd := logsApplicationCmd
|
||||
|
||||
assert.Equal(t, "logs <uuid>", cmd.Use)
|
||||
assert.NotNil(t, cmd.RunE)
|
||||
assert.NotNil(t, cmd.Args)
|
||||
|
||||
// Verify flags exist
|
||||
assert.NotNil(t, cmd.Flags().Lookup("lines"))
|
||||
assert.NotNil(t, cmd.Flags().Lookup("follow"))
|
||||
}
|
||||
|
||||
func TestApplicationsCmd_AllLifecycleCommands(t *testing.T) {
|
||||
// Verify all lifecycle subcommands are registered
|
||||
hasStartCmd := false
|
||||
hasStopCmd := false
|
||||
hasRestartCmd := false
|
||||
hasLogsCmd := false
|
||||
|
||||
for _, cmd := range applicationsCmd.Commands() {
|
||||
switch cmd.Use {
|
||||
case "start <uuid>":
|
||||
hasStartCmd = true
|
||||
case "stop <uuid>":
|
||||
hasStopCmd = true
|
||||
case "restart <uuid>":
|
||||
hasRestartCmd = true
|
||||
case "logs <uuid>":
|
||||
hasLogsCmd = true
|
||||
}
|
||||
}
|
||||
|
||||
assert.True(t, hasStartCmd, "start subcommand should be registered")
|
||||
assert.True(t, hasStopCmd, "stop subcommand should be registered")
|
||||
assert.True(t, hasRestartCmd, "restart subcommand should be registered")
|
||||
assert.True(t, hasLogsCmd, "logs subcommand should be registered")
|
||||
}
|
||||
|
||||
func TestApplicationsEnvsCmd_Structure(t *testing.T) {
|
||||
cmd := envsApplicationCmd
|
||||
|
||||
assert.Equal(t, "envs", cmd.Use)
|
||||
assert.NotNil(t, cmd.Commands())
|
||||
assert.Greater(t, len(cmd.Commands()), 0, "envs should have subcommands")
|
||||
}
|
||||
|
||||
func TestApplicationsEnvsListCmd_Args(t *testing.T) {
|
||||
cmd := listEnvsCmd
|
||||
|
||||
// Test with no arguments - should fail
|
||||
err := cmd.Args(cmd, []string{})
|
||||
assert.Error(t, err, "should require exactly 1 argument")
|
||||
|
||||
// Test with correct number of arguments - should pass
|
||||
err = cmd.Args(cmd, []string{"app-uuid-123"})
|
||||
assert.NoError(t, err, "should accept 1 argument")
|
||||
|
||||
// Test with too many arguments - should fail
|
||||
err = cmd.Args(cmd, []string{"uuid1", "uuid2"})
|
||||
assert.Error(t, err, "should not accept more than 1 argument")
|
||||
}
|
||||
|
||||
func TestApplicationsEnvsListCmd_Structure(t *testing.T) {
|
||||
cmd := listEnvsCmd
|
||||
|
||||
assert.Equal(t, "list <uuid>", cmd.Use)
|
||||
assert.NotNil(t, cmd.RunE)
|
||||
assert.NotNil(t, cmd.Args)
|
||||
}
|
||||
|
||||
func TestApplicationsCmd_HasEnvsSubcommand(t *testing.T) {
|
||||
// Verify envs subcommand is registered
|
||||
hasEnvsCmd := false
|
||||
|
||||
for _, cmd := range applicationsCmd.Commands() {
|
||||
if cmd.Use == "envs" {
|
||||
hasEnvsCmd = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
assert.True(t, hasEnvsCmd, "envs subcommand should be registered")
|
||||
}
|
||||
|
||||
func TestApplicationsEnvsCreateCmd_Args(t *testing.T) {
|
||||
cmd := createEnvCmd
|
||||
|
||||
// Test with no arguments - should fail
|
||||
err := cmd.Args(cmd, []string{})
|
||||
assert.Error(t, err, "should require exactly 1 argument")
|
||||
|
||||
// Test with correct number of arguments - should pass
|
||||
err = cmd.Args(cmd, []string{"app-uuid-123"})
|
||||
assert.NoError(t, err, "should accept 1 argument")
|
||||
|
||||
// Test with too many arguments - should fail
|
||||
err = cmd.Args(cmd, []string{"uuid1", "uuid2"})
|
||||
assert.Error(t, err, "should not accept more than 1 argument")
|
||||
}
|
||||
|
||||
func TestApplicationsEnvsCreateCmd_Structure(t *testing.T) {
|
||||
cmd := createEnvCmd
|
||||
|
||||
assert.Equal(t, "create <uuid>", cmd.Use)
|
||||
assert.NotNil(t, cmd.RunE)
|
||||
assert.NotNil(t, cmd.Args)
|
||||
|
||||
// Verify flags exist
|
||||
assert.NotNil(t, cmd.Flags().Lookup("key"))
|
||||
assert.NotNil(t, cmd.Flags().Lookup("value"))
|
||||
assert.NotNil(t, cmd.Flags().Lookup("build-time"))
|
||||
assert.NotNil(t, cmd.Flags().Lookup("preview"))
|
||||
assert.NotNil(t, cmd.Flags().Lookup("is-literal"))
|
||||
assert.NotNil(t, cmd.Flags().Lookup("is-multiline"))
|
||||
}
|
||||
|
||||
func TestApplicationsEnvsUpdateCmd_Args(t *testing.T) {
|
||||
cmd := updateEnvCmd
|
||||
|
||||
// Test with no arguments - should fail
|
||||
err := cmd.Args(cmd, []string{})
|
||||
assert.Error(t, err, "should require exactly 2 arguments")
|
||||
|
||||
// Test with 1 argument - should fail
|
||||
err = cmd.Args(cmd, []string{"app-uuid-123"})
|
||||
assert.Error(t, err, "should require exactly 2 arguments")
|
||||
|
||||
// Test with correct number of arguments - should pass
|
||||
err = cmd.Args(cmd, []string{"app-uuid-123", "env-uuid-456"})
|
||||
assert.NoError(t, err, "should accept 2 arguments")
|
||||
|
||||
// Test with too many arguments - should fail
|
||||
err = cmd.Args(cmd, []string{"uuid1", "uuid2", "uuid3"})
|
||||
assert.Error(t, err, "should not accept more than 2 arguments")
|
||||
}
|
||||
|
||||
func TestApplicationsEnvsUpdateCmd_Structure(t *testing.T) {
|
||||
cmd := updateEnvCmd
|
||||
|
||||
assert.Equal(t, "update <app_uuid> <env_uuid>", cmd.Use)
|
||||
assert.NotNil(t, cmd.RunE)
|
||||
assert.NotNil(t, cmd.Args)
|
||||
|
||||
// Verify flags exist
|
||||
assert.NotNil(t, cmd.Flags().Lookup("key"))
|
||||
assert.NotNil(t, cmd.Flags().Lookup("value"))
|
||||
assert.NotNil(t, cmd.Flags().Lookup("build-time"))
|
||||
assert.NotNil(t, cmd.Flags().Lookup("preview"))
|
||||
assert.NotNil(t, cmd.Flags().Lookup("is-literal"))
|
||||
assert.NotNil(t, cmd.Flags().Lookup("is-multiline"))
|
||||
}
|
||||
|
||||
func TestApplicationsEnvsDeleteCmd_Args(t *testing.T) {
|
||||
cmd := deleteEnvCmd
|
||||
|
||||
// Test with no arguments - should fail
|
||||
err := cmd.Args(cmd, []string{})
|
||||
assert.Error(t, err, "should require exactly 2 arguments")
|
||||
|
||||
// Test with 1 argument - should fail
|
||||
err = cmd.Args(cmd, []string{"app-uuid-123"})
|
||||
assert.Error(t, err, "should require exactly 2 arguments")
|
||||
|
||||
// Test with correct number of arguments - should pass
|
||||
err = cmd.Args(cmd, []string{"app-uuid-123", "env-uuid-456"})
|
||||
assert.NoError(t, err, "should accept 2 arguments")
|
||||
|
||||
// Test with too many arguments - should fail
|
||||
err = cmd.Args(cmd, []string{"uuid1", "uuid2", "uuid3"})
|
||||
assert.Error(t, err, "should not accept more than 2 arguments")
|
||||
}
|
||||
|
||||
func TestApplicationsEnvsDeleteCmd_Structure(t *testing.T) {
|
||||
cmd := deleteEnvCmd
|
||||
|
||||
assert.Equal(t, "delete <app_uuid> <env_uuid>", cmd.Use)
|
||||
assert.NotNil(t, cmd.RunE)
|
||||
assert.NotNil(t, cmd.Args)
|
||||
|
||||
// Verify flags exist
|
||||
assert.NotNil(t, cmd.Flags().Lookup("force"))
|
||||
}
|
||||
|
||||
func TestApplicationsEnvsImportCmd_Args(t *testing.T) {
|
||||
cmd := importEnvCmd
|
||||
|
||||
// Test with no arguments - should fail
|
||||
err := cmd.Args(cmd, []string{})
|
||||
assert.Error(t, err, "should require exactly 1 argument")
|
||||
|
||||
// Test with correct number of arguments - should pass
|
||||
err = cmd.Args(cmd, []string{"app-uuid-123"})
|
||||
assert.NoError(t, err, "should accept 1 argument")
|
||||
|
||||
// Test with too many arguments - should fail
|
||||
err = cmd.Args(cmd, []string{"uuid1", "uuid2"})
|
||||
assert.Error(t, err, "should not accept more than 1 argument")
|
||||
}
|
||||
|
||||
func TestApplicationsEnvsImportCmd_Structure(t *testing.T) {
|
||||
cmd := importEnvCmd
|
||||
|
||||
assert.Equal(t, "import <uuid>", cmd.Use)
|
||||
assert.NotNil(t, cmd.RunE)
|
||||
assert.NotNil(t, cmd.Args)
|
||||
|
||||
// Verify flags exist
|
||||
assert.NotNil(t, cmd.Flags().Lookup("file"))
|
||||
assert.NotNil(t, cmd.Flags().Lookup("build-time"))
|
||||
assert.NotNil(t, cmd.Flags().Lookup("preview"))
|
||||
assert.NotNil(t, cmd.Flags().Lookup("is-literal"))
|
||||
}
|
||||
+1012
File diff suppressed because it is too large
Load Diff
+327
-49
@@ -1,79 +1,357 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
|
||||
"github.com/coollabsio/coolify-cli/internal/output"
|
||||
"github.com/coollabsio/coolify-cli/internal/service"
|
||||
"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",
|
||||
}
|
||||
|
||||
// DeployResultDisplay represents a deploy result for table display
|
||||
type DeployResultDisplay struct {
|
||||
Message string `json:"message"`
|
||||
DeploymentUUID string `json:"deployment_uuid"`
|
||||
}
|
||||
|
||||
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)
|
||||
Args: exactArgs(1, "<uuid>"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := context.Background()
|
||||
uuid := args[0]
|
||||
|
||||
client, err := getAPIClient(cmd)
|
||||
if err != nil {
|
||||
log.Println(err)
|
||||
return
|
||||
return fmt.Errorf("failed to get API client: %w", err)
|
||||
}
|
||||
|
||||
if PrettyMode {
|
||||
var prettyJSON bytes.Buffer
|
||||
err := json.Indent(&prettyJSON, []byte(data), "", "\t")
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
force, _ := cmd.Flags().GetBool("force")
|
||||
deploySvc := service.NewDeploymentService(client)
|
||||
result, err := deploySvc.Deploy(ctx, uuid, force)
|
||||
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([]DeployResultDisplay, len(result.Deployments))
|
||||
for i, dep := range result.Deployments {
|
||||
displays[i] = DeployResultDisplay{
|
||||
Message: dep.Message,
|
||||
DeploymentUUID: dep.DeploymentUUID,
|
||||
}
|
||||
}
|
||||
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
|
||||
return formatter.Format(displays)
|
||||
}
|
||||
|
||||
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()
|
||||
|
||||
return formatter.Format(result)
|
||||
},
|
||||
}
|
||||
|
||||
// TODO deployByTagCmd
|
||||
var deployByNameCmd = &cobra.Command{
|
||||
Use: "name <name>",
|
||||
Short: "Deploy by resource name",
|
||||
Args: exactArgs(1, "<uuid>"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := context.Background()
|
||||
name := args[0]
|
||||
|
||||
client, err := getAPIClient(cmd)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get API client: %w", 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
|
||||
force, _ := cmd.Flags().GetBool("force")
|
||||
deploySvc := service.NewDeploymentService(client)
|
||||
result, err := deploySvc.Deploy(ctx, matchedUUID, force)
|
||||
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([]DeployResultDisplay, len(result.Deployments))
|
||||
for i, dep := range result.Deployments {
|
||||
displays[i] = DeployResultDisplay{
|
||||
Message: dep.Message,
|
||||
DeploymentUUID: dep.DeploymentUUID,
|
||||
}
|
||||
}
|
||||
return formatter.Format(displays)
|
||||
}
|
||||
|
||||
return formatter.Format(result)
|
||||
},
|
||||
}
|
||||
|
||||
var deployBatchCmd = &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: exactArgs(1, "<uuid>"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := context.Background()
|
||||
namesStr := args[0]
|
||||
|
||||
client, err := getAPIClient(cmd)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get API client: %w", 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
|
||||
force, _ := cmd.Flags().GetBool("force")
|
||||
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, uuid, force)
|
||||
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
|
||||
},
|
||||
}
|
||||
|
||||
var listDeploymentsCmd = &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List all deployments",
|
||||
Long: `List all currently running deployments across all resources.`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := context.Background()
|
||||
|
||||
client, err := 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)
|
||||
},
|
||||
}
|
||||
|
||||
var getDeploymentCmd = &cobra.Command{
|
||||
Use: "get <uuid>",
|
||||
Short: "Get deployment details by UUID",
|
||||
Long: `Get detailed information about a specific deployment by its UUID.`,
|
||||
Args: exactArgs(1, "<uuid>"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := context.Background()
|
||||
uuid := args[0]
|
||||
|
||||
client, err := 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)
|
||||
},
|
||||
}
|
||||
|
||||
var cancelDeploymentCmd = &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: exactArgs(1, "<uuid>"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := context.Background()
|
||||
uuid := args[0]
|
||||
|
||||
client, err := 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 cancel deployment %s? (yes/no): ", uuid)
|
||||
fmt.Scanln(&response)
|
||||
|
||||
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, _ := 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(result)
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
deployByUuidCmd.Flags().Bool("force", false, "Force deployment")
|
||||
deployByNameCmd.Flags().Bool("force", false, "Force deployment")
|
||||
deployBatchCmd.Flags().Bool("force", false, "Force deployment")
|
||||
cancelDeploymentCmd.Flags().BoolP("force", "f", false, "Skip confirmation prompt")
|
||||
|
||||
rootCmd.AddCommand(deployCmd)
|
||||
deployCmd.AddCommand(deployByUuidCmd)
|
||||
deployCmd.AddCommand(deployByNameCmd)
|
||||
deployCmd.AddCommand(deployBatchCmd)
|
||||
deployCmd.AddCommand(listDeploymentsCmd)
|
||||
deployCmd.AddCommand(getDeploymentCmd)
|
||||
deployCmd.AddCommand(cancelDeploymentCmd)
|
||||
}
|
||||
|
||||
+93
@@ -0,0 +1,93 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/cobra/doc"
|
||||
)
|
||||
|
||||
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, args []string) error {
|
||||
outputDir, _ := cmd.Flags().GetString("output-dir")
|
||||
|
||||
// Create output directory if it doesn't exist
|
||||
if err := os.MkdirAll(outputDir, 0755); 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 " + Version,
|
||||
}
|
||||
|
||||
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",
|
||||
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, args []string) error {
|
||||
outputDir, _ := cmd.Flags().GetString("output-dir")
|
||||
|
||||
// Create output directory if it doesn't exist
|
||||
if err := os.MkdirAll(outputDir, 0755); 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
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
rootCmd.AddCommand(docsCmd)
|
||||
docsCmd.AddCommand(manCmd)
|
||||
docsCmd.AddCommand(markdownCmd)
|
||||
|
||||
manCmd.Flags().StringP("output-dir", "o", "./man", "Output directory for man pages")
|
||||
markdownCmd.Flags().StringP("output-dir", "o", "./docs", "Output directory for markdown files")
|
||||
}
|
||||
+26
-45
@@ -1,68 +1,49 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"github.com/coollabsio/coolify-cli/internal/output"
|
||||
"github.com/coollabsio/coolify-cli/internal/service"
|
||||
"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",
|
||||
Use: "domain",
|
||||
Aliases: []string{"domains"},
|
||||
Short: "Domain related commands",
|
||||
Long: `List all domains configured across your Coolify resources.`,
|
||||
}
|
||||
|
||||
var listDomainsCmd = &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List all domains",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
CheckDefaultThings(nil)
|
||||
data, err := Fetch("domains")
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := context.Background()
|
||||
|
||||
client, err := getAPIClient(cmd)
|
||||
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
|
||||
return fmt.Errorf("failed to get API client: %w", err)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
domainSvc := service.NewDomainService(client)
|
||||
domains, err := domainSvc.List(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to list domains: %w", err)
|
||||
}
|
||||
w.Flush()
|
||||
|
||||
format, _ := cmd.Flags().GetString("format")
|
||||
formatter, err := output.NewFormatter(format, output.Options{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return formatter.Format(domains)
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
// rootCmd.AddCommand(domainsCmd)
|
||||
// domainsCmd.AddCommand(listDomainsCmd)
|
||||
|
||||
rootCmd.AddCommand(domainsCmd)
|
||||
domainsCmd.AddCommand(listDomainsCmd)
|
||||
}
|
||||
|
||||
+393
@@ -0,0 +1,393 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/coollabsio/coolify-cli/internal/models"
|
||||
"github.com/coollabsio/coolify-cli/internal/output"
|
||||
"github.com/coollabsio/coolify-cli/internal/service"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var githubCmd = &cobra.Command{
|
||||
Use: "github",
|
||||
Aliases: []string{"gh", "github-app", "github-apps"},
|
||||
Short: "Manage GitHub App integrations",
|
||||
Long: `Manage GitHub App integrations for private repository deployments.`,
|
||||
}
|
||||
|
||||
var listGitHubAppsCmd = &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List all GitHub App integrations",
|
||||
Long: `List all GitHub App integrations configured in your Coolify instance.`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := context.Background()
|
||||
|
||||
client, err := getAPIClient(cmd)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get API client: %w", err)
|
||||
}
|
||||
|
||||
svc := service.NewGitHubAppService(client)
|
||||
apps, err := svc.List(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to list GitHub Apps: %w", err)
|
||||
}
|
||||
|
||||
format, _ := cmd.Flags().GetString("format")
|
||||
formatter, err := output.NewFormatter(format, output.Options{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return formatter.Format(apps)
|
||||
},
|
||||
}
|
||||
|
||||
var getGitHubAppCmd = &cobra.Command{
|
||||
Use: "get <app_uuid>",
|
||||
Short: "Get GitHub App details by UUID",
|
||||
Long: `Get detailed information about a specific GitHub App integration.`,
|
||||
Args: exactArgs(1, "<app_uuid>"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := context.Background()
|
||||
appUUID := args[0]
|
||||
|
||||
client, err := getAPIClient(cmd)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get API client: %w", err)
|
||||
}
|
||||
|
||||
svc := service.NewGitHubAppService(client)
|
||||
app, err := svc.Get(ctx, appUUID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get GitHub App: %w", err)
|
||||
}
|
||||
|
||||
format, _ := cmd.Flags().GetString("format")
|
||||
formatter, err := output.NewFormatter(format, output.Options{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return formatter.Format(app)
|
||||
},
|
||||
}
|
||||
|
||||
var createGitHubAppCmd = &cobra.Command{
|
||||
Use: "create",
|
||||
Short: "Create a GitHub App integration",
|
||||
Long: `Create a new GitHub App integration. This allows you to deploy private repositories from GitHub.
|
||||
|
||||
Required flags: --name, --api-url, --html-url, --app-id, --installation-id, --client-id, --client-secret, --private-key-uuid
|
||||
|
||||
Example: 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 "secret123" --private-key-uuid "abc-123-def-456"`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := context.Background()
|
||||
|
||||
client, err := getAPIClient(cmd)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get API client: %w", err)
|
||||
}
|
||||
|
||||
name, _ := cmd.Flags().GetString("name")
|
||||
apiURL, _ := cmd.Flags().GetString("api-url")
|
||||
htmlURL, _ := cmd.Flags().GetString("html-url")
|
||||
appID, _ := cmd.Flags().GetInt("app-id")
|
||||
installationID, _ := cmd.Flags().GetInt("installation-id")
|
||||
clientID, _ := cmd.Flags().GetString("client-id")
|
||||
clientSecret, _ := cmd.Flags().GetString("client-secret")
|
||||
privateKeyUUID, _ := cmd.Flags().GetString("private-key-uuid")
|
||||
|
||||
req := &models.GitHubAppCreateRequest{
|
||||
Name: name,
|
||||
APIURL: apiURL,
|
||||
HTMLURL: htmlURL,
|
||||
AppID: appID,
|
||||
InstallationID: installationID,
|
||||
ClientID: clientID,
|
||||
ClientSecret: clientSecret,
|
||||
PrivateKeyUUID: privateKeyUUID,
|
||||
}
|
||||
|
||||
// Optional fields
|
||||
if cmd.Flags().Changed("organization") {
|
||||
org, _ := cmd.Flags().GetString("organization")
|
||||
req.Organization = &org
|
||||
}
|
||||
if cmd.Flags().Changed("custom-user") {
|
||||
user, _ := cmd.Flags().GetString("custom-user")
|
||||
req.CustomUser = &user
|
||||
}
|
||||
if cmd.Flags().Changed("custom-port") {
|
||||
port, _ := cmd.Flags().GetInt("custom-port")
|
||||
req.CustomPort = &port
|
||||
}
|
||||
if cmd.Flags().Changed("webhook-secret") {
|
||||
secret, _ := cmd.Flags().GetString("webhook-secret")
|
||||
req.WebhookSecret = &secret
|
||||
}
|
||||
if cmd.Flags().Changed("system-wide") {
|
||||
systemWide, _ := cmd.Flags().GetBool("system-wide")
|
||||
req.IsSystemWide = &systemWide
|
||||
}
|
||||
|
||||
svc := service.NewGitHubAppService(client)
|
||||
app, err := svc.Create(ctx, req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create GitHub App: %w", err)
|
||||
}
|
||||
|
||||
format, _ := cmd.Flags().GetString("format")
|
||||
formatter, err := output.NewFormatter(format, output.Options{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return formatter.Format(app)
|
||||
},
|
||||
}
|
||||
|
||||
var updateGitHubAppCmd = &cobra.Command{
|
||||
Use: "update <app_uuid>",
|
||||
Short: "Update a GitHub App integration",
|
||||
Long: `Update an existing GitHub App integration. Provide the app UUID and the fields you want to update.`,
|
||||
Args: exactArgs(1, "<app_uuid>"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := context.Background()
|
||||
appUUID := args[0]
|
||||
|
||||
client, err := getAPIClient(cmd)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get API client: %w", err)
|
||||
}
|
||||
|
||||
req := &models.GitHubAppUpdateRequest{}
|
||||
|
||||
// Update only fields that were explicitly provided
|
||||
if cmd.Flags().Changed("name") {
|
||||
name, _ := cmd.Flags().GetString("name")
|
||||
req.Name = &name
|
||||
}
|
||||
if cmd.Flags().Changed("organization") {
|
||||
org, _ := cmd.Flags().GetString("organization")
|
||||
req.Organization = &org
|
||||
}
|
||||
if cmd.Flags().Changed("api-url") {
|
||||
apiURL, _ := cmd.Flags().GetString("api-url")
|
||||
req.APIURL = &apiURL
|
||||
}
|
||||
if cmd.Flags().Changed("html-url") {
|
||||
htmlURL, _ := cmd.Flags().GetString("html-url")
|
||||
req.HTMLURL = &htmlURL
|
||||
}
|
||||
if cmd.Flags().Changed("custom-user") {
|
||||
user, _ := cmd.Flags().GetString("custom-user")
|
||||
req.CustomUser = &user
|
||||
}
|
||||
if cmd.Flags().Changed("custom-port") {
|
||||
port, _ := cmd.Flags().GetInt("custom-port")
|
||||
req.CustomPort = &port
|
||||
}
|
||||
if cmd.Flags().Changed("app-id") {
|
||||
id, _ := cmd.Flags().GetInt("app-id")
|
||||
req.AppID = &id
|
||||
}
|
||||
if cmd.Flags().Changed("installation-id") {
|
||||
id, _ := cmd.Flags().GetInt("installation-id")
|
||||
req.InstallationID = &id
|
||||
}
|
||||
if cmd.Flags().Changed("client-id") {
|
||||
clientID, _ := cmd.Flags().GetString("client-id")
|
||||
req.ClientID = &clientID
|
||||
}
|
||||
if cmd.Flags().Changed("client-secret") {
|
||||
clientSecret, _ := cmd.Flags().GetString("client-secret")
|
||||
req.ClientSecret = &clientSecret
|
||||
}
|
||||
if cmd.Flags().Changed("webhook-secret") {
|
||||
secret, _ := cmd.Flags().GetString("webhook-secret")
|
||||
req.WebhookSecret = &secret
|
||||
}
|
||||
if cmd.Flags().Changed("private-key-uuid") {
|
||||
uuid, _ := cmd.Flags().GetString("private-key-uuid")
|
||||
req.PrivateKeyUUID = &uuid
|
||||
}
|
||||
if cmd.Flags().Changed("system-wide") {
|
||||
systemWide, _ := cmd.Flags().GetBool("system-wide")
|
||||
req.IsSystemWide = &systemWide
|
||||
}
|
||||
|
||||
svc := service.NewGitHubAppService(client)
|
||||
err = svc.Update(ctx, appUUID, req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to update GitHub App: %w", err)
|
||||
}
|
||||
|
||||
fmt.Println("GitHub App updated successfully")
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var deleteGitHubAppCmd = &cobra.Command{
|
||||
Use: "delete <app_uuid>",
|
||||
Short: "Delete a GitHub App integration",
|
||||
Long: `Delete a GitHub App integration. The app must not be used by any applications.`,
|
||||
Args: exactArgs(1, "<app_uuid>"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := context.Background()
|
||||
appUUID := args[0]
|
||||
|
||||
client, err := 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 GitHub App %s? This cannot be undone. (yes/no): ", appUUID)
|
||||
fmt.Scanln(&response)
|
||||
|
||||
if response != "yes" && response != "y" {
|
||||
fmt.Println("Delete cancelled.")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
svc := service.NewGitHubAppService(client)
|
||||
err = svc.Delete(ctx, appUUID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete GitHub App: %w", err)
|
||||
}
|
||||
|
||||
fmt.Println("GitHub App deleted successfully")
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var listRepositoriesCmd = &cobra.Command{
|
||||
Use: "repos <app_uuid>",
|
||||
Short: "List repositories accessible by a GitHub App",
|
||||
Long: `List all repositories that are accessible by the specified GitHub App.`,
|
||||
Args: exactArgs(1, "<app_uuid>"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := context.Background()
|
||||
appUUID := args[0]
|
||||
|
||||
client, err := getAPIClient(cmd)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get API client: %w", err)
|
||||
}
|
||||
|
||||
svc := service.NewGitHubAppService(client)
|
||||
repos, err := svc.ListRepositories(ctx, appUUID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to list repositories: %w", err)
|
||||
}
|
||||
|
||||
format, _ := cmd.Flags().GetString("format")
|
||||
formatter, err := output.NewFormatter(format, output.Options{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return formatter.Format(repos)
|
||||
},
|
||||
}
|
||||
|
||||
var listBranchesCmd = &cobra.Command{
|
||||
Use: "branches <app_uuid> <owner/repo>",
|
||||
Short: "List branches for a repository",
|
||||
Long: `List all branches for a specific repository. Provide the app UUID and repository in owner/repo format.
|
||||
|
||||
Example: coolify github branches abc-123-def owner/repository`,
|
||||
Args: exactArgs(2, "<app_uuid> <owner/repo>"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := context.Background()
|
||||
appUUID := args[0]
|
||||
|
||||
// Parse owner/repo
|
||||
ownerRepo := args[1]
|
||||
parts := splitOwnerRepo(ownerRepo)
|
||||
if len(parts) != 2 {
|
||||
return fmt.Errorf("invalid repository format. Expected 'owner/repo', got '%s'", ownerRepo)
|
||||
}
|
||||
owner, repo := parts[0], parts[1]
|
||||
|
||||
client, err := getAPIClient(cmd)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get API client: %w", err)
|
||||
}
|
||||
|
||||
svc := service.NewGitHubAppService(client)
|
||||
branches, err := svc.ListBranches(ctx, appUUID, owner, repo)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to list branches: %w", err)
|
||||
}
|
||||
|
||||
format, _ := cmd.Flags().GetString("format")
|
||||
formatter, err := output.NewFormatter(format, output.Options{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return formatter.Format(branches)
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
// Create command flags
|
||||
createGitHubAppCmd.Flags().String("name", "", "GitHub App name (required)")
|
||||
createGitHubAppCmd.Flags().String("organization", "", "GitHub organization")
|
||||
createGitHubAppCmd.Flags().String("api-url", "", "GitHub API URL (required, e.g., https://api.github.com)")
|
||||
createGitHubAppCmd.Flags().String("html-url", "", "GitHub HTML URL (required, e.g., https://github.com)")
|
||||
createGitHubAppCmd.Flags().String("custom-user", "", "Custom user for SSH (default: git)")
|
||||
createGitHubAppCmd.Flags().Int("custom-port", 0, "Custom port for SSH (default: 22)")
|
||||
createGitHubAppCmd.Flags().Int("app-id", 0, "GitHub App ID (required)")
|
||||
createGitHubAppCmd.Flags().Int("installation-id", 0, "GitHub Installation ID (required)")
|
||||
createGitHubAppCmd.Flags().String("client-id", "", "GitHub OAuth Client ID (required)")
|
||||
createGitHubAppCmd.Flags().String("client-secret", "", "GitHub OAuth Client Secret (required)")
|
||||
createGitHubAppCmd.Flags().String("webhook-secret", "", "GitHub Webhook Secret")
|
||||
createGitHubAppCmd.Flags().String("private-key-uuid", "", "UUID of existing private key (required)")
|
||||
createGitHubAppCmd.Flags().Bool("system-wide", false, "Is this app system-wide (cloud only)")
|
||||
|
||||
createGitHubAppCmd.MarkFlagRequired("name")
|
||||
createGitHubAppCmd.MarkFlagRequired("api-url")
|
||||
createGitHubAppCmd.MarkFlagRequired("html-url")
|
||||
createGitHubAppCmd.MarkFlagRequired("app-id")
|
||||
createGitHubAppCmd.MarkFlagRequired("installation-id")
|
||||
createGitHubAppCmd.MarkFlagRequired("client-id")
|
||||
createGitHubAppCmd.MarkFlagRequired("client-secret")
|
||||
createGitHubAppCmd.MarkFlagRequired("private-key-uuid")
|
||||
|
||||
// Update command flags (all optional)
|
||||
updateGitHubAppCmd.Flags().String("name", "", "GitHub App name")
|
||||
updateGitHubAppCmd.Flags().String("organization", "", "GitHub organization")
|
||||
updateGitHubAppCmd.Flags().String("api-url", "", "GitHub API URL")
|
||||
updateGitHubAppCmd.Flags().String("html-url", "", "GitHub HTML URL")
|
||||
updateGitHubAppCmd.Flags().String("custom-user", "", "Custom user for SSH")
|
||||
updateGitHubAppCmd.Flags().Int("custom-port", 0, "Custom port for SSH")
|
||||
updateGitHubAppCmd.Flags().Int("app-id", 0, "GitHub App ID")
|
||||
updateGitHubAppCmd.Flags().Int("installation-id", 0, "GitHub Installation ID")
|
||||
updateGitHubAppCmd.Flags().String("client-id", "", "GitHub OAuth Client ID")
|
||||
updateGitHubAppCmd.Flags().String("client-secret", "", "GitHub OAuth Client Secret")
|
||||
updateGitHubAppCmd.Flags().String("webhook-secret", "", "GitHub Webhook Secret")
|
||||
updateGitHubAppCmd.Flags().String("private-key-uuid", "", "UUID of private key")
|
||||
updateGitHubAppCmd.Flags().Bool("system-wide", false, "Is this app system-wide")
|
||||
|
||||
// Delete command flags
|
||||
deleteGitHubAppCmd.Flags().BoolP("force", "f", false, "Skip confirmation prompt")
|
||||
|
||||
rootCmd.AddCommand(githubCmd)
|
||||
githubCmd.AddCommand(listGitHubAppsCmd)
|
||||
githubCmd.AddCommand(getGitHubAppCmd)
|
||||
githubCmd.AddCommand(createGitHubAppCmd)
|
||||
githubCmd.AddCommand(updateGitHubAppCmd)
|
||||
githubCmd.AddCommand(deleteGitHubAppCmd)
|
||||
githubCmd.AddCommand(listRepositoriesCmd)
|
||||
githubCmd.AddCommand(listBranchesCmd)
|
||||
}
|
||||
+64
-59
@@ -2,6 +2,7 @@ package cmd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
@@ -9,26 +10,37 @@ import (
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
var instancesCmd = &cobra.Command{
|
||||
Use: "instances",
|
||||
Short: "Coolify instance related commands.",
|
||||
var contextCmd = &cobra.Command{
|
||||
Use: "context",
|
||||
Short: "Manage Coolify contexts (instance configurations)",
|
||||
Long: `Manage Coolify contexts. A context contains the configuration (URL and token) for a Coolify instance.`,
|
||||
}
|
||||
|
||||
var instanceVersionCmd = &cobra.Command{
|
||||
var contextVersionCmd = &cobra.Command{
|
||||
Use: "version",
|
||||
Short: "Get instance version.",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
data, err := Fetch("version")
|
||||
Short: "Get current context's Coolify version",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := context.Background()
|
||||
|
||||
// Get API client
|
||||
client, err := getAPIClient(cmd)
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
return fmt.Errorf("failed to get API client: %w", err)
|
||||
}
|
||||
fmt.Println(data)
|
||||
|
||||
// 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
|
||||
},
|
||||
}
|
||||
var listInstancesCmd = &cobra.Command{
|
||||
var listContextsCmd = &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List all Coolify instances.",
|
||||
Short: "List all configured contexts",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
instances := viper.Get("instances").([]interface{})
|
||||
|
||||
@@ -74,20 +86,21 @@ var listInstancesCmd = &cobra.Command{
|
||||
fmt.Println("\nNote: Use -s to show sensitive information.")
|
||||
},
|
||||
}
|
||||
var addInstanceCmd = &cobra.Command{
|
||||
Use: "add",
|
||||
Example: `add <instanceName> <fqdn> <token>`,
|
||||
Args: cobra.ExactArgs(3),
|
||||
Short: "Add a Coolify instance.",
|
||||
var addContextCmd = &cobra.Command{
|
||||
Use: "add <name> <url> <token>",
|
||||
Example: `context add myserver https://coolify.example.com your-api-token`,
|
||||
Args: exactArgs(3, "<name> <url> <token>"),
|
||||
Short: "Add a new context",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
Name := args[0]
|
||||
Host := args[1]
|
||||
Token := args[2]
|
||||
force, _ := cmd.Flags().GetBool("force")
|
||||
instances := viper.Get("instances").([]interface{})
|
||||
for _, instance := range instances {
|
||||
instanceMap := instance.(map[string]interface{})
|
||||
if instanceMap["name"] == Name {
|
||||
if Force {
|
||||
if force {
|
||||
instanceMap["token"] = Token
|
||||
if SetDefaultInstance {
|
||||
for _, instance := range instances {
|
||||
@@ -104,7 +117,7 @@ var addInstanceCmd = &cobra.Command{
|
||||
return
|
||||
}
|
||||
fmt.Printf("%s already exists. \n", Name)
|
||||
fmt.Println("\nNote: Use -f to force overwrite.")
|
||||
fmt.Println("\nNote: Use --force to force overwrite.")
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -124,14 +137,14 @@ var addInstanceCmd = &cobra.Command{
|
||||
}
|
||||
viper.Set("instances", instances)
|
||||
viper.WriteConfig()
|
||||
listInstancesCmd.Run(cmd, args)
|
||||
listContextsCmd.Run(cmd, args)
|
||||
},
|
||||
}
|
||||
var removeInstanceCmd = &cobra.Command{
|
||||
Use: "remove",
|
||||
Example: `remove <instanceName>`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
Short: "Remove a Coolify instance.",
|
||||
var deleteContextCmd = &cobra.Command{
|
||||
Use: "delete <name>",
|
||||
Example: `context delete myserver`,
|
||||
Args: exactArgs(1, "<name>"),
|
||||
Short: "Delete a context",
|
||||
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
Name := args[0]
|
||||
@@ -158,18 +171,11 @@ var removeInstanceCmd = &cobra.Command{
|
||||
fmt.Printf("%s not found. \n", Name)
|
||||
},
|
||||
}
|
||||
var setCmd = &cobra.Command{
|
||||
Use: "set",
|
||||
Args: cobra.ExactArgs(2),
|
||||
Short: "Set default instance or token.",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
},
|
||||
}
|
||||
var setTokenCmd = &cobra.Command{
|
||||
Use: "token",
|
||||
Example: `set token <instanceName> "<token>"`,
|
||||
Args: cobra.ExactArgs(2),
|
||||
Short: "Set token for the given Coolify instance.",
|
||||
Use: "set-token <name> <token>",
|
||||
Example: `context set-token myserver your-new-api-token`,
|
||||
Args: exactArgs(2, "<name> <token>"),
|
||||
Short: "Update the API token for a context",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
Name = args[0]
|
||||
Token = args[1]
|
||||
@@ -194,14 +200,14 @@ var setTokenCmd = &cobra.Command{
|
||||
}
|
||||
viper.Set("instances", instances)
|
||||
viper.WriteConfig()
|
||||
listInstancesCmd.Run(cmd, args)
|
||||
listContextsCmd.Run(cmd, args)
|
||||
},
|
||||
}
|
||||
var setDefaultCmd = &cobra.Command{
|
||||
Use: "default",
|
||||
Example: `set default <instanceName>`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
Short: "Set the default Coolify instance.",
|
||||
var useContextCmd = &cobra.Command{
|
||||
Use: "use <name>",
|
||||
Example: `context use myserver`,
|
||||
Args: exactArgs(1, "<name>"),
|
||||
Short: "Switch to a different context (set as default)",
|
||||
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
Name := args[0]
|
||||
@@ -228,14 +234,14 @@ var setDefaultCmd = &cobra.Command{
|
||||
}
|
||||
viper.Set("instances", instances)
|
||||
viper.WriteConfig()
|
||||
listInstancesCmd.Run(cmd, args)
|
||||
listContextsCmd.Run(cmd, args)
|
||||
},
|
||||
}
|
||||
var getInstanceCmd = &cobra.Command{
|
||||
Use: "get",
|
||||
Example: `config get <instanceName>`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
Short: "Get a Coolify instance.",
|
||||
var getContextCmd = &cobra.Command{
|
||||
Use: "get <name>",
|
||||
Example: `context get myserver`,
|
||||
Args: exactArgs(1, "<name>"),
|
||||
Short: "Get details of a specific context",
|
||||
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
Name := args[0]
|
||||
@@ -291,16 +297,15 @@ var getInstanceCmd = &cobra.Command{
|
||||
}
|
||||
|
||||
func init() {
|
||||
addInstanceCmd.Flags().BoolVarP(&SetDefaultInstance, "default", "d", false, "Set default instance")
|
||||
|
||||
rootCmd.AddCommand(instancesCmd)
|
||||
instancesCmd.AddCommand(instanceVersionCmd)
|
||||
instancesCmd.AddCommand(listInstancesCmd)
|
||||
instancesCmd.AddCommand(addInstanceCmd)
|
||||
instancesCmd.AddCommand(removeInstanceCmd)
|
||||
instancesCmd.AddCommand(setCmd)
|
||||
instancesCmd.AddCommand(getInstanceCmd)
|
||||
setCmd.AddCommand(setTokenCmd)
|
||||
setCmd.AddCommand(setDefaultCmd)
|
||||
addContextCmd.Flags().BoolVarP(&SetDefaultInstance, "default", "d", false, "Set as default context")
|
||||
addContextCmd.Flags().BoolP("force", "f", false, "Force overwrite if context already exists")
|
||||
|
||||
rootCmd.AddCommand(contextCmd)
|
||||
contextCmd.AddCommand(contextVersionCmd)
|
||||
contextCmd.AddCommand(listContextsCmd)
|
||||
contextCmd.AddCommand(addContextCmd)
|
||||
contextCmd.AddCommand(deleteContextCmd)
|
||||
contextCmd.AddCommand(setTokenCmd)
|
||||
contextCmd.AddCommand(useContextCmd)
|
||||
contextCmd.AddCommand(getContextCmd)
|
||||
}
|
||||
|
||||
+65
-125
@@ -1,192 +1,132 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/coollabsio/coolify-cli/internal/models"
|
||||
"github.com/coollabsio/coolify-cli/internal/output"
|
||||
"github.com/coollabsio/coolify-cli/internal/service"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type PrivateKeys struct {
|
||||
PrivateKeys []PrivateKey `json:"private_keys"`
|
||||
}
|
||||
|
||||
type PrivateKey struct {
|
||||
ID int `json:"id"`
|
||||
UUID string `json:"uuid"`
|
||||
Name string `json:"name"`
|
||||
PublicKey string `json:"public_key"`
|
||||
PrivateKey string `json:"private_key"`
|
||||
}
|
||||
|
||||
var privateKeysCmd = &cobra.Command{
|
||||
Use: "private-keys",
|
||||
Short: "Private key related commands",
|
||||
Use: "private-key",
|
||||
Aliases: []string{"private-keys", "key", "keys"},
|
||||
Short: "Private key related commands",
|
||||
Long: `Manage SSH private keys for server authentication - list, add, and remove keys.`,
|
||||
}
|
||||
|
||||
var listPrivateKeysCmd = &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List all private keys",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
CheckDefaultThings(nil)
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := context.Background()
|
||||
|
||||
baseUrl := "security/keys"
|
||||
data, err := Fetch(baseUrl)
|
||||
client, err := getAPIClient(cmd)
|
||||
if err != nil {
|
||||
log.Println(err)
|
||||
return
|
||||
return fmt.Errorf("failed to get API client: %w", err)
|
||||
}
|
||||
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 []PrivateKey
|
||||
err = json.Unmarshal([]byte(data), &jsondata)
|
||||
|
||||
keySvc := service.NewPrivateKeyService(client)
|
||||
keys, err := keySvc.List(ctx)
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
return fmt.Errorf("failed to list private keys: %w", err)
|
||||
}
|
||||
|
||||
fmt.Fprintln(w, "Uuid\tName")
|
||||
for _, resource := range jsondata {
|
||||
if ShowSensitive {
|
||||
fmt.Fprintf(w, "%s\t%s\n", resource.UUID, resource.Name)
|
||||
} else {
|
||||
fmt.Fprintf(w, "%s\t%s\n", resource.UUID, resource.Name)
|
||||
}
|
||||
}
|
||||
w.Flush()
|
||||
fmt.Println("\nNote: Use -s to show sensitive information.")
|
||||
},
|
||||
}
|
||||
var onePrivateKeyCmd = &cobra.Command{
|
||||
Use: "get [uuid]",
|
||||
Args: cobra.ExactArgs(1),
|
||||
Short: "Get private key details by uuid",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
CheckDefaultThings(nil)
|
||||
baseUrl := "security/keys/"
|
||||
format, _ := cmd.Flags().GetString("format")
|
||||
showSensitive, _ := cmd.Flags().GetBool("show-sensitive")
|
||||
|
||||
uuid := args[0]
|
||||
var url = baseUrl + uuid
|
||||
|
||||
data, err := Fetch(url)
|
||||
formatter, err := output.NewFormatter(format, output.Options{
|
||||
ShowSensitive: showSensitive,
|
||||
})
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
return err
|
||||
}
|
||||
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 PrivateKey
|
||||
err = json.Unmarshal([]byte(data), &jsondata)
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
fmt.Fprintln(w, "Uuid\tName\tPublicKey\tPrivateKey")
|
||||
if ShowSensitive {
|
||||
fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", jsondata.UUID, jsondata.Name, jsondata.PublicKey, strings.ReplaceAll(jsondata.PrivateKey, "\n", "\\n"))
|
||||
|
||||
} else {
|
||||
fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", jsondata.UUID, jsondata.Name, SensitiveInformationOverlay, SensitiveInformationOverlay)
|
||||
if err := formatter.Format(keys); err != nil {
|
||||
return err
|
||||
}
|
||||
w.Flush()
|
||||
fmt.Println("\nNote: Use -s to show sensitive information.")
|
||||
|
||||
if !showSensitive && format == output.FormatTable {
|
||||
fmt.Println("\nNote: Use -s to show sensitive information.")
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var addPrivateKeyCmd = &cobra.Command{
|
||||
Use: "add",
|
||||
Example: `add <name> <private_key_or_file>`,
|
||||
Args: cobra.ExactArgs(2),
|
||||
Use: "add <name> <private_key_or_file>",
|
||||
Example: `add mykey ~/.ssh/id_rsa`,
|
||||
Args: exactArgs(2, "<uuid1> <uuid2>"),
|
||||
Short: "Add a private key",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
version := "4.0.0-beta.383"
|
||||
CheckDefaultThings(&version)
|
||||
baseUrl := "security/keys"
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := context.Background()
|
||||
name := args[0]
|
||||
privateKeyInput := args[1]
|
||||
|
||||
client, err := getAPIClient(cmd)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get API client: %w", err)
|
||||
}
|
||||
|
||||
var privateKey string
|
||||
// Check if input is a file path
|
||||
if _, err := os.Stat(privateKeyInput); err == nil {
|
||||
keyBytes, err := os.ReadFile(privateKeyInput)
|
||||
if err != nil {
|
||||
fmt.Printf("Error reading private key file: %v\n", err)
|
||||
return
|
||||
return fmt.Errorf("error reading private key file: %w", err)
|
||||
}
|
||||
privateKey = string(keyBytes)
|
||||
} else {
|
||||
privateKey = privateKeyInput
|
||||
}
|
||||
|
||||
data := map[string]string{
|
||||
"name": name,
|
||||
"private_key": privateKey,
|
||||
keySvc := service.NewPrivateKeyService(client)
|
||||
req := models.PrivateKeyCreateRequest{
|
||||
Name: name,
|
||||
PrivateKey: privateKey,
|
||||
}
|
||||
jsonData, err := json.Marshal(data)
|
||||
|
||||
key, err := keySvc.Create(ctx, req)
|
||||
if err != nil {
|
||||
fmt.Printf("Error creating request: %v\n", err)
|
||||
return
|
||||
return fmt.Errorf("failed to add private key: %w", err)
|
||||
}
|
||||
_, err = Post(baseUrl, bytes.NewBuffer(jsonData))
|
||||
if err != nil {
|
||||
fmt.Printf("Error adding private key: %v\n", err)
|
||||
return
|
||||
}
|
||||
fmt.Printf("Private key '%s' added successfully\n", name)
|
||||
|
||||
fmt.Printf("Private key '%s' added successfully (UUID: %s)\n", key.Name, key.UUID)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var removePrivateKeyCmd = &cobra.Command{
|
||||
Use: "remove [uuid]",
|
||||
Args: cobra.ExactArgs(1),
|
||||
Use: "remove <uuid>",
|
||||
Args: exactArgs(1, "<uuid>"),
|
||||
Short: "Remove a private key",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
version := "4.0.0-beta.383"
|
||||
CheckDefaultThings(&version)
|
||||
baseUrl := "security/keys/"
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := context.Background()
|
||||
uuid := args[0]
|
||||
_, err := Delete(baseUrl + uuid)
|
||||
|
||||
client, err := getAPIClient(cmd)
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
return fmt.Errorf("failed to get API client: %w", err)
|
||||
}
|
||||
|
||||
keySvc := service.NewPrivateKeyService(client)
|
||||
err = keySvc.Delete(ctx, uuid)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to remove private key: %w", err)
|
||||
}
|
||||
|
||||
fmt.Println("Private key removed successfully")
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
rootCmd.AddCommand(privateKeysCmd)
|
||||
privateKeysCmd.AddCommand(listPrivateKeysCmd)
|
||||
privateKeysCmd.AddCommand(onePrivateKeyCmd)
|
||||
privateKeysCmd.AddCommand(addPrivateKeyCmd)
|
||||
privateKeysCmd.AddCommand(removePrivateKeyCmd)
|
||||
}
|
||||
|
||||
+116
-167
@@ -1,211 +1,160 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
|
||||
"github.com/coollabsio/coolify-cli/internal/models"
|
||||
"github.com/coollabsio/coolify-cli/internal/output"
|
||||
"github.com/coollabsio/coolify-cli/internal/service"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type Application struct {
|
||||
ID int `json:"id"`
|
||||
Uuid string `json:"uuid"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
type Environment struct {
|
||||
ID int `json:"id"`
|
||||
Uuid string `json:"uuid"`
|
||||
Name string `json:"name"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
Description *string `json:"description"`
|
||||
Applications []Application `json:"applications"`
|
||||
// EnvironmentRow represents an environment for display
|
||||
type EnvironmentRow struct {
|
||||
UUID string `json:"environment_uuid"`
|
||||
EnvironmentName string `json:"environment_name"`
|
||||
}
|
||||
|
||||
type Project struct {
|
||||
Uuid string `json:"uuid"`
|
||||
Name string `json:"name"`
|
||||
Environments []Environment `json:"environments"`
|
||||
// ProjectListRow represents a project for list display (without environments)
|
||||
type ProjectListRow struct {
|
||||
UUID string `json:"uuid"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
var projectsCmd = &cobra.Command{
|
||||
Use: "projects",
|
||||
Short: "Project related commands",
|
||||
Use: "project",
|
||||
Aliases: []string{"projects"},
|
||||
Short: "Project related commands",
|
||||
Long: `Manage Coolify projects - list all projects or get details about a specific project.`,
|
||||
}
|
||||
|
||||
var listProjectsCmd = &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List all projects",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
CheckDefaultThings(nil)
|
||||
baseUrl := "projects"
|
||||
data, err := Fetch(baseUrl)
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := context.Background()
|
||||
|
||||
client, err := getAPIClient(cmd)
|
||||
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 []Project
|
||||
err = json.Unmarshal([]byte(data), &jsondata)
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
return fmt.Errorf("failed to get API client: %w", err)
|
||||
}
|
||||
|
||||
fmt.Fprintln(w, "Uuid\tName")
|
||||
for _, resource := range jsondata {
|
||||
fmt.Fprintf(w, "%s\t%s\n", resource.Uuid, resource.Name)
|
||||
projectSvc := service.NewProjectService(client)
|
||||
projects, err := projectSvc.List(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to list projects: %w", err)
|
||||
}
|
||||
w.Flush()
|
||||
|
||||
format, _ := cmd.Flags().GetString("format")
|
||||
showSensitive, _ := cmd.Flags().GetBool("show-sensitive")
|
||||
|
||||
// For JSON/pretty formats, return the full project structure
|
||||
if format != output.FormatTable {
|
||||
formatter, err := output.NewFormatter(format, output.Options{
|
||||
ShowSensitive: showSensitive,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return formatter.Format(projects)
|
||||
}
|
||||
|
||||
// For table format, convert to simplified rows without environments
|
||||
var rows []ProjectListRow
|
||||
for _, p := range projects {
|
||||
desc := ""
|
||||
if p.Description != nil {
|
||||
desc = *p.Description
|
||||
}
|
||||
rows = append(rows, ProjectListRow{
|
||||
UUID: p.UUID,
|
||||
Name: p.Name,
|
||||
Description: desc,
|
||||
})
|
||||
}
|
||||
|
||||
formatter, err := output.NewFormatter(format, output.Options{
|
||||
ShowSensitive: showSensitive,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return formatter.Format(rows)
|
||||
},
|
||||
}
|
||||
|
||||
var oneProjectCmd = &cobra.Command{
|
||||
Use: "get [uuid]",
|
||||
Short: "Get a project by uuid",
|
||||
Args: cobra.ExactArgs(1),
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
CheckDefaultThings(nil)
|
||||
Args: exactArgs(1, "<uuid>"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := context.Background()
|
||||
uuid := args[0]
|
||||
environment, _ := cmd.Flags().GetString("environment")
|
||||
if environment != "" {
|
||||
url := "projects/" + uuid + "/" + environment
|
||||
data, err := Fetch(url)
|
||||
|
||||
client, err := getAPIClient(cmd)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get API client: %w", err)
|
||||
}
|
||||
|
||||
projectSvc := service.NewProjectService(client)
|
||||
project, err := projectSvc.Get(ctx, uuid)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get project: %w", err)
|
||||
}
|
||||
|
||||
format, _ := cmd.Flags().GetString("format")
|
||||
showSensitive, _ := cmd.Flags().GetBool("show-sensitive")
|
||||
|
||||
// For JSON/pretty formats, return the full project structure
|
||||
if format != output.FormatTable {
|
||||
formatter, err := output.NewFormatter(format, output.Options{
|
||||
ShowSensitive: showSensitive,
|
||||
})
|
||||
if err != nil {
|
||||
log.Println(err)
|
||||
return
|
||||
return err
|
||||
}
|
||||
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 Environment
|
||||
err = json.Unmarshal([]byte(data), &jsondata)
|
||||
if err != nil {
|
||||
log.Println(err)
|
||||
return
|
||||
}
|
||||
fmt.Fprintln(w, "Uuid\tName\tStatus")
|
||||
for _, resource := range jsondata.Applications {
|
||||
fmt.Fprintf(w, "%s\t%s\t%s\n", resource.Uuid, resource.Name, resource.Status)
|
||||
}
|
||||
w.Flush()
|
||||
return
|
||||
return formatter.Format(project)
|
||||
}
|
||||
data, err := Fetch("projects/" + uuid)
|
||||
|
||||
// For table format, expand environments into separate rows
|
||||
rows := expandProjectEnvironments(project)
|
||||
|
||||
formatter, err := output.NewFormatter(format, output.Options{
|
||||
ShowSensitive: showSensitive,
|
||||
})
|
||||
if err != nil {
|
||||
log.Println(err)
|
||||
return
|
||||
return err
|
||||
}
|
||||
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 Project
|
||||
err = json.Unmarshal([]byte(data), &jsondata)
|
||||
if err != nil {
|
||||
log.Println(err)
|
||||
return
|
||||
}
|
||||
fmt.Fprintln(w, "Uuid\tName\tEnvironments")
|
||||
envNames := make([]string, len(jsondata.Environments))
|
||||
for i, env := range jsondata.Environments {
|
||||
envNames[i] = env.Name + " (" + env.Uuid + ")"
|
||||
}
|
||||
fmt.Fprintf(w, "%s\t%s\t%s\n", jsondata.Uuid, jsondata.Name, strings.Join(envNames, ", "))
|
||||
w.Flush()
|
||||
},
|
||||
}
|
||||
var addProjectCmd = &cobra.Command{
|
||||
Use: "add [name]",
|
||||
Short: "Add a project",
|
||||
Args: cobra.ExactArgs(1),
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
CheckDefaultThings(nil)
|
||||
baseUrl := "projects"
|
||||
name := args[0]
|
||||
data := map[string]string{
|
||||
"name": name,
|
||||
}
|
||||
jsonData, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
response, err := Post(baseUrl, bytes.NewBuffer(jsonData))
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
msg := map[string]string{}
|
||||
json.Unmarshal([]byte(response), &msg)
|
||||
fmt.Println("Project added successfully with uuid " + msg["uuid"])
|
||||
|
||||
return formatter.Format(rows)
|
||||
},
|
||||
}
|
||||
|
||||
var removeProjectCmd = &cobra.Command{
|
||||
Use: "remove [uuid]",
|
||||
Short: "Remove a project",
|
||||
Args: cobra.ExactArgs(1),
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
CheckDefaultThings(nil)
|
||||
baseUrl := "projects/"
|
||||
uuid := args[0]
|
||||
response, err := Delete(baseUrl + uuid)
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
msg := map[string]string{}
|
||||
json.Unmarshal([]byte(response), &msg)
|
||||
fmt.Println(msg["message"])
|
||||
},
|
||||
// expandProjectEnvironments creates environment rows for display
|
||||
func expandProjectEnvironments(project *models.Project) []EnvironmentRow {
|
||||
var rows []EnvironmentRow
|
||||
|
||||
// If no environments, return empty list
|
||||
if len(project.Environments) == 0 {
|
||||
return rows
|
||||
}
|
||||
|
||||
// Create one row per environment with just UUID and Name
|
||||
for _, env := range project.Environments {
|
||||
rows = append(rows, EnvironmentRow{
|
||||
UUID: env.UUID,
|
||||
EnvironmentName: env.Name,
|
||||
})
|
||||
}
|
||||
|
||||
return rows
|
||||
}
|
||||
|
||||
func init() {
|
||||
rootCmd.AddCommand(projectsCmd)
|
||||
projectsCmd.AddCommand(listProjectsCmd)
|
||||
oneProjectCmd.Flags().StringP("environment", "e", "", "Environment")
|
||||
projectsCmd.AddCommand(oneProjectCmd)
|
||||
|
||||
projectsCmd.AddCommand(addProjectCmd)
|
||||
projectsCmd.AddCommand(removeProjectCmd)
|
||||
}
|
||||
|
||||
+27
-32
@@ -1,54 +1,49 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"github.com/coollabsio/coolify-cli/internal/output"
|
||||
"github.com/coollabsio/coolify-cli/internal/service"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var resourcesCmd = &cobra.Command{
|
||||
Use: "resources",
|
||||
Short: "Resource related commands",
|
||||
Use: "resource",
|
||||
Aliases: []string{"resources"},
|
||||
Short: "Resource related commands",
|
||||
Long: `List all resources (applications, services, databases) across your Coolify instance.`,
|
||||
}
|
||||
|
||||
var listResourcesCmd = &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List all resources",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
CheckDefaultThings(nil)
|
||||
data, err := Fetch("resources")
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := context.Background()
|
||||
|
||||
client, err := getAPIClient(cmd)
|
||||
if err != nil {
|
||||
log.Println(err)
|
||||
return
|
||||
return fmt.Errorf("failed to get API client: %w", err)
|
||||
}
|
||||
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 []Resource
|
||||
err = json.Unmarshal([]byte(data), &jsondata)
|
||||
|
||||
resourceSvc := service.NewResourceService(client)
|
||||
resources, err := resourceSvc.List(ctx)
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
return fmt.Errorf("failed to list resources: %w", err)
|
||||
}
|
||||
fmt.Fprintln(w, "Uuid\tName\tType\tStatus")
|
||||
for _, resource := range jsondata {
|
||||
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t\n", resource.Uuid, resource.Name, resource.Type, resource.Status)
|
||||
|
||||
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
|
||||
}
|
||||
w.Flush()
|
||||
|
||||
return formatter.Format(resources)
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
+184
-223
@@ -11,37 +11,41 @@ import (
|
||||
"text/tabwriter"
|
||||
"time"
|
||||
|
||||
"github.com/adrg/xdg"
|
||||
"github.com/coollabsio/coolify-cli/internal/api"
|
||||
"github.com/coollabsio/coolify-cli/internal/config"
|
||||
compareVersion "github.com/hashicorp/go-version"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
var CliVersion = "0.0.1"
|
||||
var LastUpdateCheckTime time.Time
|
||||
var CheckInverval = 10 * time.Minute
|
||||
// CliVersion is the CLI version
|
||||
var CliVersion = "1.0.0"
|
||||
|
||||
var ConfigDir = xdg.ConfigHome
|
||||
// CheckInterval for version checking
|
||||
var CheckInterval = 10 * time.Minute
|
||||
|
||||
var Version string
|
||||
var Name string
|
||||
var Fqdn string
|
||||
var Token string
|
||||
var Instance http.Client
|
||||
// SensitiveInformationOverlay is the string used to hide sensitive data
|
||||
var SensitiveInformationOverlay = "********"
|
||||
|
||||
// Flags
|
||||
var Debug bool
|
||||
var ShowSensitive bool
|
||||
var Force bool
|
||||
var Format string
|
||||
|
||||
var JsonMode bool
|
||||
var PrettyMode bool
|
||||
var SetDefaultInstance bool
|
||||
|
||||
var w = tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', tabwriter.Debug)
|
||||
// Legacy global variables - kept for backward compatibility during migration
|
||||
// TODO: Remove these once all commands are refactored
|
||||
var (
|
||||
Version string
|
||||
Name string
|
||||
Fqdn string
|
||||
Token string
|
||||
ContextName string
|
||||
Debug bool
|
||||
ShowSensitive bool
|
||||
Format string
|
||||
JsonMode bool
|
||||
PrettyMode bool
|
||||
SetDefaultInstance bool
|
||||
w = tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', tabwriter.Debug)
|
||||
Instance http.Client
|
||||
)
|
||||
|
||||
// Tag represents a git tag for version checking
|
||||
type Tag struct {
|
||||
Ref string `json:"ref"`
|
||||
}
|
||||
@@ -50,167 +54,129 @@ var rootCmd = &cobra.Command{
|
||||
Use: "coolify",
|
||||
Short: "Coolify CLI",
|
||||
Long: `A CLI tool to interact with Coolify API.`,
|
||||
SilenceUsage: true, // Don't show usage on errors
|
||||
SilenceErrors: false, // Still print errors
|
||||
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func CheckFormat(format string) {
|
||||
if format == "json" {
|
||||
JsonMode = true
|
||||
return
|
||||
}
|
||||
if format == "pretty" {
|
||||
PrettyMode = true
|
||||
return
|
||||
}
|
||||
if format == "table" {
|
||||
return
|
||||
}
|
||||
fmt.Println("Invalid format", format)
|
||||
os.Exit(0)
|
||||
}
|
||||
// getAPIClient creates an API client from command flags or config
|
||||
func getAPIClient(cmd *cobra.Command) (*api.Client, error) {
|
||||
// Get flags
|
||||
token, _ := cmd.Flags().GetString("token")
|
||||
contextName, _ := cmd.Flags().GetString("context")
|
||||
debug, _ := cmd.Flags().GetBool("debug")
|
||||
|
||||
func CheckDefaultThings(version *string) {
|
||||
FetchVersion()
|
||||
CheckFormat(Format)
|
||||
if version == nil {
|
||||
CheckMinimumVersion(Version)
|
||||
// Load config to get instance details
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to load config: %w", err)
|
||||
}
|
||||
|
||||
var instance *config.Instance
|
||||
// Use context if specified, otherwise use default
|
||||
if contextName != "" {
|
||||
instance, err = cfg.GetInstance(contextName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("context '%s' not found: %w", contextName, err)
|
||||
}
|
||||
} else {
|
||||
CheckMinimumVersion(*version)
|
||||
}
|
||||
}
|
||||
|
||||
func CheckMinimumVersion(version string) {
|
||||
requiredVersion, err := compareVersion.NewVersion(version)
|
||||
if err != nil {
|
||||
log.Println(err)
|
||||
os.Exit(0)
|
||||
}
|
||||
currentVersion, err := compareVersion.NewVersion(Version)
|
||||
if err != nil {
|
||||
log.Println(err)
|
||||
os.Exit(0)
|
||||
}
|
||||
if currentVersion.LessThan(requiredVersion) {
|
||||
log.Printf("Minimum required Coolify API version is: %s\n", version)
|
||||
log.Print("Please upgrade your Coolify instance for this command.\n")
|
||||
os.Exit(0)
|
||||
}
|
||||
}
|
||||
func FetchVersion() (string, error) {
|
||||
data, err := Fetch("version")
|
||||
if err != nil {
|
||||
log.Println(err)
|
||||
os.Exit(0)
|
||||
return "", err
|
||||
}
|
||||
Version = data
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func Fetch(url string) (string, error) {
|
||||
url = Fqdn + "/api/v1/" + url
|
||||
if Debug {
|
||||
log.Println("Fetching data from", url)
|
||||
}
|
||||
req, err := http.NewRequest("GET", url, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Add("Authorization", "Bearer "+Token)
|
||||
resp, err := Instance.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if resp.StatusCode != 200 {
|
||||
return "", fmt.Errorf("%d - Failed to fetch data from %s. Error: %s", resp.StatusCode, url, string(body))
|
||||
}
|
||||
|
||||
return string(body), nil
|
||||
}
|
||||
func Post(url string, input io.Reader) (string, error) {
|
||||
url = Fqdn + "/api/v1/" + url
|
||||
if Debug {
|
||||
log.Println("Posting data to", url)
|
||||
}
|
||||
req, err := http.NewRequest("POST", url, input)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Add("Authorization", "Bearer "+Token)
|
||||
req.Header.Add("Content-Type", "application/json")
|
||||
resp, err := Instance.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if resp.StatusCode >= 400 {
|
||||
message := string(body)
|
||||
if message == "" {
|
||||
message = "Unknown error"
|
||||
} else {
|
||||
msg := map[string]string{}
|
||||
json.Unmarshal(body, &msg)
|
||||
message = msg["message"]
|
||||
instance, err = cfg.GetDefault()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("no default instance configured: %w", err)
|
||||
}
|
||||
return "", fmt.Errorf("%s (rc: %d)", message, resp.StatusCode)
|
||||
}
|
||||
|
||||
return string(body), nil
|
||||
// Get FQDN from instance
|
||||
fqdn := instance.FQDN
|
||||
|
||||
// Use token from flag if provided, otherwise use instance token
|
||||
if token == "" {
|
||||
token = instance.Token
|
||||
}
|
||||
|
||||
// Create client
|
||||
client := api.NewClient(fqdn, token, api.WithDebug(debug))
|
||||
|
||||
// Set legacy global variables for backward compatibility
|
||||
Fqdn = fqdn
|
||||
Token = token
|
||||
Debug = debug
|
||||
|
||||
return client, nil
|
||||
}
|
||||
|
||||
func Delete(url string) (string, error) {
|
||||
url = Fqdn + "/api/v1/" + url
|
||||
if Debug {
|
||||
log.Println("Deleting data from", url)
|
||||
}
|
||||
req, err := http.NewRequest("DELETE", url, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Add("Authorization", "Bearer "+Token)
|
||||
resp, err := Instance.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if resp.StatusCode != 200 {
|
||||
message := string(body)
|
||||
if message == "" {
|
||||
message = "Unknown error"
|
||||
} else {
|
||||
msg := map[string]string{}
|
||||
json.Unmarshal(body, &msg)
|
||||
message = msg["message"]
|
||||
// exactArgs returns a validator that ensures exactly n arguments are provided with a helpful error message
|
||||
func exactArgs(n int, usage string) cobra.PositionalArgs {
|
||||
return func(cmd *cobra.Command, args []string) error {
|
||||
if len(args) != n {
|
||||
if n == 1 {
|
||||
return fmt.Errorf("missing required argument: %s\n\nUsage: %s", usage, cmd.UseLine())
|
||||
}
|
||||
return fmt.Errorf("expected %d argument(s), got %d\n\nUsage: %s", n, len(args), cmd.UseLine())
|
||||
}
|
||||
return "", fmt.Errorf("%s (rc: %d)", message, resp.StatusCode)
|
||||
return nil
|
||||
}
|
||||
return string(body), nil
|
||||
}
|
||||
|
||||
// minArgs returns a validator that ensures at least n arguments are provided with a helpful error message
|
||||
func minArgs(n int, usage string) cobra.PositionalArgs {
|
||||
return func(cmd *cobra.Command, args []string) error {
|
||||
if len(args) < n {
|
||||
return fmt.Errorf("missing required arguments: %s\n\nUsage: %s", usage, cmd.UseLine())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// parseInt parses a string to int with better error message
|
||||
func parseInt(s string) (int, error) {
|
||||
var result int
|
||||
_, err := fmt.Sscanf(s, "%d", &result)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("'%s' is not a valid integer", s)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// splitOwnerRepo splits owner/repo string into parts
|
||||
func splitOwnerRepo(s string) []string {
|
||||
parts := make([]string, 0, 2)
|
||||
var current string
|
||||
for _, char := range s {
|
||||
if char == '/' {
|
||||
if current != "" {
|
||||
parts = append(parts, current)
|
||||
current = ""
|
||||
}
|
||||
} else {
|
||||
current += string(char)
|
||||
}
|
||||
}
|
||||
if current != "" {
|
||||
parts = append(parts, current)
|
||||
}
|
||||
return parts
|
||||
}
|
||||
|
||||
// CheckLatestVersionOfCli checks for CLI updates
|
||||
func CheckLatestVersionOfCli() (string, error) {
|
||||
getLastUpdateCheckTime()
|
||||
if LastUpdateCheckTime.Add(CheckInverval).After(time.Now()) {
|
||||
if Debug {
|
||||
log.Println("Skipping update check. Last check was less than 10 minutes ago.")
|
||||
lastCheck := viper.GetString("lastupdatechecktime")
|
||||
if lastCheck != "" {
|
||||
lastCheckTime, err := time.Parse(time.RFC3339, lastCheck)
|
||||
if err == nil && lastCheckTime.Add(CheckInterval).After(time.Now()) {
|
||||
if Debug {
|
||||
log.Println("Skipping update check. Last check was less than 10 minutes ago.")
|
||||
}
|
||||
return CliVersion, nil
|
||||
}
|
||||
return CliVersion, nil
|
||||
}
|
||||
setLastUpdateCheckTime()
|
||||
|
||||
// Update check time
|
||||
viper.Set("lastupdatechecktime", time.Now().Format(time.RFC3339))
|
||||
viper.WriteConfig()
|
||||
|
||||
url := "https://api.github.com/repos/coollabsio/coolify-cli/git/refs/tags"
|
||||
req, err := http.NewRequest("GET", url, nil)
|
||||
if err != nil {
|
||||
@@ -254,13 +220,21 @@ func CheckLatestVersionOfCli() (string, error) {
|
||||
}
|
||||
|
||||
sort.Sort(compareVersion.Collection(versions))
|
||||
latestVersion := versions[len(versions)-1].String()
|
||||
if latestVersion != CliVersion {
|
||||
fmt.Printf("There is a new version of Coolify CLI available.\nPlease update with 'coolify --update'.\n\n")
|
||||
}
|
||||
return latestVersion, nil
|
||||
latestVersion := versions[len(versions)-1]
|
||||
|
||||
// Compare versions properly using semantic versioning
|
||||
currentVersion, err := compareVersion.NewVersion(CliVersion)
|
||||
if err != nil {
|
||||
return latestVersion.String(), err
|
||||
}
|
||||
|
||||
if latestVersion.GreaterThan(currentVersion) {
|
||||
fmt.Printf("There is a new version of Coolify CLI available.\nPlease update with 'coolify update'.\n\n")
|
||||
}
|
||||
return latestVersion.String(), nil
|
||||
}
|
||||
|
||||
// Execute runs the root command
|
||||
func Execute() {
|
||||
err := rootCmd.Execute()
|
||||
if err != nil {
|
||||
@@ -271,82 +245,69 @@ func Execute() {
|
||||
func init() {
|
||||
cobra.OnInitialize(initConfig)
|
||||
|
||||
rootCmd.PersistentFlags().StringVarP(&Token, "token", "", "", "Token for authentication (https://app.coolify.io/security/api-tokens)")
|
||||
rootCmd.PersistentFlags().StringVarP(&Fqdn, "host", "", "", "Coolify instance hostname")
|
||||
rootCmd.PersistentFlags().StringVarP(&Token, "token", "", "", "Token for authentication (override context token)")
|
||||
rootCmd.PersistentFlags().StringVarP(&ContextName, "context", "", "", "Use specific context by name")
|
||||
|
||||
rootCmd.PersistentFlags().StringVarP(&Format, "format", "", "table", "Format output (table|json|pretty)")
|
||||
rootCmd.PersistentFlags().BoolVarP(&ShowSensitive, "show-sensitive", "s", false, "Show sensitive information")
|
||||
rootCmd.PersistentFlags().BoolVarP(&Force, "force", "f", false, "Force")
|
||||
rootCmd.PersistentFlags().BoolVarP(&Debug, "debug", "", false, "Debug mode")
|
||||
}
|
||||
func setLastUpdateCheckTime() {
|
||||
timeNow := time.Now()
|
||||
viper.Set("lastupdatechecktime", timeNow)
|
||||
viper.WriteConfig()
|
||||
LastUpdateCheckTime = timeNow
|
||||
}
|
||||
func getLastUpdateCheckTime() {
|
||||
lastUpdateCheckTimeString := viper.Get("lastupdatechecktime").(string)
|
||||
lastUpdateCheckTime, err := time.Parse(time.RFC3339, lastUpdateCheckTimeString)
|
||||
if err != nil {
|
||||
log.Fatalf("Error parsing time: %v", err)
|
||||
}
|
||||
LastUpdateCheckTime = lastUpdateCheckTime
|
||||
|
||||
}
|
||||
func initConfig() {
|
||||
viper.SetConfigName("config")
|
||||
viper.SetConfigType("json")
|
||||
viper.AddConfigPath(ConfigDir + "/coolify")
|
||||
if _, err := os.Stat(ConfigDir + "/coolify"); os.IsNotExist(err) {
|
||||
os.MkdirAll(ConfigDir+"/coolify", 0755)
|
||||
viper.AddConfigPath(config.Path()[:len(config.Path())-len("/config.json")])
|
||||
|
||||
// Ensure config directory exists
|
||||
configDir := config.Path()[:len(config.Path())-len("/config.json")]
|
||||
if _, err := os.Stat(configDir); os.IsNotExist(err) {
|
||||
os.MkdirAll(configDir, 0755)
|
||||
}
|
||||
|
||||
if err := viper.ReadInConfig(); err != nil {
|
||||
if _, ok := err.(viper.ConfigFileNotFoundError); ok {
|
||||
log.Println("Config file not found. Creating a new one at", ConfigDir+"/coolify/config.json")
|
||||
viper.Set("lastUpdateCheckTime", time.Now())
|
||||
viper.Set("instances", []interface{}{map[string]interface{}{
|
||||
"name": "cloud",
|
||||
"default": true,
|
||||
"fqdn": "https://app.coolify.io",
|
||||
"token": "",
|
||||
},
|
||||
})
|
||||
viper.Set("instances", append(viper.Get("instances").([]interface{}), map[string]interface{}{
|
||||
"name": "localhost",
|
||||
"fqdn": "http://localhost:8000",
|
||||
"token": "",
|
||||
}))
|
||||
viper.SafeWriteConfig()
|
||||
return
|
||||
// Config file not found; ignore error if desired
|
||||
log.Println("Config file not found. Creating a new one at", config.Path())
|
||||
if err := config.CreateDefault(); err != nil {
|
||||
log.Printf("Failed to create default config: %v\n", err)
|
||||
return
|
||||
}
|
||||
// Reload config after creating default
|
||||
if err := viper.ReadInConfig(); err != nil {
|
||||
log.Printf("Failed to read newly created config: %v\n", err)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
fmt.Println("Error reading config file, ", err)
|
||||
fmt.Println("Error reading config file:", err)
|
||||
return
|
||||
// Config file was found but another error was produced
|
||||
}
|
||||
}
|
||||
|
||||
if Debug {
|
||||
log.Println("Using config file:", viper.ConfigFileUsed())
|
||||
}
|
||||
instancesMap := viper.Get("instances").([]interface{})
|
||||
for _, instance := range instancesMap {
|
||||
instanceMap := instance.(map[string]interface{})
|
||||
if instanceMap["default"] == true {
|
||||
if Fqdn == "" {
|
||||
Fqdn = instanceMap["fqdn"].(string)
|
||||
}
|
||||
if Token == "" {
|
||||
Token = instanceMap["token"].(string)
|
||||
|
||||
// Note: We don't pre-populate Fqdn/Token here anymore
|
||||
// They are loaded on-demand by getAPIClient() based on --instance or default instance
|
||||
// This allows --instance flag to work correctly
|
||||
|
||||
// Check for updates
|
||||
latestVersionStr, err := CheckLatestVersionOfCli()
|
||||
if err != nil {
|
||||
if Debug {
|
||||
log.Println("Failed to check for updates:", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Compare versions properly using semantic versioning
|
||||
if latestVersionStr != "" {
|
||||
latestVersion, err := compareVersion.NewVersion(latestVersionStr)
|
||||
if err == nil {
|
||||
currentVersion, err := compareVersion.NewVersion(CliVersion)
|
||||
if err == nil && latestVersion.GreaterThan(currentVersion) {
|
||||
if Debug {
|
||||
log.Printf("New version of Coolify CLI is available: %s\n", latestVersionStr)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
data, err := CheckLatestVersionOfCli()
|
||||
if err != nil {
|
||||
log.Println(err)
|
||||
}
|
||||
if data != CliVersion {
|
||||
log.Printf("New version of Coolify CLI is available: %s\n", data)
|
||||
}
|
||||
}
|
||||
|
||||
+169
-165
@@ -1,234 +1,238 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"github.com/coollabsio/coolify-cli/internal/models"
|
||||
"github.com/coollabsio/coolify-cli/internal/output"
|
||||
"github.com/coollabsio/coolify-cli/internal/service"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var WithResources bool
|
||||
|
||||
type Resource struct {
|
||||
ID int `json:"id"`
|
||||
Uuid string `json:"uuid"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
type Resources struct {
|
||||
Resources []Resource `json:"resources"`
|
||||
}
|
||||
|
||||
type Server struct {
|
||||
ID int `json:"id"`
|
||||
UUID string `json:"uuid"`
|
||||
Name string `json:"name"`
|
||||
IP string `json:"ip"`
|
||||
User string `json:"user"`
|
||||
Port int `json:"port"`
|
||||
Settings struct {
|
||||
Reachable bool `json:"is_reachable"`
|
||||
Usable bool `json:"is_usable"`
|
||||
} `json:"settings"`
|
||||
}
|
||||
|
||||
var serversCmd = &cobra.Command{
|
||||
Use: "servers",
|
||||
Short: "Server related commands",
|
||||
Use: "server",
|
||||
Aliases: []string{"servers"},
|
||||
Short: "Server related commands",
|
||||
Long: `Manage Coolify servers - list, get details, add new servers, validate connections, and remove servers.`,
|
||||
}
|
||||
|
||||
var listServersCmd = &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List all servers",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
CheckDefaultThings(nil)
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := context.Background()
|
||||
|
||||
baseUrl := "servers"
|
||||
data, err := Fetch(baseUrl)
|
||||
// Get API client
|
||||
client, err := getAPIClient(cmd)
|
||||
if err != nil {
|
||||
log.Println(err)
|
||||
return
|
||||
return fmt.Errorf("failed to get API client: %w", err)
|
||||
}
|
||||
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 []Server
|
||||
err = json.Unmarshal([]byte(data), &jsondata)
|
||||
|
||||
// Check API version
|
||||
version, err := client.GetVersion(ctx)
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
return fmt.Errorf("failed to get API version: %w", err)
|
||||
}
|
||||
Version = version
|
||||
|
||||
fmt.Fprintln(w, "Uuid\tName\tIP Address\tUser\tPort\tReachable\tUsable")
|
||||
for _, resource := range jsondata {
|
||||
if ShowSensitive {
|
||||
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%d\t%t\t%t\n", resource.UUID, resource.Name, resource.IP, resource.User, resource.Port, resource.Settings.Reachable, resource.Settings.Usable)
|
||||
} else {
|
||||
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%t\t%t\n", resource.UUID, resource.Name, SensitiveInformationOverlay, SensitiveInformationOverlay, SensitiveInformationOverlay, resource.Settings.Reachable, resource.Settings.Usable)
|
||||
}
|
||||
}
|
||||
w.Flush()
|
||||
fmt.Println("\nNote: Use -s to show sensitive information.")
|
||||
},
|
||||
}
|
||||
var oneServerCmd = &cobra.Command{
|
||||
Use: "get [uuid]",
|
||||
Args: cobra.ExactArgs(1),
|
||||
Short: "Get server details by uuid",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
CheckDefaultThings(nil)
|
||||
baseUrl := "servers/"
|
||||
|
||||
uuid := args[0]
|
||||
var url = baseUrl + uuid
|
||||
if WithResources {
|
||||
url = baseUrl + uuid + "?resources=true"
|
||||
}
|
||||
|
||||
data, err := Fetch(url)
|
||||
// Use service layer
|
||||
serverSvc := service.NewServerService(client)
|
||||
servers, err := serverSvc.List(ctx)
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
return fmt.Errorf("failed to list servers: %w", err)
|
||||
}
|
||||
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
|
||||
}
|
||||
if WithResources {
|
||||
var jsondata Resources
|
||||
err = json.Unmarshal([]byte(data), &jsondata)
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
fmt.Fprintln(w, "Uuid\tName\tType\tStatus")
|
||||
for _, resource := range jsondata.Resources {
|
||||
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t\n", resource.Uuid, resource.Name, resource.Type, resource.Status)
|
||||
}
|
||||
w.Flush()
|
||||
} else {
|
||||
var jsondata Server
|
||||
err = json.Unmarshal([]byte(data), &jsondata)
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
fmt.Fprintln(w, "Uuid\tName\tIP Address\tUser\tPort\tReachable\tUsable")
|
||||
if ShowSensitive {
|
||||
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%d\t%t\t%t\n", jsondata.UUID, jsondata.Name, jsondata.IP, jsondata.User, jsondata.Port, jsondata.Settings.Reachable, jsondata.Settings.Usable)
|
||||
|
||||
} else {
|
||||
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%t\t%t\n", jsondata.UUID, jsondata.Name, SensitiveInformationOverlay, SensitiveInformationOverlay, SensitiveInformationOverlay, jsondata.Settings.Reachable, jsondata.Settings.Usable)
|
||||
}
|
||||
w.Flush()
|
||||
// Use output formatter
|
||||
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
|
||||
}
|
||||
|
||||
if err := formatter.Format(servers); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !showSensitive && format == output.FormatTable {
|
||||
fmt.Println("\nNote: Use -s to show sensitive information.")
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var oneServerCmd = &cobra.Command{
|
||||
Use: "get [uuid]",
|
||||
Args: exactArgs(1, "<uuid>"),
|
||||
Short: "Get server details by uuid",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := context.Background()
|
||||
|
||||
// Get API client
|
||||
client, err := getAPIClient(cmd)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get API client: %w", err)
|
||||
}
|
||||
|
||||
// Use service layer
|
||||
serverSvc := service.NewServerService(client)
|
||||
uuid := args[0]
|
||||
|
||||
// Get format flags
|
||||
format, _ := cmd.Flags().GetString("format")
|
||||
showSensitive, _ := cmd.Flags().GetBool("show-sensitive")
|
||||
|
||||
var data interface{}
|
||||
if WithResources {
|
||||
resources, err := serverSvc.GetResources(ctx, uuid)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get server resources: %w", err)
|
||||
}
|
||||
data = resources.Resources
|
||||
} else {
|
||||
server, err := serverSvc.Get(ctx, uuid)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get server: %w", err)
|
||||
}
|
||||
data = server
|
||||
}
|
||||
|
||||
// Use output formatter
|
||||
formatter, err := output.NewFormatter(format, output.Options{
|
||||
ShowSensitive: showSensitive,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := formatter.Format(data); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !showSensitive && format == output.FormatTable && !WithResources {
|
||||
fmt.Println("\nNote: Use -s to show sensitive information.")
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var removeServerCmd = &cobra.Command{
|
||||
Use: "remove [uuid]",
|
||||
Args: exactArgs(1, "<uuid>"),
|
||||
Short: "Remove a server",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
CheckDefaultThings(nil)
|
||||
baseUrl := "servers/"
|
||||
uuid := args[0]
|
||||
response, err := Delete(baseUrl + uuid)
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := context.Background()
|
||||
|
||||
// Get API client
|
||||
client, err := getAPIClient(cmd)
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
return fmt.Errorf("failed to get API client: %w", err)
|
||||
}
|
||||
msg := map[string]string{}
|
||||
json.Unmarshal([]byte(response), &msg)
|
||||
fmt.Println(msg["message"])
|
||||
|
||||
// Use service layer
|
||||
serverSvc := service.NewServerService(client)
|
||||
uuid := args[0]
|
||||
|
||||
if err := serverSvc.Delete(ctx, uuid); err != nil {
|
||||
return fmt.Errorf("failed to delete server: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("Server %s deleted successfully\n", uuid)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var addServerCmd = &cobra.Command{
|
||||
Use: "add [name] [ip] [private_key_uuid]",
|
||||
Args: exactArgs(3, "<uuid1> <uuid2> <uuid3>"),
|
||||
Short: "Add a server",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
CheckDefaultThings(nil)
|
||||
baseUrl := "servers"
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := context.Background()
|
||||
|
||||
// Get API client
|
||||
client, err := getAPIClient(cmd)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get API client: %w", err)
|
||||
}
|
||||
|
||||
// Parse arguments and flags
|
||||
name := args[0]
|
||||
ip := args[1]
|
||||
privateKeyUuid := args[2]
|
||||
port, _ := cmd.Flags().GetInt("port")
|
||||
user, _ := cmd.Flags().GetString("user")
|
||||
validate, _ := cmd.Flags().GetBool("validate")
|
||||
jsonData, err := json.Marshal(map[string]interface{}{
|
||||
"name": name,
|
||||
"ip": ip,
|
||||
"port": port,
|
||||
"user": user,
|
||||
"private_key_uuid": privateKeyUuid,
|
||||
"instant_validate": validate,
|
||||
})
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
|
||||
// Create request
|
||||
req := models.ServerCreateRequest{
|
||||
Name: name,
|
||||
IP: ip,
|
||||
Port: port,
|
||||
User: user,
|
||||
PrivateKeyUUID: privateKeyUuid,
|
||||
InstantValidate: validate,
|
||||
}
|
||||
response, err := Post(baseUrl, bytes.NewBuffer(jsonData))
|
||||
|
||||
// Use service layer
|
||||
serverSvc := service.NewServerService(client)
|
||||
response, err := serverSvc.Create(ctx, req)
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
return fmt.Errorf("failed to create server: %w", err)
|
||||
}
|
||||
msg := map[string]string{}
|
||||
json.Unmarshal([]byte(response), &msg)
|
||||
|
||||
if validate {
|
||||
fmt.Println("Server added successfully with uuid " + msg["uuid"])
|
||||
fmt.Printf("Server added successfully with uuid %s\n", response.UUID)
|
||||
} else {
|
||||
fmt.Println("Server added successfully with uuid " + msg["uuid"] + ". Server is not validated. Use 'servers validate " + msg["uuid"] + "' to validate the server.")
|
||||
fmt.Printf("Server added successfully with uuid %s. Server is not validated. Use 'servers validate %s' to validate the server.\n", response.UUID, response.UUID)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var validateServerCmd = &cobra.Command{
|
||||
Use: "validate [uuid]",
|
||||
Args: exactArgs(1, "<uuid>"),
|
||||
Short: "Validate a server",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
CheckDefaultThings(nil)
|
||||
baseUrl := "servers/"
|
||||
uuid := args[0]
|
||||
var url = baseUrl + uuid + "/validate"
|
||||
response, err := Fetch(url)
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := context.Background()
|
||||
|
||||
// Get API client
|
||||
client, err := getAPIClient(cmd)
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
return fmt.Errorf("failed to get API client: %w", err)
|
||||
}
|
||||
msg := map[string]string{}
|
||||
json.Unmarshal([]byte(response), &msg)
|
||||
fmt.Println(msg["message"])
|
||||
|
||||
// Use service layer
|
||||
serverSvc := service.NewServerService(client)
|
||||
uuid := args[0]
|
||||
|
||||
response, err := serverSvc.Validate(ctx, uuid)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to validate server: %w", err)
|
||||
}
|
||||
|
||||
if response.Message != "" {
|
||||
fmt.Println(response.Message)
|
||||
} else {
|
||||
fmt.Printf("Server %s validated successfully\n", uuid)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
// Note: format and show-sensitive flags are inherited from rootCmd.PersistentFlags()
|
||||
|
||||
oneServerCmd.Flags().BoolVarP(&WithResources, "resources", "", false, "With resources")
|
||||
rootCmd.AddCommand(serversCmd)
|
||||
serversCmd.AddCommand(listServersCmd)
|
||||
|
||||
+629
@@ -0,0 +1,629 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/coollabsio/coolify-cli/internal/models"
|
||||
"github.com/coollabsio/coolify-cli/internal/output"
|
||||
"github.com/coollabsio/coolify-cli/internal/parser"
|
||||
"github.com/coollabsio/coolify-cli/internal/service"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var servicesCmd = &cobra.Command{
|
||||
Use: "service",
|
||||
Aliases: []string{"services", "svc"},
|
||||
Short: "Service related commands",
|
||||
Long: `Manage Coolify one-click services (databases, Redis, PostgreSQL, etc.).`,
|
||||
}
|
||||
|
||||
var listServicesCmd = &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List all services",
|
||||
Long: `List all services in your Coolify instance.`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := context.Background()
|
||||
|
||||
client, err := getAPIClient(cmd)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get API client: %w", err)
|
||||
}
|
||||
|
||||
serviceSvc := service.NewServiceService(client)
|
||||
services, err := serviceSvc.List(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to list services: %w", err)
|
||||
}
|
||||
|
||||
formatter, err := output.NewFormatter(Format, output.Options{})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create formatter: %w", err)
|
||||
}
|
||||
|
||||
return formatter.Format(services)
|
||||
},
|
||||
}
|
||||
|
||||
var getServiceCmd = &cobra.Command{
|
||||
Use: "get <uuid>",
|
||||
Short: "Get service details",
|
||||
Long: `Get detailed information about a specific service.`,
|
||||
Args: exactArgs(1, "<uuid>"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := context.Background()
|
||||
uuid := args[0]
|
||||
|
||||
client, err := getAPIClient(cmd)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get API client: %w", err)
|
||||
}
|
||||
|
||||
serviceSvc := service.NewServiceService(client)
|
||||
svc, err := serviceSvc.Get(ctx, uuid)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get service: %w", err)
|
||||
}
|
||||
|
||||
formatter, err := output.NewFormatter(Format, output.Options{})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create formatter: %w", err)
|
||||
}
|
||||
|
||||
return formatter.Format(svc)
|
||||
},
|
||||
}
|
||||
|
||||
var startServiceCmd = &cobra.Command{
|
||||
Use: "start <uuid>",
|
||||
Short: "Start a service",
|
||||
Long: `Start a service (deploy all containers).`,
|
||||
Args: exactArgs(1, "<uuid>"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := context.Background()
|
||||
uuid := args[0]
|
||||
|
||||
client, err := getAPIClient(cmd)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get API client: %w", err)
|
||||
}
|
||||
|
||||
serviceSvc := service.NewServiceService(client)
|
||||
resp, err := serviceSvc.Start(ctx, uuid)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to start service: %w", err)
|
||||
}
|
||||
|
||||
fmt.Println(resp.Message)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var stopServiceCmd = &cobra.Command{
|
||||
Use: "stop <uuid>",
|
||||
Short: "Stop a service",
|
||||
Long: `Stop a service (stop all containers).`,
|
||||
Args: exactArgs(1, "<uuid>"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := context.Background()
|
||||
uuid := args[0]
|
||||
|
||||
client, err := getAPIClient(cmd)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get API client: %w", err)
|
||||
}
|
||||
|
||||
serviceSvc := service.NewServiceService(client)
|
||||
resp, err := serviceSvc.Stop(ctx, uuid)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to stop service: %w", err)
|
||||
}
|
||||
|
||||
fmt.Println(resp.Message)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var restartServiceCmd = &cobra.Command{
|
||||
Use: "restart <uuid>",
|
||||
Short: "Restart a service",
|
||||
Long: `Restart a service (restart all containers).`,
|
||||
Args: exactArgs(1, "<uuid>"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := context.Background()
|
||||
uuid := args[0]
|
||||
|
||||
client, err := getAPIClient(cmd)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get API client: %w", err)
|
||||
}
|
||||
|
||||
serviceSvc := service.NewServiceService(client)
|
||||
resp, err := serviceSvc.Restart(ctx, uuid)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to restart service: %w", err)
|
||||
}
|
||||
|
||||
fmt.Println(resp.Message)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var deleteServiceCmd = &cobra.Command{
|
||||
Use: "delete <uuid>",
|
||||
Short: "Delete a service",
|
||||
Long: `Delete a service and optionally clean up its configurations, volumes, and networks.`,
|
||||
Args: exactArgs(1, "<uuid>"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := context.Background()
|
||||
uuid := args[0]
|
||||
|
||||
client, err := getAPIClient(cmd)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get API client: %w", err)
|
||||
}
|
||||
|
||||
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")
|
||||
|
||||
// Prompt for confirmation unless --force is used
|
||||
if !force {
|
||||
var response string
|
||||
fmt.Printf("Are you sure you want to delete this service? (yes/no): ")
|
||||
fmt.Scanln(&response)
|
||||
|
||||
if response != "yes" && response != "y" {
|
||||
fmt.Println("Delete cancelled.")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
serviceSvc := service.NewServiceService(client)
|
||||
err = serviceSvc.Delete(ctx, uuid, deleteConfigurations, deleteVolumes, dockerCleanup, deleteConnectedNetworks)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete service: %w", err)
|
||||
}
|
||||
|
||||
fmt.Println("Service deletion request queued.")
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var envsServiceCmd = &cobra.Command{
|
||||
Use: "env",
|
||||
Aliases: []string{"envs", "environment"},
|
||||
Short: "Manage service environment variables",
|
||||
Long: `Manage environment variables for a service. All commands require the service UUID first to establish context.`,
|
||||
}
|
||||
|
||||
var listServiceEnvsCmd = &cobra.Command{
|
||||
Use: "list <service_uuid>",
|
||||
Short: "List all environment variables for a service",
|
||||
Long: `List all environment variables for a specific service.`,
|
||||
Args: exactArgs(1, "<uuid>"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := context.Background()
|
||||
uuid := args[0]
|
||||
|
||||
client, err := getAPIClient(cmd)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get API client: %w", err)
|
||||
}
|
||||
|
||||
serviceSvc := service.NewServiceService(client)
|
||||
envs, err := serviceSvc.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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
},
|
||||
}
|
||||
|
||||
var getServiceEnvCmd = &cobra.Command{
|
||||
Use: "get <service_uuid> <env_uuid_or_key>",
|
||||
Short: "Get environment variable details",
|
||||
Long: `Get detailed information about a specific environment variable. First UUID is the service, second is the environment variable UUID or key name.`,
|
||||
Args: exactArgs(2, "<uuid1> <uuid2>"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := context.Background()
|
||||
serviceUUID := args[0]
|
||||
envUUID := args[1]
|
||||
|
||||
client, err := getAPIClient(cmd)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get API client: %w", err)
|
||||
}
|
||||
|
||||
serviceSvc := service.NewServiceService(client)
|
||||
env, err := serviceSvc.GetEnv(ctx, serviceUUID, 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
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
},
|
||||
}
|
||||
|
||||
var createServiceEnvCmd = &cobra.Command{
|
||||
Use: "create <service_uuid>",
|
||||
Short: "Create an environment variable for a service",
|
||||
Long: `Create a new environment variable for a specific service. Use --key and --value flags to specify the variable.`,
|
||||
Args: exactArgs(1, "<uuid>"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := context.Background()
|
||||
uuid := args[0]
|
||||
|
||||
client, err := 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")
|
||||
|
||||
if key == "" {
|
||||
return fmt.Errorf("--key is required")
|
||||
}
|
||||
if value == "" {
|
||||
return fmt.Errorf("--value is required")
|
||||
}
|
||||
|
||||
req := &models.EnvironmentVariableCreateRequest{
|
||||
Key: key,
|
||||
Value: value,
|
||||
}
|
||||
|
||||
// Only set flags if they were 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("is-multiline") {
|
||||
req.IsMultiline = &isMultiline
|
||||
}
|
||||
|
||||
serviceSvc := service.NewServiceService(client)
|
||||
env, err := serviceSvc.CreateEnv(ctx, uuid, req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create environment variable: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("Environment variable '%s' created successfully.\n", env.Key)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var updateServiceEnvCmd = &cobra.Command{
|
||||
Use: "update <service_uuid> <env_uuid>",
|
||||
Short: "Update an environment variable",
|
||||
Long: `Update an existing environment variable. First UUID is the service, second is the specific environment variable to update.`,
|
||||
Args: exactArgs(2, "<uuid1> <uuid2>"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := context.Background()
|
||||
serviceUUID := args[0]
|
||||
envUUID := args[1]
|
||||
|
||||
client, err := getAPIClient(cmd)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get API client: %w", err)
|
||||
}
|
||||
|
||||
req := &models.EnvironmentVariableUpdateRequest{
|
||||
UUID: envUUID,
|
||||
}
|
||||
|
||||
// Only set fields that were provided
|
||||
if cmd.Flags().Changed("key") {
|
||||
key, _ := cmd.Flags().GetString("key")
|
||||
req.Key = &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
|
||||
}
|
||||
|
||||
// Check if at least one field is being updated
|
||||
if req.Key == nil && req.Value == nil && req.IsBuildTime == nil && req.IsPreview == nil && req.IsLiteral == nil && req.IsMultiline == nil {
|
||||
return fmt.Errorf("at least one field must be provided to update (--key, --value, --build-time, --preview, --is-literal, or --is-multiline)")
|
||||
}
|
||||
|
||||
serviceSvc := service.NewServiceService(client)
|
||||
env, err := serviceSvc.UpdateEnv(ctx, serviceUUID, 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
|
||||
},
|
||||
}
|
||||
|
||||
var deleteServiceEnvCmd = &cobra.Command{
|
||||
Use: "delete <service_uuid> <env_uuid>",
|
||||
Short: "Delete an environment variable",
|
||||
Long: `Delete an environment variable from a service. First UUID is the service, second is the specific environment variable to delete.`,
|
||||
Args: exactArgs(2, "<uuid1> <uuid2>"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := context.Background()
|
||||
serviceUUID := args[0]
|
||||
envUUID := args[1]
|
||||
|
||||
client, err := 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
|
||||
}
|
||||
}
|
||||
|
||||
serviceSvc := service.NewServiceService(client)
|
||||
err = serviceSvc.DeleteEnv(ctx, serviceUUID, envUUID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete environment variable: %w", err)
|
||||
}
|
||||
|
||||
fmt.Println("Environment variable deleted successfully.")
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var syncServiceEnvCmd = &cobra.Command{
|
||||
Use: "sync <service_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 service env sync abc123 --file .env.production`,
|
||||
Args: exactArgs(1, "<uuid>"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := context.Background()
|
||||
uuid := args[0]
|
||||
|
||||
client, err := 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")
|
||||
|
||||
// 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
|
||||
serviceSvc := service.NewServiceService(client)
|
||||
existingEnvs, err := serviceSvc.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
|
||||
}
|
||||
|
||||
// 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 := serviceSvc.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 := serviceSvc.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
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
// Define delete command flags
|
||||
deleteServiceCmd.Flags().BoolP("force", "f", false, "Skip confirmation prompt")
|
||||
deleteServiceCmd.Flags().Bool("delete-configurations", true, "Delete configurations")
|
||||
deleteServiceCmd.Flags().Bool("delete-volumes", true, "Delete volumes")
|
||||
deleteServiceCmd.Flags().Bool("docker-cleanup", true, "Run docker cleanup")
|
||||
deleteServiceCmd.Flags().Bool("delete-connected-networks", true, "Delete connected networks")
|
||||
|
||||
// Define envs create command flags
|
||||
createServiceEnvCmd.Flags().String("key", "", "Environment variable key (required)")
|
||||
createServiceEnvCmd.Flags().String("value", "", "Environment variable value (required)")
|
||||
createServiceEnvCmd.Flags().Bool("build-time", false, "Available at build time")
|
||||
createServiceEnvCmd.Flags().Bool("preview", false, "Available in preview deployments")
|
||||
createServiceEnvCmd.Flags().Bool("is-literal", false, "Treat value as literal (don't interpolate variables)")
|
||||
createServiceEnvCmd.Flags().Bool("is-multiline", false, "Value is multiline")
|
||||
|
||||
// Define envs update command flags
|
||||
updateServiceEnvCmd.Flags().String("key", "", "New environment variable key")
|
||||
updateServiceEnvCmd.Flags().String("value", "", "New environment variable value")
|
||||
updateServiceEnvCmd.Flags().Bool("build-time", false, "Available at build time")
|
||||
updateServiceEnvCmd.Flags().Bool("preview", false, "Available in preview deployments")
|
||||
updateServiceEnvCmd.Flags().Bool("is-literal", false, "Treat value as literal (don't interpolate variables)")
|
||||
updateServiceEnvCmd.Flags().Bool("is-multiline", false, "Value is multiline")
|
||||
|
||||
// Define envs delete command flags
|
||||
deleteServiceEnvCmd.Flags().Bool("force", false, "Skip confirmation prompt")
|
||||
|
||||
// Define envs sync command flags
|
||||
syncServiceEnvCmd.Flags().StringP("file", "f", "", "Path to .env file (required)")
|
||||
syncServiceEnvCmd.Flags().Bool("build-time", false, "Make all variables available at build time")
|
||||
syncServiceEnvCmd.Flags().Bool("preview", false, "Make all variables available in preview deployments")
|
||||
syncServiceEnvCmd.Flags().Bool("is-literal", false, "Treat all values as literal (don't interpolate variables)")
|
||||
|
||||
rootCmd.AddCommand(servicesCmd)
|
||||
servicesCmd.AddCommand(listServicesCmd)
|
||||
servicesCmd.AddCommand(getServiceCmd)
|
||||
servicesCmd.AddCommand(startServiceCmd)
|
||||
servicesCmd.AddCommand(stopServiceCmd)
|
||||
servicesCmd.AddCommand(restartServiceCmd)
|
||||
servicesCmd.AddCommand(deleteServiceCmd)
|
||||
servicesCmd.AddCommand(envsServiceCmd)
|
||||
envsServiceCmd.AddCommand(listServiceEnvsCmd)
|
||||
envsServiceCmd.AddCommand(getServiceEnvCmd)
|
||||
envsServiceCmd.AddCommand(createServiceEnvCmd)
|
||||
envsServiceCmd.AddCommand(updateServiceEnvCmd)
|
||||
envsServiceCmd.AddCommand(deleteServiceEnvCmd)
|
||||
envsServiceCmd.AddCommand(syncServiceEnvCmd)
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestServicesCmd_Structure(t *testing.T) {
|
||||
cmd := servicesCmd
|
||||
|
||||
assert.Equal(t, "services", cmd.Use)
|
||||
assert.NotEmpty(t, cmd.Short)
|
||||
|
||||
// Check that subcommands are registered
|
||||
hasListCmd := false
|
||||
hasGetCmd := false
|
||||
hasStartCmd := false
|
||||
hasStopCmd := false
|
||||
hasRestartCmd := false
|
||||
|
||||
for _, subCmd := range cmd.Commands() {
|
||||
switch subCmd.Use {
|
||||
case "list":
|
||||
hasListCmd = true
|
||||
case "get <uuid>":
|
||||
hasGetCmd = true
|
||||
case "start <uuid>":
|
||||
hasStartCmd = true
|
||||
case "stop <uuid>":
|
||||
hasStopCmd = true
|
||||
case "restart <uuid>":
|
||||
hasRestartCmd = true
|
||||
}
|
||||
}
|
||||
|
||||
assert.True(t, hasListCmd, "list subcommand should be registered")
|
||||
assert.True(t, hasGetCmd, "get subcommand should be registered")
|
||||
assert.True(t, hasStartCmd, "start subcommand should be registered")
|
||||
assert.True(t, hasStopCmd, "stop subcommand should be registered")
|
||||
assert.True(t, hasRestartCmd, "restart subcommand should be registered")
|
||||
}
|
||||
|
||||
func TestServicesListCmd_Structure(t *testing.T) {
|
||||
cmd := listServicesCmd
|
||||
|
||||
assert.Equal(t, "list", cmd.Use)
|
||||
assert.NotNil(t, cmd.RunE)
|
||||
assert.Nil(t, cmd.Args) // list takes no arguments
|
||||
}
|
||||
|
||||
func TestServicesGetCmd_Args(t *testing.T) {
|
||||
cmd := getServiceCmd
|
||||
|
||||
// Test with no arguments - should fail
|
||||
err := cmd.Args(cmd, []string{})
|
||||
assert.Error(t, err, "should require exactly 1 argument")
|
||||
|
||||
// Test with correct number of arguments - should pass
|
||||
err = cmd.Args(cmd, []string{"service-uuid-123"})
|
||||
assert.NoError(t, err, "should accept 1 argument")
|
||||
|
||||
// Test with too many arguments - should fail
|
||||
err = cmd.Args(cmd, []string{"uuid1", "uuid2"})
|
||||
assert.Error(t, err, "should not accept more than 1 argument")
|
||||
}
|
||||
|
||||
func TestServicesGetCmd_Structure(t *testing.T) {
|
||||
cmd := getServiceCmd
|
||||
|
||||
assert.Equal(t, "get <uuid>", cmd.Use)
|
||||
assert.NotNil(t, cmd.RunE)
|
||||
assert.NotNil(t, cmd.Args)
|
||||
}
|
||||
|
||||
func TestServicesStartCmd_Args(t *testing.T) {
|
||||
cmd := startServiceCmd
|
||||
|
||||
// Test with no arguments - should fail
|
||||
err := cmd.Args(cmd, []string{})
|
||||
assert.Error(t, err, "should require exactly 1 argument")
|
||||
|
||||
// Test with correct number of arguments - should pass
|
||||
err = cmd.Args(cmd, []string{"service-uuid-123"})
|
||||
assert.NoError(t, err, "should accept 1 argument")
|
||||
|
||||
// Test with too many arguments - should fail
|
||||
err = cmd.Args(cmd, []string{"uuid1", "uuid2"})
|
||||
assert.Error(t, err, "should not accept more than 1 argument")
|
||||
}
|
||||
|
||||
func TestServicesStartCmd_Structure(t *testing.T) {
|
||||
cmd := startServiceCmd
|
||||
|
||||
assert.Equal(t, "start <uuid>", cmd.Use)
|
||||
assert.NotNil(t, cmd.RunE)
|
||||
assert.NotNil(t, cmd.Args)
|
||||
}
|
||||
|
||||
func TestServicesStopCmd_Args(t *testing.T) {
|
||||
cmd := stopServiceCmd
|
||||
|
||||
// Test with no arguments - should fail
|
||||
err := cmd.Args(cmd, []string{})
|
||||
assert.Error(t, err, "should require exactly 1 argument")
|
||||
|
||||
// Test with correct number of arguments - should pass
|
||||
err = cmd.Args(cmd, []string{"service-uuid-123"})
|
||||
assert.NoError(t, err, "should accept 1 argument")
|
||||
|
||||
// Test with too many arguments - should fail
|
||||
err = cmd.Args(cmd, []string{"uuid1", "uuid2"})
|
||||
assert.Error(t, err, "should not accept more than 1 argument")
|
||||
}
|
||||
|
||||
func TestServicesStopCmd_Structure(t *testing.T) {
|
||||
cmd := stopServiceCmd
|
||||
|
||||
assert.Equal(t, "stop <uuid>", cmd.Use)
|
||||
assert.NotNil(t, cmd.RunE)
|
||||
assert.NotNil(t, cmd.Args)
|
||||
}
|
||||
|
||||
func TestServicesRestartCmd_Args(t *testing.T) {
|
||||
cmd := restartServiceCmd
|
||||
|
||||
// Test with no arguments - should fail
|
||||
err := cmd.Args(cmd, []string{})
|
||||
assert.Error(t, err, "should require exactly 1 argument")
|
||||
|
||||
// Test with correct number of arguments - should pass
|
||||
err = cmd.Args(cmd, []string{"service-uuid-123"})
|
||||
assert.NoError(t, err, "should accept 1 argument")
|
||||
|
||||
// Test with too many arguments - should fail
|
||||
err = cmd.Args(cmd, []string{"uuid1", "uuid2"})
|
||||
assert.Error(t, err, "should not accept more than 1 argument")
|
||||
}
|
||||
|
||||
func TestServicesRestartCmd_Structure(t *testing.T) {
|
||||
cmd := restartServiceCmd
|
||||
|
||||
assert.Equal(t, "restart <uuid>", cmd.Use)
|
||||
assert.NotNil(t, cmd.RunE)
|
||||
assert.NotNil(t, cmd.Args)
|
||||
}
|
||||
|
||||
func TestServicesDeleteCmd_Args(t *testing.T) {
|
||||
cmd := deleteServiceCmd
|
||||
|
||||
// Test with no arguments - should fail
|
||||
err := cmd.Args(cmd, []string{})
|
||||
assert.Error(t, err, "should require exactly 1 argument")
|
||||
|
||||
// Test with correct number of arguments - should pass
|
||||
err = cmd.Args(cmd, []string{"service-uuid-123"})
|
||||
assert.NoError(t, err, "should accept 1 argument")
|
||||
|
||||
// Test with too many arguments - should fail
|
||||
err = cmd.Args(cmd, []string{"uuid1", "uuid2"})
|
||||
assert.Error(t, err, "should not accept more than 1 argument")
|
||||
}
|
||||
|
||||
func TestServicesDeleteCmd_Structure(t *testing.T) {
|
||||
cmd := deleteServiceCmd
|
||||
|
||||
assert.Equal(t, "delete <uuid>", cmd.Use)
|
||||
assert.NotNil(t, cmd.RunE)
|
||||
assert.NotNil(t, cmd.Args)
|
||||
|
||||
// Verify flags exist
|
||||
assert.NotNil(t, cmd.Flags().Lookup("force"))
|
||||
assert.NotNil(t, cmd.Flags().Lookup("delete-configurations"))
|
||||
assert.NotNil(t, cmd.Flags().Lookup("delete-volumes"))
|
||||
assert.NotNil(t, cmd.Flags().Lookup("docker-cleanup"))
|
||||
assert.NotNil(t, cmd.Flags().Lookup("delete-connected-networks"))
|
||||
}
|
||||
+171
@@ -0,0 +1,171 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/coollabsio/coolify-cli/internal/output"
|
||||
"github.com/coollabsio/coolify-cli/internal/service"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var teamsCmd = &cobra.Command{
|
||||
Use: "team",
|
||||
Aliases: []string{"teams"},
|
||||
Short: "Team related commands",
|
||||
Long: `Manage Coolify teams - list all teams, get team details, view current team, and manage team members.`,
|
||||
}
|
||||
|
||||
var listTeamsCmd = &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List all teams",
|
||||
Long: `List all teams you have access to.`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := context.Background()
|
||||
|
||||
client, err := getAPIClient(cmd)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get API client: %w", err)
|
||||
}
|
||||
|
||||
teamSvc := service.NewTeamService(client)
|
||||
teams, err := teamSvc.List(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to list teams: %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(teams)
|
||||
},
|
||||
}
|
||||
|
||||
var getTeamCmd = &cobra.Command{
|
||||
Use: "get <id>",
|
||||
Short: "Get team details by ID",
|
||||
Long: `Get detailed information about a specific team by its ID.`,
|
||||
Args: exactArgs(1, "<id>"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := context.Background()
|
||||
teamID := args[0]
|
||||
|
||||
client, err := getAPIClient(cmd)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get API client: %w", err)
|
||||
}
|
||||
|
||||
teamSvc := service.NewTeamService(client)
|
||||
team, err := teamSvc.Get(ctx, teamID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get team: %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(team)
|
||||
},
|
||||
}
|
||||
|
||||
var currentTeamCmd = &cobra.Command{
|
||||
Use: "current",
|
||||
Short: "Get currently authenticated team",
|
||||
Long: `Get details of the team associated with the current authentication token.`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := context.Background()
|
||||
|
||||
client, err := getAPIClient(cmd)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get API client: %w", err)
|
||||
}
|
||||
|
||||
teamSvc := service.NewTeamService(client)
|
||||
team, err := teamSvc.Current(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get current team: %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(team)
|
||||
},
|
||||
}
|
||||
|
||||
var teamMembersCmd = &cobra.Command{
|
||||
Use: "members",
|
||||
Short: "Team members related commands",
|
||||
Long: `Manage team members - list members of a specific team or the current team.`,
|
||||
}
|
||||
|
||||
var listTeamMembersCmd = &cobra.Command{
|
||||
Use: "list [team_id]",
|
||||
Short: "List team members",
|
||||
Long: `List members of a specific team by ID, or list members of the current team if no ID is provided.`,
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := context.Background()
|
||||
|
||||
client, err := getAPIClient(cmd)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get API client: %w", err)
|
||||
}
|
||||
|
||||
teamSvc := service.NewTeamService(client)
|
||||
|
||||
// If team ID provided, get members of that team
|
||||
// Otherwise get members of current team
|
||||
var members interface{}
|
||||
var membersErr error
|
||||
|
||||
if len(args) > 0 {
|
||||
teamID := args[0]
|
||||
members, membersErr = teamSvc.ListMembers(ctx, teamID)
|
||||
} else {
|
||||
members, membersErr = teamSvc.CurrentMembers(ctx)
|
||||
}
|
||||
|
||||
if membersErr != nil {
|
||||
return fmt.Errorf("failed to list team members: %w", membersErr)
|
||||
}
|
||||
|
||||
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(members); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !showSensitive && format == output.FormatTable {
|
||||
fmt.Println("\nNote: Use -s to show sensitive information.")
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
rootCmd.AddCommand(teamsCmd)
|
||||
teamsCmd.AddCommand(listTeamsCmd)
|
||||
teamsCmd.AddCommand(getTeamCmd)
|
||||
teamsCmd.AddCommand(currentTeamCmd)
|
||||
teamsCmd.AddCommand(teamMembersCmd)
|
||||
teamMembersCmd.AddCommand(listTeamMembersCmd)
|
||||
}
|
||||
Executable
+57
@@ -0,0 +1,57 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
echo "🔧 Setting up Coolify CLI workspace..."
|
||||
|
||||
# Check if Go is installed
|
||||
if ! command -v go &> /dev/null; then
|
||||
echo "❌ Error: Go is not installed"
|
||||
echo "Please install Go 1.24+ from https://go.dev/dl/"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check Go version
|
||||
GO_VERSION=$(go version | awk '{print $3}' | sed 's/go//')
|
||||
MAJOR_MINOR=$(echo $GO_VERSION | cut -d. -f1,2)
|
||||
|
||||
# Compare version (must be 1.24 or higher)
|
||||
if [ $(echo "$MAJOR_MINOR" | awk -F. '{print ($1 * 100) + $2}') -lt 124 ]; then
|
||||
echo "❌ Error: Go version 1.24+ is required"
|
||||
echo "Current version: $GO_VERSION"
|
||||
echo "Please upgrade Go from https://go.dev/dl/"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "✅ Go version $GO_VERSION detected"
|
||||
|
||||
# Download dependencies
|
||||
echo "📦 Downloading dependencies..."
|
||||
if ! go mod download; then
|
||||
echo "❌ Error: Failed to download dependencies"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "✅ Dependencies downloaded"
|
||||
|
||||
# Install air if not already installed
|
||||
if ! command -v air &> /dev/null; then
|
||||
echo "📦 Installing air (Go file watcher)..."
|
||||
if ! go install github.com/air-verse/air@latest; then
|
||||
echo "⚠️ Warning: Failed to install air, but continuing..."
|
||||
else
|
||||
echo "✅ air installed successfully"
|
||||
fi
|
||||
else
|
||||
echo "✅ air already installed"
|
||||
fi
|
||||
|
||||
# Build the binary
|
||||
echo "🔨 Building coolify binary..."
|
||||
if ! go build -o coolify .; then
|
||||
echo "❌ Error: Build failed"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "✅ Binary built successfully: ./coolify"
|
||||
echo "🎉 Workspace setup complete!"
|
||||
echo "🔥 Use the run script for hot reload during development"
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"scripts": {
|
||||
"setup": "./conductor-setup.sh",
|
||||
"run": "~/go/bin/air"
|
||||
},
|
||||
"runScriptMode": "nonconcurrent"
|
||||
}
|
||||
@@ -1,49 +1,47 @@
|
||||
module github.com/coollabsio/coolify-cli
|
||||
|
||||
go 1.22.0
|
||||
|
||||
toolchain go1.23.2
|
||||
go 1.24.6
|
||||
|
||||
require (
|
||||
github.com/adrg/xdg v0.5.3
|
||||
github.com/creativeprojects/go-selfupdate v1.4.0
|
||||
github.com/creativeprojects/go-selfupdate v1.5.1
|
||||
github.com/hashicorp/go-version v1.7.0
|
||||
github.com/spf13/cobra v1.8.1
|
||||
github.com/spf13/viper v1.19.0
|
||||
github.com/spf13/cobra v1.10.1
|
||||
github.com/spf13/viper v1.21.0
|
||||
github.com/stretchr/testify v1.11.1
|
||||
)
|
||||
|
||||
require (
|
||||
code.gitea.io/sdk/gitea v0.20.0 // indirect
|
||||
github.com/42wim/httpsig v1.2.2 // indirect
|
||||
github.com/Masterminds/semver/v3 v3.3.1 // indirect
|
||||
code.gitea.io/sdk/gitea v0.22.0 // indirect
|
||||
github.com/42wim/httpsig v1.2.3 // indirect
|
||||
github.com/Masterminds/semver/v3 v3.4.0 // indirect
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||
github.com/davidmz/go-pageant v1.0.2 // indirect
|
||||
github.com/fsnotify/fsnotify v1.8.0 // indirect
|
||||
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
||||
github.com/go-fed/httpsig v1.1.0 // indirect
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
|
||||
github.com/google/go-github/v30 v30.1.0 // indirect
|
||||
github.com/google/go-querystring v1.1.0 // indirect
|
||||
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
|
||||
github.com/hashicorp/go-retryablehttp v0.7.7 // indirect
|
||||
github.com/hashicorp/hcl v1.0.0 // indirect
|
||||
github.com/hashicorp/go-retryablehttp v0.7.8 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/magiconair/properties v1.8.9 // indirect
|
||||
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.3 // indirect
|
||||
github.com/sagikazarmark/locafero v0.7.0 // indirect
|
||||
github.com/sagikazarmark/slog-shim v0.1.0 // indirect
|
||||
github.com/sourcegraph/conc v0.3.0 // indirect
|
||||
github.com/spf13/afero v1.12.0 // indirect
|
||||
github.com/spf13/cast v1.7.1 // indirect
|
||||
github.com/spf13/pflag v1.0.5 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||
github.com/russross/blackfriday/v2 v2.1.0 // indirect
|
||||
github.com/sagikazarmark/locafero v0.12.0 // indirect
|
||||
github.com/spf13/afero v1.15.0 // indirect
|
||||
github.com/spf13/cast v1.10.0 // indirect
|
||||
github.com/spf13/pflag v1.0.10 // indirect
|
||||
github.com/subosito/gotenv v1.6.0 // indirect
|
||||
github.com/ulikunitz/xz v0.5.12 // indirect
|
||||
github.com/ulikunitz/xz v0.5.15 // indirect
|
||||
github.com/xanzy/go-gitlab v0.115.0 // indirect
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
golang.org/x/crypto v0.32.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20250106191152-7588d65b2ba8 // indirect
|
||||
golang.org/x/oauth2 v0.25.0 // indirect
|
||||
golang.org/x/sys v0.29.0 // indirect
|
||||
golang.org/x/text v0.21.0 // indirect
|
||||
golang.org/x/time v0.9.0 // indirect
|
||||
gopkg.in/ini.v1 v1.67.0 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||
golang.org/x/crypto v0.43.0 // indirect
|
||||
golang.org/x/oauth2 v0.32.0 // indirect
|
||||
golang.org/x/sys v0.37.0 // indirect
|
||||
golang.org/x/text v0.30.0 // indirect
|
||||
golang.org/x/time v0.14.0 // indirect
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
code.gitea.io/sdk/gitea v0.20.0 h1:Zm/QDwwZK1awoM4AxdjeAQbxolzx2rIP8dDfmKu+KoU=
|
||||
code.gitea.io/sdk/gitea v0.20.0/go.mod h1:faouBHC/zyx5wLgjmRKR62ydyvMzwWf3QnU0bH7Cw6U=
|
||||
github.com/42wim/httpsig v1.2.2 h1:ofAYoHUNs/MJOLqQ8hIxeyz2QxOz8qdSVvp3PX/oPgA=
|
||||
github.com/42wim/httpsig v1.2.2/go.mod h1:P/UYo7ytNBFwc+dg35IubuAUIs8zj5zzFIgUCEl55WY=
|
||||
github.com/Masterminds/semver/v3 v3.3.1 h1:QtNSWtVZ3nBfk8mAOu/B6v7FMJ+NHTIgUPi7rj+4nv4=
|
||||
github.com/Masterminds/semver/v3 v3.3.1/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
|
||||
code.gitea.io/sdk/gitea v0.22.0 h1:HCKq7bX/HQ85Nw7c/HAhWgRye+vBp5nQOE8Md1+9Ef0=
|
||||
code.gitea.io/sdk/gitea v0.22.0/go.mod h1:yyF5+GhljqvA30sRDreoyHILruNiy4ASufugzYg0VHM=
|
||||
github.com/42wim/httpsig v1.2.3 h1:xb0YyWhkYj57SPtfSttIobJUPJZB9as1nsfo7KWVcEs=
|
||||
github.com/42wim/httpsig v1.2.3/go.mod h1:nZq9OlYKDrUBhptd77IHx4/sZZD+IxTBADvAPI9G/EM=
|
||||
github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0=
|
||||
github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
|
||||
github.com/adrg/xdg v0.5.3 h1:xRnxJXne7+oWDatRhR1JLnvuccuIeCoBu2rtuLqQB78=
|
||||
github.com/adrg/xdg v0.5.3/go.mod h1:nlTsY+NNiCBGCK2tpm09vRqfVzrc2fLmXGpBLF0zlTQ=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
|
||||
github.com/creativeprojects/go-selfupdate v1.4.0 h1:4ePPd2CPCNl/YoPXeVxpuBLDUZh8rMEKP5ac+1Y/r5c=
|
||||
github.com/creativeprojects/go-selfupdate v1.4.0/go.mod h1:oPG7LmzEmS6OxfqEm620k5VKxP45xFZNKMkp4V5qqUY=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.7 h1:zbFlGlXEAKlwXpmvle3d8Oe3YnkKIK4xSRTd3sHPnBo=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||
github.com/creativeprojects/go-selfupdate v1.5.1 h1:fuyEGFFfqcC8SxDGolcEPYPLXGQ9Mcrc5uRyRG2Mqnk=
|
||||
github.com/creativeprojects/go-selfupdate v1.5.1/go.mod h1:2uY75rP8z/D/PBuDn6mlBnzu+ysEmwOJfcgF8np0JIM=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davidmz/go-pageant v1.0.2 h1:bPblRCh5jGU+Uptpz6LgMZGD5hJoOt7otgT454WvHn0=
|
||||
@@ -18,10 +19,12 @@ github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM=
|
||||
github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE=
|
||||
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||
github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M=
|
||||
github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||
github.com/go-fed/httpsig v1.1.0 h1:9M+hb0jkEICD8/cAiNqEB66R87tTINszBRTjwjQzWcI=
|
||||
github.com/go-fed/httpsig v1.1.0/go.mod h1:RCMrTZvN1bJYtofsG4rd5NaO5obxQ5xBkdiS7xsT7bM=
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
@@ -35,93 +38,81 @@ github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9n
|
||||
github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48=
|
||||
github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k=
|
||||
github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M=
|
||||
github.com/hashicorp/go-retryablehttp v0.7.7 h1:C8hUCYzor8PIfXHa4UrZkU4VvK8o9ISHxT2Q8+VepXU=
|
||||
github.com/hashicorp/go-retryablehttp v0.7.7/go.mod h1:pkQpWZeYWskR+D1tR2O5OcBFOxfA7DoAO6xtkuQnHTk=
|
||||
github.com/hashicorp/go-retryablehttp v0.7.8 h1:ylXZWnqa7Lhqpk0L1P1LzDtGcCR0rPVUrx/c8Unxc48=
|
||||
github.com/hashicorp/go-retryablehttp v0.7.8/go.mod h1:rjiScheydd+CxvumBsIrFKlx3iS0jrZ7LvzFGFmuKbw=
|
||||
github.com/hashicorp/go-version v1.7.0 h1:5tqGy27NaOTB8yJKUZELlFAS/LTKJkrmONwQKeRZfjY=
|
||||
github.com/hashicorp/go-version v1.7.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
|
||||
github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
|
||||
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
|
||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/magiconair/properties v1.8.9 h1:nWcCbLq1N2v/cpNsy5WvQ37Fb+YElfq20WJ/a8RkpQM=
|
||||
github.com/magiconair/properties v1.8.9/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
|
||||
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
||||
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
|
||||
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
|
||||
github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M=
|
||||
github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
|
||||
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
||||
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/sagikazarmark/locafero v0.7.0 h1:5MqpDsTGNDhY8sGp0Aowyf0qKsPrhewaLSsFaodPcyo=
|
||||
github.com/sagikazarmark/locafero v0.7.0/go.mod h1:2za3Cg5rMaTMoG/2Ulr9AwtFaIppKXTRYnozin4aB5k=
|
||||
github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE=
|
||||
github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ=
|
||||
github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
|
||||
github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0=
|
||||
github.com/spf13/afero v1.12.0 h1:UcOPyRBYczmFn6yvphxkn9ZEOY65cpwGKb5mL36mrqs=
|
||||
github.com/spf13/afero v1.12.0/go.mod h1:ZTlWwG4/ahT8W7T0WQ5uYmjI9duaLQGy3Q2OAl4sk/4=
|
||||
github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y=
|
||||
github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
|
||||
github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM=
|
||||
github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y=
|
||||
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
|
||||
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spf13/viper v1.19.0 h1:RWq5SEjt8o25SROyN3z2OrDB9l7RPd3lwTWU8EcEdcI=
|
||||
github.com/spf13/viper v1.19.0/go.mod h1:GQUN9bilAbhU/jgc1bKs99f/suXKeUMct8Adx5+Ntkg=
|
||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/sagikazarmark/locafero v0.12.0 h1:/NQhBAkUb4+fH1jivKHWusDYFjMOOKU88eegjfxfHb4=
|
||||
github.com/sagikazarmark/locafero v0.12.0/go.mod h1:sZh36u/YSZ918v0Io+U9ogLYQJ9tLLBmM4eneO6WwsI=
|
||||
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
|
||||
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
|
||||
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
|
||||
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
|
||||
github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s=
|
||||
github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0=
|
||||
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
|
||||
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
|
||||
github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
|
||||
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
|
||||
github.com/ulikunitz/xz v0.5.12 h1:37Nm15o69RwBkXM0J6A5OlE67RZTfzUxTj8fB3dfcsc=
|
||||
github.com/ulikunitz/xz v0.5.12/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
|
||||
github.com/ulikunitz/xz v0.5.15 h1:9DNdB5s+SgV3bQ2ApL10xRc35ck0DuIX/isZvIk+ubY=
|
||||
github.com/ulikunitz/xz v0.5.15/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
|
||||
github.com/xanzy/go-gitlab v0.115.0 h1:6DmtItNcVe+At/liXSgfE/DZNZrGfalQmBRmOcJjOn8=
|
||||
github.com/xanzy/go-gitlab v0.115.0/go.mod h1:5XCDtM7AM6WMKmfDdOiEpyRWUqui2iS9ILfvCZ2gJ5M=
|
||||
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
|
||||
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
||||
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8=
|
||||
golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc=
|
||||
golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc=
|
||||
golang.org/x/exp v0.0.0-20250106191152-7588d65b2ba8 h1:yqrTHse8TCMW1M1ZCP+VAR/l0kKxwaAIqN/il7x4voA=
|
||||
golang.org/x/exp v0.0.0-20250106191152-7588d65b2ba8/go.mod h1:tujkw807nyEEAamNbDrEGzRav+ilXA7PCRAd6xsmwiU=
|
||||
golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04=
|
||||
golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0=
|
||||
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||
golang.org/x/oauth2 v0.25.0 h1:CY4y7XT9v0cRI9oupztF8AgiIu99L/ksR/Xp/6jrZ70=
|
||||
golang.org/x/oauth2 v0.25.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
|
||||
golang.org/x/oauth2 v0.32.0 h1:jsCblLleRMDrxMN29H3z/k1KliIvpLgCkE6R8FXXNgY=
|
||||
golang.org/x/oauth2 v0.32.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
|
||||
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
|
||||
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.28.0 h1:/Ts8HFuMR2E6IP/jlo7QVLZHggjKQbhu/7H0LJFr3Gg=
|
||||
golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek=
|
||||
golang.org/x/term v0.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q=
|
||||
golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
|
||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||
golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY=
|
||||
golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
||||
golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
|
||||
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
|
||||
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
|
||||
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
|
||||
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"math"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultTimeout = 30 * time.Second
|
||||
defaultRetries = 3
|
||||
apiV1Path = "/api/v1/"
|
||||
)
|
||||
|
||||
// Client is the HTTP client for Coolify API
|
||||
type Client struct {
|
||||
baseURL string
|
||||
token string
|
||||
httpClient *http.Client
|
||||
debug bool
|
||||
retries int
|
||||
timeout time.Duration
|
||||
}
|
||||
|
||||
// NewClient creates a new API client
|
||||
func NewClient(baseURL, token string, opts ...Option) *Client {
|
||||
c := &Client{
|
||||
baseURL: baseURL,
|
||||
token: token,
|
||||
httpClient: &http.Client{},
|
||||
timeout: defaultTimeout,
|
||||
retries: defaultRetries,
|
||||
debug: false,
|
||||
}
|
||||
|
||||
// Apply options
|
||||
for _, opt := range opts {
|
||||
opt(c)
|
||||
}
|
||||
|
||||
// Set timeout on HTTP client
|
||||
c.httpClient.Timeout = c.timeout
|
||||
|
||||
return c
|
||||
}
|
||||
|
||||
// Get makes a GET request to the API
|
||||
func (c *Client) Get(ctx context.Context, path string, result interface{}) error {
|
||||
return c.doRequest(ctx, "GET", path, nil, result)
|
||||
}
|
||||
|
||||
// Post makes a POST request to the API
|
||||
func (c *Client) Post(ctx context.Context, path string, body, result interface{}) error {
|
||||
return c.doRequest(ctx, "POST", path, body, result)
|
||||
}
|
||||
|
||||
// Delete makes a DELETE request to the API
|
||||
func (c *Client) Delete(ctx context.Context, path string) error {
|
||||
return c.doRequest(ctx, "DELETE", path, nil, nil)
|
||||
}
|
||||
|
||||
// Patch makes a PATCH request to the API
|
||||
func (c *Client) Patch(ctx context.Context, path string, body, result interface{}) error {
|
||||
return c.doRequest(ctx, "PATCH", path, body, result)
|
||||
}
|
||||
|
||||
// GetVersion fetches the API version
|
||||
func (c *Client) GetVersion(ctx context.Context) (string, error) {
|
||||
var version string
|
||||
err := c.Get(ctx, "version", &version)
|
||||
return version, err
|
||||
}
|
||||
|
||||
// doRequest executes an HTTP request with retry logic
|
||||
func (c *Client) doRequest(ctx context.Context, method, path string, body, result interface{}) error {
|
||||
var lastErr error
|
||||
|
||||
for attempt := 0; attempt <= c.retries; attempt++ {
|
||||
if attempt > 0 {
|
||||
// Exponential backoff
|
||||
backoff := time.Duration(math.Pow(2, float64(attempt-1))) * time.Second
|
||||
// Always log retries so users know what's happening
|
||||
log.Printf("Request failed, retrying (attempt %d/%d) after %v...", attempt, c.retries, backoff)
|
||||
select {
|
||||
case <-time.After(backoff):
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
err := c.doRequestOnce(ctx, method, path, body, result)
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
lastErr = err
|
||||
|
||||
// Don't retry on client errors (4xx) except 429 (rate limit)
|
||||
if apiErr, ok := err.(*Error); ok {
|
||||
if apiErr.StatusCode >= 400 && apiErr.StatusCode < 500 && apiErr.StatusCode != 429 {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Don't retry on context cancellation
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
return lastErr
|
||||
}
|
||||
|
||||
// doRequestOnce executes a single HTTP request
|
||||
func (c *Client) doRequestOnce(ctx context.Context, method, path string, body, result interface{}) error {
|
||||
url := c.baseURL + apiV1Path + path
|
||||
|
||||
if c.debug {
|
||||
log.Printf("%s %s", method, url)
|
||||
}
|
||||
|
||||
// Prepare request body
|
||||
var bodyReader io.Reader
|
||||
if body != nil {
|
||||
jsonBody, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal request body: %w", err)
|
||||
}
|
||||
bodyReader = bytes.NewReader(jsonBody)
|
||||
|
||||
if c.debug {
|
||||
log.Printf("Request body: %s", string(jsonBody))
|
||||
}
|
||||
}
|
||||
|
||||
// Create request
|
||||
req, err := http.NewRequestWithContext(ctx, method, url, bodyReader)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
// Set headers
|
||||
req.Header.Set("Authorization", "Bearer "+c.token)
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
|
||||
// Execute request
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Read response body
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read response body: %w", err)
|
||||
}
|
||||
|
||||
if c.debug {
|
||||
log.Printf("Response status: %d", resp.StatusCode)
|
||||
log.Printf("Response body: %s", string(respBody))
|
||||
}
|
||||
|
||||
// Check status code
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
message := string(respBody)
|
||||
if message == "" {
|
||||
message = "Unknown error"
|
||||
} else {
|
||||
// Try to extract error message from JSON
|
||||
var errResp struct {
|
||||
Message string `json:"message"`
|
||||
Error string `json:"error"`
|
||||
}
|
||||
if err := json.Unmarshal(respBody, &errResp); err == nil {
|
||||
if errResp.Message != "" {
|
||||
message = errResp.Message
|
||||
} else if errResp.Error != "" {
|
||||
message = errResp.Error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return NewError(resp.StatusCode, path, message)
|
||||
}
|
||||
|
||||
// Unmarshal response into result
|
||||
if result != nil {
|
||||
// Handle string responses
|
||||
if strResult, ok := result.(*string); ok {
|
||||
*strResult = string(respBody)
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(respBody, result); err != nil {
|
||||
return fmt.Errorf("failed to unmarshal response: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestNewClient(t *testing.T) {
|
||||
t.Run("creates client with defaults", func(t *testing.T) {
|
||||
client := NewClient("https://app.coolify.io", "test-token")
|
||||
|
||||
assert.Equal(t, "https://app.coolify.io", client.baseURL)
|
||||
assert.Equal(t, "test-token", client.token)
|
||||
assert.Equal(t, defaultTimeout, client.timeout)
|
||||
assert.Equal(t, defaultRetries, client.retries)
|
||||
assert.False(t, client.debug)
|
||||
})
|
||||
|
||||
t.Run("applies options", func(t *testing.T) {
|
||||
customTimeout := 10 * time.Second
|
||||
client := NewClient(
|
||||
"https://app.coolify.io",
|
||||
"test-token",
|
||||
WithDebug(true),
|
||||
WithTimeout(customTimeout),
|
||||
WithRetries(5),
|
||||
)
|
||||
|
||||
assert.True(t, client.debug)
|
||||
assert.Equal(t, customTimeout, client.timeout)
|
||||
assert.Equal(t, 5, client.retries)
|
||||
})
|
||||
}
|
||||
|
||||
func TestClient_Get_Success(t *testing.T) {
|
||||
type ServerResponse struct {
|
||||
UUID string `json:"uuid"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "GET", r.Method)
|
||||
assert.Equal(t, "/api/v1/servers", r.URL.Path)
|
||||
assert.Equal(t, "Bearer test-token", r.Header.Get("Authorization"))
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode([]ServerResponse{
|
||||
{UUID: "uuid-1", Name: "server-1"},
|
||||
{UUID: "uuid-2", Name: "server-2"},
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(server.URL, "test-token")
|
||||
var servers []ServerResponse
|
||||
|
||||
err := client.Get(context.Background(), "servers", &servers)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, servers, 2)
|
||||
assert.Equal(t, "uuid-1", servers[0].UUID)
|
||||
assert.Equal(t, "server-1", servers[0].Name)
|
||||
}
|
||||
|
||||
func TestClient_Get_StringResponse(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "/api/v1/version", r.URL.Path)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte("4.0.0"))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(server.URL, "test-token")
|
||||
var version string
|
||||
|
||||
err := client.Get(context.Background(), "version", &version)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "4.0.0", version)
|
||||
}
|
||||
|
||||
func TestClient_Get_NotFound(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
json.NewEncoder(w).Encode(map[string]string{
|
||||
"message": "Server not found",
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(server.URL, "test-token")
|
||||
var result interface{}
|
||||
|
||||
err := client.Get(context.Background(), "servers/unknown", &result)
|
||||
|
||||
require.Error(t, err)
|
||||
assert.True(t, IsNotFound(err))
|
||||
|
||||
var apiErr *Error
|
||||
require.ErrorAs(t, err, &apiErr)
|
||||
assert.Equal(t, 404, apiErr.StatusCode)
|
||||
assert.Equal(t, "Server not found", apiErr.Message)
|
||||
}
|
||||
|
||||
func TestClient_Get_Unauthorized(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
json.NewEncoder(w).Encode(map[string]string{
|
||||
"message": "Invalid token",
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(server.URL, "invalid-token")
|
||||
var result interface{}
|
||||
|
||||
err := client.Get(context.Background(), "servers", &result)
|
||||
|
||||
require.Error(t, err)
|
||||
assert.True(t, IsUnauthorized(err))
|
||||
}
|
||||
|
||||
func TestClient_Post_Success(t *testing.T) {
|
||||
type CreateServerRequest struct {
|
||||
Name string `json:"name"`
|
||||
IP string `json:"ip"`
|
||||
}
|
||||
|
||||
type CreateServerResponse struct {
|
||||
UUID string `json:"uuid"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "POST", r.Method)
|
||||
assert.Equal(t, "/api/v1/servers", r.URL.Path)
|
||||
assert.Equal(t, "Bearer test-token", r.Header.Get("Authorization"))
|
||||
assert.Equal(t, "application/json", r.Header.Get("Content-Type"))
|
||||
|
||||
var req CreateServerRequest
|
||||
err := json.NewDecoder(r.Body).Decode(&req)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "test-server", req.Name)
|
||||
assert.Equal(t, "192.168.1.100", req.IP)
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode(CreateServerResponse{
|
||||
UUID: "new-uuid",
|
||||
Message: "Server created",
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(server.URL, "test-token")
|
||||
|
||||
requestBody := CreateServerRequest{
|
||||
Name: "test-server",
|
||||
IP: "192.168.1.100",
|
||||
}
|
||||
|
||||
var response CreateServerResponse
|
||||
err := client.Post(context.Background(), "servers", requestBody, &response)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "new-uuid", response.UUID)
|
||||
assert.Equal(t, "Server created", response.Message)
|
||||
}
|
||||
|
||||
func TestClient_Post_BadRequest(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
json.NewEncoder(w).Encode(map[string]string{
|
||||
"message": "Invalid IP address",
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(server.URL, "test-token")
|
||||
|
||||
requestBody := map[string]string{"ip": "invalid"}
|
||||
var response interface{}
|
||||
|
||||
err := client.Post(context.Background(), "servers", requestBody, &response)
|
||||
|
||||
require.Error(t, err)
|
||||
assert.True(t, IsBadRequest(err))
|
||||
}
|
||||
|
||||
func TestClient_Delete_Success(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "DELETE", r.Method)
|
||||
assert.Equal(t, "/api/v1/servers/test-uuid", r.URL.Path)
|
||||
assert.Equal(t, "Bearer test-token", r.Header.Get("Authorization"))
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode(map[string]string{
|
||||
"message": "Server deleted",
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(server.URL, "test-token")
|
||||
|
||||
err := client.Delete(context.Background(), "servers/test-uuid")
|
||||
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestClient_GetVersion(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "/api/v1/version", r.URL.Path)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte("4.0.0-beta.383"))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(server.URL, "test-token")
|
||||
|
||||
version, err := client.GetVersion(context.Background())
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "4.0.0-beta.383", version)
|
||||
}
|
||||
|
||||
func TestClient_Retry_Success(t *testing.T) {
|
||||
attempts := 0
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
attempts++
|
||||
if attempts < 3 {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte("success"))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(server.URL, "test-token", WithRetries(3))
|
||||
var result string
|
||||
|
||||
err := client.Get(context.Background(), "test", &result)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "success", result)
|
||||
assert.Equal(t, 3, attempts)
|
||||
}
|
||||
|
||||
func TestClient_Retry_NoRetryOn4xx(t *testing.T) {
|
||||
attempts := 0
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
attempts++
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
json.NewEncoder(w).Encode(map[string]string{"message": "Bad request"})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(server.URL, "test-token", WithRetries(3))
|
||||
var result interface{}
|
||||
|
||||
err := client.Get(context.Background(), "test", &result)
|
||||
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, 1, attempts) // Should not retry on 400
|
||||
assert.True(t, IsBadRequest(err))
|
||||
}
|
||||
|
||||
func TestClient_ContextCancellation(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(server.URL, "test-token")
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
// Cancel immediately
|
||||
cancel()
|
||||
|
||||
var result interface{}
|
||||
err := client.Get(ctx, "test", &result)
|
||||
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, context.Canceled, err)
|
||||
}
|
||||
|
||||
func TestClient_Timeout(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
time.Sleep(2 * time.Second)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(server.URL, "test-token", WithTimeout(100*time.Millisecond))
|
||||
var result interface{}
|
||||
|
||||
err := client.Get(context.Background(), "test", &result)
|
||||
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "deadline exceeded")
|
||||
}
|
||||
|
||||
func TestClient_Debug(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte("test"))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
// This test just verifies debug mode doesn't crash
|
||||
// In real usage, debug logs would go to stdout
|
||||
client := NewClient(server.URL, "test-token", WithDebug(true))
|
||||
var result string
|
||||
|
||||
err := client.Get(context.Background(), "test", &result)
|
||||
|
||||
require.NoError(t, err)
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// Error represents an API error response
|
||||
type Error struct {
|
||||
StatusCode int
|
||||
Message string
|
||||
Path string
|
||||
}
|
||||
|
||||
// Error implements the error interface
|
||||
func (e *Error) Error() string {
|
||||
if e.Message != "" {
|
||||
return fmt.Sprintf("API error %d on %s: %s", e.StatusCode, e.Path, e.Message)
|
||||
}
|
||||
return fmt.Sprintf("API error %d on %s", e.StatusCode, e.Path)
|
||||
}
|
||||
|
||||
// NewError creates a new API error
|
||||
func NewError(statusCode int, path, message string) *Error {
|
||||
return &Error{
|
||||
StatusCode: statusCode,
|
||||
Path: path,
|
||||
Message: message,
|
||||
}
|
||||
}
|
||||
|
||||
// IsNotFound checks if the error is a 404 Not Found error
|
||||
func IsNotFound(err error) bool {
|
||||
var apiErr *Error
|
||||
if errors.As(err, &apiErr) {
|
||||
return apiErr.StatusCode == 404
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// IsUnauthorized checks if the error is a 401 or 403 error
|
||||
func IsUnauthorized(err error) bool {
|
||||
var apiErr *Error
|
||||
if errors.As(err, &apiErr) {
|
||||
return apiErr.StatusCode == 401 || apiErr.StatusCode == 403
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// IsBadRequest checks if the error is a 400 Bad Request error
|
||||
func IsBadRequest(err error) bool {
|
||||
var apiErr *Error
|
||||
if errors.As(err, &apiErr) {
|
||||
return apiErr.StatusCode == 400
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// IsServerError checks if the error is a 5xx server error
|
||||
func IsServerError(err error) bool {
|
||||
var apiErr *Error
|
||||
if errors.As(err, &apiErr) {
|
||||
return apiErr.StatusCode >= 500 && apiErr.StatusCode < 600
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Option configures the API client
|
||||
type Option func(*Client)
|
||||
|
||||
// WithDebug enables debug logging
|
||||
func WithDebug(debug bool) Option {
|
||||
return func(c *Client) {
|
||||
c.debug = debug
|
||||
}
|
||||
}
|
||||
|
||||
// WithTimeout sets the request timeout
|
||||
func WithTimeout(timeout time.Duration) Option {
|
||||
return func(c *Client) {
|
||||
c.timeout = timeout
|
||||
}
|
||||
}
|
||||
|
||||
// WithRetries sets the number of retries for failed requests
|
||||
func WithRetries(retries int) Option {
|
||||
return func(c *Client) {
|
||||
c.retries = retries
|
||||
}
|
||||
}
|
||||
|
||||
// WithHTTPClient sets a custom HTTP client
|
||||
func WithHTTPClient(client *http.Client) Option {
|
||||
return func(c *Client) {
|
||||
c.httpClient = client
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/adrg/xdg"
|
||||
)
|
||||
|
||||
// Config holds all CLI configuration
|
||||
type Config struct {
|
||||
Instances []Instance `json:"instances"`
|
||||
LastUpdateCheckTime string `json:"lastUpdateCheckTime"`
|
||||
path string // config file path (not serialized)
|
||||
}
|
||||
|
||||
// New creates a new config with default values
|
||||
func New() *Config {
|
||||
return &Config{
|
||||
Instances: []Instance{},
|
||||
LastUpdateCheckTime: time.Now().Format(time.RFC3339),
|
||||
path: Path(),
|
||||
}
|
||||
}
|
||||
|
||||
// Load loads config from the default location
|
||||
func Load() (*Config, error) {
|
||||
return LoadFromFile(Path())
|
||||
}
|
||||
|
||||
// Save saves config to the default location
|
||||
func (c *Config) Save() error {
|
||||
c.path = Path()
|
||||
return SaveToFile(c.path, c)
|
||||
}
|
||||
|
||||
// GetDefault returns the default instance
|
||||
func (c *Config) GetDefault() (*Instance, error) {
|
||||
for i := range c.Instances {
|
||||
if c.Instances[i].Default {
|
||||
return &c.Instances[i], nil
|
||||
}
|
||||
}
|
||||
return nil, errors.New("no default instance configured")
|
||||
}
|
||||
|
||||
// SetDefault sets the default instance by name
|
||||
func (c *Config) SetDefault(name string) error {
|
||||
found := false
|
||||
for i := range c.Instances {
|
||||
if c.Instances[i].Name == name {
|
||||
c.Instances[i].Default = true
|
||||
found = true
|
||||
} else {
|
||||
c.Instances[i].Default = false
|
||||
}
|
||||
}
|
||||
|
||||
if !found {
|
||||
return fmt.Errorf("instance '%s' not found", name)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// AddInstance adds a new instance to the configuration
|
||||
func (c *Config) AddInstance(instance Instance) error {
|
||||
// Validate instance
|
||||
if err := instance.Validate(); err != nil {
|
||||
return fmt.Errorf("invalid instance: %w", err)
|
||||
}
|
||||
|
||||
// Check for duplicate name
|
||||
for i := range c.Instances {
|
||||
if c.Instances[i].Name == instance.Name {
|
||||
return fmt.Errorf("instance '%s' already exists", instance.Name)
|
||||
}
|
||||
}
|
||||
|
||||
// If this is the first instance or marked as default, make it default
|
||||
if len(c.Instances) == 0 || instance.Default {
|
||||
// Clear other defaults
|
||||
for i := range c.Instances {
|
||||
c.Instances[i].Default = false
|
||||
}
|
||||
instance.Default = true
|
||||
}
|
||||
|
||||
c.Instances = append(c.Instances, instance)
|
||||
return nil
|
||||
}
|
||||
|
||||
// RemoveInstance removes an instance by name
|
||||
func (c *Config) RemoveInstance(name string) error {
|
||||
for i := range c.Instances {
|
||||
if c.Instances[i].Name == name {
|
||||
wasDefault := c.Instances[i].Default
|
||||
|
||||
// Remove instance
|
||||
c.Instances = append(c.Instances[:i], c.Instances[i+1:]...)
|
||||
|
||||
// If it was default, make the first instance default
|
||||
if wasDefault && len(c.Instances) > 0 {
|
||||
c.Instances[0].Default = true
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("instance '%s' not found", name)
|
||||
}
|
||||
|
||||
// GetInstance gets an instance by name
|
||||
func (c *Config) GetInstance(name string) (*Instance, error) {
|
||||
for i := range c.Instances {
|
||||
if c.Instances[i].Name == name {
|
||||
return &c.Instances[i], nil
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("instance '%s' not found", name)
|
||||
}
|
||||
|
||||
// UpdateInstanceToken updates the token for an instance
|
||||
func (c *Config) UpdateInstanceToken(name, token string) error {
|
||||
instance, err := c.GetInstance(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if token == "" {
|
||||
return errors.New("token cannot be empty")
|
||||
}
|
||||
|
||||
instance.Token = token
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListInstances returns all instances
|
||||
func (c *Config) ListInstances() []Instance {
|
||||
return c.Instances
|
||||
}
|
||||
|
||||
// Validate validates the entire config
|
||||
func (c *Config) Validate() error {
|
||||
if len(c.Instances) == 0 {
|
||||
return errors.New("no instances configured")
|
||||
}
|
||||
|
||||
// Validate each instance
|
||||
for i, instance := range c.Instances {
|
||||
if err := instance.Validate(); err != nil {
|
||||
return fmt.Errorf("instance %d (%s) is invalid: %w", i, instance.Name, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Check for duplicate names
|
||||
names := make(map[string]bool)
|
||||
for _, instance := range c.Instances {
|
||||
if names[instance.Name] {
|
||||
return fmt.Errorf("duplicate instance name: %s", instance.Name)
|
||||
}
|
||||
names[instance.Name] = true
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Path returns the default config file path
|
||||
// Linux/macOS: ~/.config/coolify/config.json
|
||||
// Windows: %APPDATA%\coolify\config.json (e.g., C:\Users\username\AppData\Roaming\coolify\config.json)
|
||||
func Path() string {
|
||||
homeDir, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
// Fallback to xdg if home dir fails
|
||||
return filepath.Join(xdg.ConfigHome, "coolify", "config.json")
|
||||
}
|
||||
|
||||
// Windows uses AppData/Roaming
|
||||
if filepath.Separator == '\\' {
|
||||
appData := os.Getenv("APPDATA")
|
||||
if appData != "" {
|
||||
return filepath.Join(appData, "coolify", "config.json")
|
||||
}
|
||||
// Fallback for Windows if APPDATA not set
|
||||
return filepath.Join(homeDir, "AppData", "Roaming", "coolify", "config.json")
|
||||
}
|
||||
|
||||
// Unix-like systems (Linux, macOS, BSD, etc.)
|
||||
return filepath.Join(homeDir, ".config", "coolify", "config.json")
|
||||
}
|
||||
@@ -0,0 +1,565 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestInstance_Validate(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
instance Instance
|
||||
wantErr bool
|
||||
errMsg string
|
||||
}{
|
||||
{
|
||||
name: "valid instance",
|
||||
instance: Instance{
|
||||
Name: "test",
|
||||
FQDN: "https://app.coolify.io",
|
||||
Token: "test-token",
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "valid instance with http",
|
||||
instance: Instance{
|
||||
Name: "test",
|
||||
FQDN: "http://localhost:8000",
|
||||
Token: "test-token",
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "empty name",
|
||||
instance: Instance{
|
||||
Name: "",
|
||||
FQDN: "https://app.coolify.io",
|
||||
Token: "test-token",
|
||||
},
|
||||
wantErr: true,
|
||||
errMsg: "name cannot be empty",
|
||||
},
|
||||
{
|
||||
name: "empty FQDN",
|
||||
instance: Instance{
|
||||
Name: "test",
|
||||
FQDN: "",
|
||||
Token: "test-token",
|
||||
},
|
||||
wantErr: true,
|
||||
errMsg: "FQDN cannot be empty",
|
||||
},
|
||||
{
|
||||
name: "invalid FQDN (no protocol)",
|
||||
instance: Instance{
|
||||
Name: "test",
|
||||
FQDN: "app.coolify.io",
|
||||
Token: "test-token",
|
||||
},
|
||||
wantErr: true,
|
||||
errMsg: "must start with http",
|
||||
},
|
||||
{
|
||||
name: "empty token",
|
||||
instance: Instance{
|
||||
Name: "test",
|
||||
FQDN: "https://app.coolify.io",
|
||||
Token: "",
|
||||
},
|
||||
wantErr: true,
|
||||
errMsg: "token cannot be empty",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := tt.instance.Validate()
|
||||
|
||||
if tt.wantErr {
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), tt.errMsg)
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNew(t *testing.T) {
|
||||
cfg := New()
|
||||
|
||||
assert.NotNil(t, cfg)
|
||||
assert.Empty(t, cfg.Instances)
|
||||
assert.NotEmpty(t, cfg.LastUpdateCheckTime)
|
||||
}
|
||||
|
||||
func TestConfig_AddInstance(t *testing.T) {
|
||||
t.Run("add first instance makes it default", func(t *testing.T) {
|
||||
cfg := New()
|
||||
|
||||
instance := Instance{
|
||||
Name: "test",
|
||||
FQDN: "https://app.coolify.io",
|
||||
Token: "test-token",
|
||||
}
|
||||
|
||||
err := cfg.AddInstance(instance)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, cfg.Instances, 1)
|
||||
assert.True(t, cfg.Instances[0].Default)
|
||||
})
|
||||
|
||||
t.Run("add second instance keeps first as default", func(t *testing.T) {
|
||||
cfg := New()
|
||||
|
||||
cfg.AddInstance(Instance{
|
||||
Name: "first",
|
||||
FQDN: "https://first.io",
|
||||
Token: "token1",
|
||||
})
|
||||
|
||||
err := cfg.AddInstance(Instance{
|
||||
Name: "second",
|
||||
FQDN: "https://second.io",
|
||||
Token: "token2",
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, cfg.Instances, 2)
|
||||
assert.True(t, cfg.Instances[0].Default)
|
||||
assert.False(t, cfg.Instances[1].Default)
|
||||
})
|
||||
|
||||
t.Run("add instance with default flag", func(t *testing.T) {
|
||||
cfg := New()
|
||||
|
||||
cfg.AddInstance(Instance{
|
||||
Name: "first",
|
||||
FQDN: "https://first.io",
|
||||
Token: "token1",
|
||||
})
|
||||
|
||||
err := cfg.AddInstance(Instance{
|
||||
Name: "second",
|
||||
FQDN: "https://second.io",
|
||||
Token: "token2",
|
||||
Default: true,
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.False(t, cfg.Instances[0].Default)
|
||||
assert.True(t, cfg.Instances[1].Default)
|
||||
})
|
||||
|
||||
t.Run("duplicate name returns error", func(t *testing.T) {
|
||||
cfg := New()
|
||||
|
||||
cfg.AddInstance(Instance{
|
||||
Name: "test",
|
||||
FQDN: "https://test.io",
|
||||
Token: "token1",
|
||||
})
|
||||
|
||||
err := cfg.AddInstance(Instance{
|
||||
Name: "test",
|
||||
FQDN: "https://other.io",
|
||||
Token: "token2",
|
||||
})
|
||||
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "already exists")
|
||||
})
|
||||
|
||||
t.Run("invalid instance returns error", func(t *testing.T) {
|
||||
cfg := New()
|
||||
|
||||
err := cfg.AddInstance(Instance{
|
||||
Name: "",
|
||||
FQDN: "https://test.io",
|
||||
Token: "token",
|
||||
})
|
||||
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "invalid instance")
|
||||
})
|
||||
}
|
||||
|
||||
func TestConfig_RemoveInstance(t *testing.T) {
|
||||
t.Run("remove existing instance", func(t *testing.T) {
|
||||
cfg := New()
|
||||
cfg.AddInstance(Instance{
|
||||
Name: "first",
|
||||
FQDN: "https://first.io",
|
||||
Token: "token1",
|
||||
})
|
||||
cfg.AddInstance(Instance{
|
||||
Name: "second",
|
||||
FQDN: "https://second.io",
|
||||
Token: "token2",
|
||||
})
|
||||
|
||||
err := cfg.RemoveInstance("second")
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, cfg.Instances, 1)
|
||||
assert.Equal(t, "first", cfg.Instances[0].Name)
|
||||
})
|
||||
|
||||
t.Run("remove default instance makes first default", func(t *testing.T) {
|
||||
cfg := New()
|
||||
cfg.AddInstance(Instance{
|
||||
Name: "first",
|
||||
FQDN: "https://first.io",
|
||||
Token: "token1",
|
||||
})
|
||||
cfg.AddInstance(Instance{
|
||||
Name: "second",
|
||||
FQDN: "https://second.io",
|
||||
Token: "token2",
|
||||
})
|
||||
cfg.SetDefault("second")
|
||||
|
||||
err := cfg.RemoveInstance("second")
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.True(t, cfg.Instances[0].Default)
|
||||
})
|
||||
|
||||
t.Run("remove non-existent instance returns error", func(t *testing.T) {
|
||||
cfg := New()
|
||||
|
||||
err := cfg.RemoveInstance("nonexistent")
|
||||
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "not found")
|
||||
})
|
||||
}
|
||||
|
||||
func TestConfig_GetInstance(t *testing.T) {
|
||||
cfg := New()
|
||||
cfg.AddInstance(Instance{
|
||||
Name: "test",
|
||||
FQDN: "https://test.io",
|
||||
Token: "test-token",
|
||||
})
|
||||
|
||||
t.Run("get existing instance", func(t *testing.T) {
|
||||
instance, err := cfg.GetInstance("test")
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "test", instance.Name)
|
||||
assert.Equal(t, "https://test.io", instance.FQDN)
|
||||
})
|
||||
|
||||
t.Run("get non-existent instance", func(t *testing.T) {
|
||||
_, err := cfg.GetInstance("nonexistent")
|
||||
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "not found")
|
||||
})
|
||||
}
|
||||
|
||||
func TestConfig_GetDefault(t *testing.T) {
|
||||
t.Run("get default instance", func(t *testing.T) {
|
||||
cfg := New()
|
||||
cfg.AddInstance(Instance{
|
||||
Name: "test",
|
||||
FQDN: "https://test.io",
|
||||
Token: "test-token",
|
||||
Default: true,
|
||||
})
|
||||
|
||||
instance, err := cfg.GetDefault()
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "test", instance.Name)
|
||||
})
|
||||
|
||||
t.Run("no default instance", func(t *testing.T) {
|
||||
cfg := New()
|
||||
|
||||
_, err := cfg.GetDefault()
|
||||
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "no default instance")
|
||||
})
|
||||
}
|
||||
|
||||
func TestConfig_SetDefault(t *testing.T) {
|
||||
t.Run("set existing instance as default", func(t *testing.T) {
|
||||
cfg := New()
|
||||
cfg.AddInstance(Instance{
|
||||
Name: "first",
|
||||
FQDN: "https://first.io",
|
||||
Token: "token1",
|
||||
})
|
||||
cfg.AddInstance(Instance{
|
||||
Name: "second",
|
||||
FQDN: "https://second.io",
|
||||
Token: "token2",
|
||||
})
|
||||
|
||||
err := cfg.SetDefault("second")
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.False(t, cfg.Instances[0].Default)
|
||||
assert.True(t, cfg.Instances[1].Default)
|
||||
})
|
||||
|
||||
t.Run("set non-existent instance returns error", func(t *testing.T) {
|
||||
cfg := New()
|
||||
|
||||
err := cfg.SetDefault("nonexistent")
|
||||
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "not found")
|
||||
})
|
||||
}
|
||||
|
||||
func TestConfig_UpdateInstanceToken(t *testing.T) {
|
||||
t.Run("update existing instance token", func(t *testing.T) {
|
||||
cfg := New()
|
||||
cfg.AddInstance(Instance{
|
||||
Name: "test",
|
||||
FQDN: "https://test.io",
|
||||
Token: "old-token",
|
||||
})
|
||||
|
||||
err := cfg.UpdateInstanceToken("test", "new-token")
|
||||
|
||||
require.NoError(t, err)
|
||||
instance, _ := cfg.GetInstance("test")
|
||||
assert.Equal(t, "new-token", instance.Token)
|
||||
})
|
||||
|
||||
t.Run("update non-existent instance", func(t *testing.T) {
|
||||
cfg := New()
|
||||
|
||||
err := cfg.UpdateInstanceToken("nonexistent", "token")
|
||||
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "not found")
|
||||
})
|
||||
|
||||
t.Run("empty token returns error", func(t *testing.T) {
|
||||
cfg := New()
|
||||
cfg.AddInstance(Instance{
|
||||
Name: "test",
|
||||
FQDN: "https://test.io",
|
||||
Token: "old-token",
|
||||
})
|
||||
|
||||
err := cfg.UpdateInstanceToken("test", "")
|
||||
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "cannot be empty")
|
||||
})
|
||||
}
|
||||
|
||||
func TestConfig_Validate(t *testing.T) {
|
||||
t.Run("valid config", func(t *testing.T) {
|
||||
cfg := New()
|
||||
cfg.AddInstance(Instance{
|
||||
Name: "test",
|
||||
FQDN: "https://test.io",
|
||||
Token: "token",
|
||||
})
|
||||
|
||||
err := cfg.Validate()
|
||||
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("empty instances", func(t *testing.T) {
|
||||
cfg := New()
|
||||
|
||||
err := cfg.Validate()
|
||||
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "no instances")
|
||||
})
|
||||
|
||||
t.Run("invalid instance", func(t *testing.T) {
|
||||
cfg := New()
|
||||
// Bypass AddInstance validation
|
||||
cfg.Instances = append(cfg.Instances, Instance{
|
||||
Name: "",
|
||||
FQDN: "https://test.io",
|
||||
Token: "token",
|
||||
})
|
||||
|
||||
err := cfg.Validate()
|
||||
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "is invalid")
|
||||
})
|
||||
|
||||
t.Run("duplicate names", func(t *testing.T) {
|
||||
cfg := New()
|
||||
// Bypass AddInstance validation
|
||||
cfg.Instances = append(cfg.Instances, Instance{
|
||||
Name: "test",
|
||||
FQDN: "https://test1.io",
|
||||
Token: "token1",
|
||||
})
|
||||
cfg.Instances = append(cfg.Instances, Instance{
|
||||
Name: "test",
|
||||
FQDN: "https://test2.io",
|
||||
Token: "token2",
|
||||
})
|
||||
|
||||
err := cfg.Validate()
|
||||
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "duplicate")
|
||||
})
|
||||
}
|
||||
|
||||
func TestLoadFromFile(t *testing.T) {
|
||||
t.Run("load valid config", func(t *testing.T) {
|
||||
// Create temp file
|
||||
tmpDir := t.TempDir()
|
||||
configPath := filepath.Join(tmpDir, "config.json")
|
||||
|
||||
validConfig := Config{
|
||||
Instances: []Instance{
|
||||
{
|
||||
Name: "test",
|
||||
FQDN: "https://test.io",
|
||||
Token: "test-token",
|
||||
Default: true,
|
||||
},
|
||||
},
|
||||
LastUpdateCheckTime: "2025-10-14T12:00:00Z",
|
||||
}
|
||||
|
||||
data, _ := json.Marshal(validConfig)
|
||||
os.WriteFile(configPath, data, 0600)
|
||||
|
||||
// Load config
|
||||
cfg, err := LoadFromFile(configPath)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, cfg.Instances, 1)
|
||||
assert.Equal(t, "test", cfg.Instances[0].Name)
|
||||
})
|
||||
|
||||
t.Run("file not found", func(t *testing.T) {
|
||||
_, err := LoadFromFile("/nonexistent/config.json")
|
||||
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "not found")
|
||||
})
|
||||
|
||||
t.Run("invalid JSON", func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
configPath := filepath.Join(tmpDir, "config.json")
|
||||
|
||||
os.WriteFile(configPath, []byte("invalid json"), 0600)
|
||||
|
||||
_, err := LoadFromFile(configPath)
|
||||
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "failed to parse")
|
||||
})
|
||||
}
|
||||
|
||||
func TestSaveToFile(t *testing.T) {
|
||||
t.Run("save valid config", func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
configPath := filepath.Join(tmpDir, "config.json")
|
||||
|
||||
cfg := New()
|
||||
cfg.AddInstance(Instance{
|
||||
Name: "test",
|
||||
FQDN: "https://test.io",
|
||||
Token: "test-token",
|
||||
})
|
||||
|
||||
err := SaveToFile(configPath, cfg)
|
||||
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify file was created
|
||||
assert.FileExists(t, configPath)
|
||||
|
||||
// Verify content
|
||||
data, _ := os.ReadFile(configPath)
|
||||
var loaded Config
|
||||
json.Unmarshal(data, &loaded)
|
||||
assert.Len(t, loaded.Instances, 1)
|
||||
assert.Equal(t, "test", loaded.Instances[0].Name)
|
||||
})
|
||||
|
||||
t.Run("nil config", func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
configPath := filepath.Join(tmpDir, "config.json")
|
||||
|
||||
err := SaveToFile(configPath, nil)
|
||||
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "cannot be nil")
|
||||
})
|
||||
}
|
||||
|
||||
func TestCreateDefault(t *testing.T) {
|
||||
// Test that CreateDefault sets up proper instances
|
||||
cfg := New()
|
||||
|
||||
// Simulate CreateDefault
|
||||
cfg.Instances = append(cfg.Instances, Instance{
|
||||
Name: "cloud",
|
||||
FQDN: "https://app.coolify.io",
|
||||
Token: "",
|
||||
Default: true,
|
||||
})
|
||||
|
||||
cfg.Instances = append(cfg.Instances, Instance{
|
||||
Name: "localhost",
|
||||
FQDN: "http://localhost:8000",
|
||||
Token: "root",
|
||||
})
|
||||
|
||||
// Verify instances
|
||||
assert.Len(t, cfg.Instances, 2)
|
||||
assert.Equal(t, "cloud", cfg.Instances[0].Name)
|
||||
assert.Equal(t, "localhost", cfg.Instances[1].Name)
|
||||
assert.Equal(t, "root", cfg.Instances[1].Token)
|
||||
assert.True(t, cfg.Instances[0].Default)
|
||||
assert.False(t, cfg.Instances[1].Default)
|
||||
}
|
||||
|
||||
func TestPath(t *testing.T) {
|
||||
path := Path()
|
||||
|
||||
// Should not be empty
|
||||
assert.NotEmpty(t, path)
|
||||
|
||||
// Should contain "coolify"
|
||||
assert.Contains(t, path, "coolify")
|
||||
|
||||
// Should end with config.json
|
||||
assert.Contains(t, path, "config.json")
|
||||
|
||||
// On Unix systems, should contain .config
|
||||
// On Windows, should contain AppData or Roaming
|
||||
if filepath.Separator == '/' {
|
||||
assert.Contains(t, path, ".config")
|
||||
} else {
|
||||
// Windows path should contain either AppData or backslashes
|
||||
assert.True(t,
|
||||
filepath.Separator == '\\' &&
|
||||
(os.Getenv("APPDATA") != "" || filepath.IsAbs(path)),
|
||||
"Windows path should be valid",
|
||||
)
|
||||
}
|
||||
|
||||
t.Logf("Config path: %s", path)
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Instance represents a Coolify instance configuration
|
||||
type Instance struct {
|
||||
Name string `json:"name"`
|
||||
FQDN string `json:"fqdn"`
|
||||
Token string `json:"token"`
|
||||
Default bool `json:"default,omitempty"`
|
||||
}
|
||||
|
||||
// Validate validates the instance configuration
|
||||
func (i *Instance) Validate() error {
|
||||
if strings.TrimSpace(i.Name) == "" {
|
||||
return errors.New("instance name cannot be empty")
|
||||
}
|
||||
|
||||
if strings.TrimSpace(i.FQDN) == "" {
|
||||
return errors.New("instance FQDN cannot be empty")
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(i.FQDN, "http://") && !strings.HasPrefix(i.FQDN, "https://") {
|
||||
return fmt.Errorf("instance FQDN must start with http:// or https://")
|
||||
}
|
||||
|
||||
if strings.TrimSpace(i.Token) == "" {
|
||||
return errors.New("instance token cannot be empty")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// LoadFromFile loads config from a specific file path
|
||||
func LoadFromFile(path string) (*Config, error) {
|
||||
// Check if file exists
|
||||
if !fileExists(path) {
|
||||
return nil, fmt.Errorf("config file not found: %s", path)
|
||||
}
|
||||
|
||||
// Read file
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read config file: %w", err)
|
||||
}
|
||||
|
||||
// Unmarshal JSON
|
||||
var cfg Config
|
||||
if err := json.Unmarshal(data, &cfg); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse config file: %w", err)
|
||||
}
|
||||
|
||||
cfg.path = path
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
// SaveToFile saves config to a specific file path
|
||||
func SaveToFile(path string, cfg *Config) error {
|
||||
if cfg == nil {
|
||||
return errors.New("config cannot be nil")
|
||||
}
|
||||
|
||||
// Ensure directory exists
|
||||
dir := filepath.Dir(path)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return fmt.Errorf("failed to create config directory: %w", err)
|
||||
}
|
||||
|
||||
// Marshal to JSON
|
||||
data, err := json.MarshalIndent(cfg, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal config: %w", err)
|
||||
}
|
||||
|
||||
// Write file
|
||||
if err := os.WriteFile(path, data, 0600); err != nil {
|
||||
return fmt.Errorf("failed to write config file: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Exists checks if the config file exists at the default location
|
||||
func Exists() bool {
|
||||
return fileExists(Path())
|
||||
}
|
||||
|
||||
// CreateDefault creates a default config file with cloud and localhost instances
|
||||
func CreateDefault() error {
|
||||
cfg := New()
|
||||
|
||||
// Add default cloud instance
|
||||
cfg.Instances = append(cfg.Instances, Instance{
|
||||
Name: "cloud",
|
||||
FQDN: "https://app.coolify.io",
|
||||
Token: "",
|
||||
Default: true,
|
||||
})
|
||||
|
||||
// Add localhost instance
|
||||
cfg.Instances = append(cfg.Instances, Instance{
|
||||
Name: "localhost",
|
||||
FQDN: "http://localhost:8000",
|
||||
Token: "root",
|
||||
})
|
||||
|
||||
return cfg.Save()
|
||||
}
|
||||
|
||||
// fileExists checks if a file exists
|
||||
func fileExists(path string) bool {
|
||||
_, err := os.Stat(path)
|
||||
return err == nil
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
package models
|
||||
|
||||
// Application represents a Coolify application
|
||||
type Application struct {
|
||||
ID int `json:"-" table:"-"`
|
||||
UUID string `json:"uuid"`
|
||||
Name string `json:"name"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
Status string `json:"status"`
|
||||
GitBranch *string `json:"git_branch,omitempty"`
|
||||
FQDN *string `json:"fqdn,omitempty"`
|
||||
CreatedAt string `json:"-" table:"-"`
|
||||
UpdatedAt string `json:"-" table:"-"`
|
||||
}
|
||||
|
||||
// ApplicationListItem represents a simplified application for list view
|
||||
type ApplicationListItem struct {
|
||||
UUID string `json:"uuid"`
|
||||
Name string `json:"name"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
Status string `json:"status"`
|
||||
GitBranch *string `json:"git_branch,omitempty"`
|
||||
FQDN *string `json:"fqdn,omitempty"`
|
||||
}
|
||||
|
||||
// ApplicationUpdateRequest represents the request to update an application
|
||||
// All fields are optional - only provided fields will be updated
|
||||
type ApplicationUpdateRequest struct {
|
||||
Name *string `json:"name,omitempty"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
GitBranch *string `json:"git_branch,omitempty"`
|
||||
GitRepository *string `json:"git_repository,omitempty"`
|
||||
GitCommitSHA *string `json:"git_commit_sha,omitempty"`
|
||||
Domains *string `json:"domains,omitempty"`
|
||||
BuildCommand *string `json:"build_command,omitempty"`
|
||||
StartCommand *string `json:"start_command,omitempty"`
|
||||
InstallCommand *string `json:"install_command,omitempty"`
|
||||
BaseDirectory *string `json:"base_directory,omitempty"`
|
||||
PublishDirectory *string `json:"publish_directory,omitempty"`
|
||||
BuildPack *string `json:"build_pack,omitempty"`
|
||||
PortsExposes *string `json:"ports_exposes,omitempty"`
|
||||
PortsMappings *string `json:"ports_mappings,omitempty"`
|
||||
|
||||
// Docker configuration
|
||||
Dockerfile *string `json:"dockerfile,omitempty"`
|
||||
DockerRegistryImageName *string `json:"docker_registry_image_name,omitempty"`
|
||||
DockerRegistryImageTag *string `json:"docker_registry_image_tag,omitempty"`
|
||||
CustomDockerRunOptions *string `json:"custom_docker_run_options,omitempty"`
|
||||
CustomLabels *string `json:"custom_labels,omitempty"`
|
||||
|
||||
// Health checks
|
||||
HealthCheckEnabled *bool `json:"health_check_enabled,omitempty"`
|
||||
HealthCheckPath *string `json:"health_check_path,omitempty"`
|
||||
HealthCheckPort *string `json:"health_check_port,omitempty"`
|
||||
HealthCheckHost *string `json:"health_check_host,omitempty"`
|
||||
HealthCheckMethod *string `json:"health_check_method,omitempty"`
|
||||
HealthCheckScheme *string `json:"health_check_scheme,omitempty"`
|
||||
HealthCheckReturnCode *int `json:"health_check_return_code,omitempty"`
|
||||
HealthCheckResponseText *string `json:"health_check_response_text,omitempty"`
|
||||
HealthCheckInterval *int `json:"health_check_interval,omitempty"`
|
||||
HealthCheckTimeout *int `json:"health_check_timeout,omitempty"`
|
||||
HealthCheckRetries *int `json:"health_check_retries,omitempty"`
|
||||
HealthCheckStartPeriod *int `json:"health_check_start_period,omitempty"`
|
||||
|
||||
// Resource limits
|
||||
LimitsCPUs *string `json:"limits_cpus,omitempty"`
|
||||
LimitsCPUShares *int `json:"limits_cpu_shares,omitempty"`
|
||||
LimitsCPUSet *string `json:"limits_cpuset,omitempty"`
|
||||
LimitsMemory *string `json:"limits_memory,omitempty"`
|
||||
LimitsMemoryReservation *string `json:"limits_memory_reservation,omitempty"`
|
||||
LimitsMemorySwap *string `json:"limits_memory_swap,omitempty"`
|
||||
LimitsMemorySwappiness *int `json:"limits_memory_swappiness,omitempty"`
|
||||
|
||||
// Deployment hooks
|
||||
PreDeploymentCommand *string `json:"pre_deployment_command,omitempty"`
|
||||
PreDeploymentCommandContainer *string `json:"pre_deployment_command_container,omitempty"`
|
||||
PostDeploymentCommand *string `json:"post_deployment_command,omitempty"`
|
||||
PostDeploymentCommandContainer *string `json:"post_deployment_command_container,omitempty"`
|
||||
|
||||
// Misc
|
||||
Redirect *string `json:"redirect,omitempty"`
|
||||
WatchPaths *string `json:"watch_paths,omitempty"`
|
||||
IsStatic *bool `json:"is_static,omitempty"`
|
||||
}
|
||||
|
||||
// ApplicationLifecycleResponse represents the response from lifecycle operations
|
||||
type ApplicationLifecycleResponse struct {
|
||||
Message string `json:"message"`
|
||||
DeploymentUUID *string `json:"deployment_uuid,omitempty"`
|
||||
}
|
||||
|
||||
// ApplicationLogsResponse represents the response from logs endpoint
|
||||
type ApplicationLogsResponse struct {
|
||||
Logs string `json:"logs"`
|
||||
}
|
||||
|
||||
// EnvironmentVariable represents an environment variable for an application
|
||||
type EnvironmentVariable struct {
|
||||
ID int `json:"-" table:"-"`
|
||||
UUID string `json:"uuid"`
|
||||
Key string `json:"key"`
|
||||
Value string `json:"value"`
|
||||
IsBuildTime bool `json:"is_build_time"`
|
||||
IsPreview bool `json:"is_preview"`
|
||||
IsLiteralValue bool `json:"is_literal"`
|
||||
IsShownOnce bool `json:"is_shown_once"`
|
||||
RealValue *string `json:"real_value,omitempty"`
|
||||
ApplicationID *int `json:"-" table:"-"`
|
||||
CreatedAt string `json:"-" table:"-"`
|
||||
UpdatedAt string `json:"-" table:"-"`
|
||||
}
|
||||
|
||||
// EnvironmentVariableCreateRequest represents the request to create an environment variable
|
||||
type EnvironmentVariableCreateRequest struct {
|
||||
Key string `json:"key"`
|
||||
Value string `json:"value"`
|
||||
IsBuildTime *bool `json:"is_build_time,omitempty"`
|
||||
IsPreview *bool `json:"is_preview,omitempty"`
|
||||
IsLiteral *bool `json:"is_literal,omitempty"`
|
||||
IsMultiline *bool `json:"is_multiline,omitempty"`
|
||||
}
|
||||
|
||||
// EnvironmentVariableUpdateRequest represents the request to update an environment variable
|
||||
type EnvironmentVariableUpdateRequest struct {
|
||||
UUID string `json:"uuid"`
|
||||
Key *string `json:"key,omitempty"`
|
||||
Value *string `json:"value,omitempty"`
|
||||
IsBuildTime *bool `json:"is_build_time,omitempty"`
|
||||
IsPreview *bool `json:"is_preview,omitempty"`
|
||||
IsLiteral *bool `json:"is_literal,omitempty"`
|
||||
IsMultiline *bool `json:"is_multiline,omitempty"`
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package models
|
||||
|
||||
// Response wraps common API response fields
|
||||
type Response struct {
|
||||
Message string `json:"message,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
UUID string `json:"uuid,omitempty"`
|
||||
}
|
||||
|
||||
// UUID is a common UUID field
|
||||
type UUID struct {
|
||||
UUID string `json:"uuid"`
|
||||
}
|
||||
|
||||
// Timestamps for created/updated times
|
||||
type Timestamps struct{
|
||||
CreatedAt string `json:"-" table:"-"`
|
||||
UpdatedAt string `json:"-" table:"-"`
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
package models
|
||||
|
||||
// Database represents a standalone Coolify database
|
||||
type Database struct {
|
||||
ID int `json:"-" table:"-"`
|
||||
UUID string `json:"uuid"`
|
||||
Name string `json:"name"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
Image *string `json:"image,omitempty"`
|
||||
Status string `json:"status"`
|
||||
Type string `json:"type"` // postgresql, mysql, mongodb, redis, etc.
|
||||
|
||||
// Network configuration
|
||||
IsPublic *bool `json:"is_public,omitempty"`
|
||||
PublicPort *int `json:"public_port,omitempty"`
|
||||
|
||||
// Resource limits (hidden from CLI output)
|
||||
LimitsMemory *string `json:"limits_memory,omitempty" table:"-"`
|
||||
LimitsMemorySwap *string `json:"limits_memory_swap,omitempty" table:"-"`
|
||||
LimitsMemorySwappiness *int `json:"limits_memory_swappiness,omitempty" table:"-"`
|
||||
LimitsMemoryReservation *string `json:"limits_memory_reservation,omitempty" table:"-"`
|
||||
LimitsCpus *string `json:"limits_cpus,omitempty" table:"-"`
|
||||
LimitsCpuset *string `json:"limits_cpuset,omitempty" table:"-"`
|
||||
LimitsCpuShares *int `json:"limits_cpu_shares,omitempty" table:"-"`
|
||||
|
||||
// PostgreSQL specific
|
||||
PostgresUser *string `json:"postgres_user,omitempty" table:"-"`
|
||||
PostgresPassword *string `json:"postgres_password,omitempty" table:"-"`
|
||||
PostgresDb *string `json:"postgres_db,omitempty" table:"-"`
|
||||
PostgresInitdbArgs *string `json:"postgres_initdb_args,omitempty" table:"-"`
|
||||
PostgresHostAuthMethod *string `json:"postgres_host_auth_method,omitempty" table:"-"`
|
||||
PostgresConf *string `json:"postgres_conf,omitempty" table:"-"`
|
||||
|
||||
// MySQL specific
|
||||
MysqlRootPassword *string `json:"mysql_root_password,omitempty" table:"-"`
|
||||
MysqlPassword *string `json:"mysql_password,omitempty" table:"-"`
|
||||
MysqlUser *string `json:"mysql_user,omitempty" table:"-"`
|
||||
MysqlDatabase *string `json:"mysql_database,omitempty" table:"-"`
|
||||
MysqlConf *string `json:"mysql_conf,omitempty" table:"-"`
|
||||
|
||||
// MariaDB specific
|
||||
MariadbRootPassword *string `json:"mariadb_root_password,omitempty" table:"-"`
|
||||
MariadbPassword *string `json:"mariadb_password,omitempty" table:"-"`
|
||||
MariadbUser *string `json:"mariadb_user,omitempty" table:"-"`
|
||||
MariadbDatabase *string `json:"mariadb_database,omitempty" table:"-"`
|
||||
MariadbConf *string `json:"mariadb_conf,omitempty" table:"-"`
|
||||
|
||||
// MongoDB specific
|
||||
MongoInitdbRootUsername *string `json:"mongo_initdb_root_username,omitempty" table:"-"`
|
||||
MongoInitdbRootPassword *string `json:"mongo_initdb_root_password,omitempty" table:"-"`
|
||||
MongoInitdbDatabase *string `json:"mongo_initdb_database,omitempty" table:"-"`
|
||||
MongoConf *string `json:"mongo_conf,omitempty" table:"-"`
|
||||
|
||||
// Redis specific
|
||||
RedisPassword *string `json:"redis_password,omitempty" table:"-"`
|
||||
RedisConf *string `json:"redis_conf,omitempty" table:"-"`
|
||||
|
||||
// KeyDB specific
|
||||
KeydbPassword *string `json:"keydb_password,omitempty" table:"-"`
|
||||
KeydbConf *string `json:"keydb_conf,omitempty" table:"-"`
|
||||
|
||||
// Clickhouse specific
|
||||
ClickhouseAdminUser *string `json:"clickhouse_admin_user,omitempty" table:"-"`
|
||||
ClickhouseAdminPassword *string `json:"clickhouse_admin_password,omitempty" table:"-"`
|
||||
|
||||
// Dragonfly specific
|
||||
DragonflyPassword *string `json:"dragonfly_password,omitempty" table:"-"`
|
||||
|
||||
// Relationship IDs - internal database IDs (hidden from output)
|
||||
ServerID *int `json:"-" table:"-"`
|
||||
EnvironmentID *int `json:"-" table:"-"`
|
||||
ProjectID *int `json:"-" table:"-"`
|
||||
|
||||
// Metadata
|
||||
CreatedAt string `json:"-" table:"-"`
|
||||
UpdatedAt string `json:"-" table:"-"`
|
||||
}
|
||||
|
||||
// DatabaseCreateRequest represents the base request to create a database
|
||||
type DatabaseCreateRequest struct {
|
||||
ServerUUID string `json:"server_uuid"`
|
||||
ProjectUUID string `json:"project_uuid"`
|
||||
EnvironmentName *string `json:"environment_name,omitempty"`
|
||||
EnvironmentUUID *string `json:"environment_uuid,omitempty"`
|
||||
DestinationUUID *string `json:"destination_uuid,omitempty"`
|
||||
InstantDeploy *bool `json:"instant_deploy,omitempty"`
|
||||
|
||||
// Common fields
|
||||
Name *string `json:"name,omitempty"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
Image *string `json:"image,omitempty"`
|
||||
IsPublic *bool `json:"is_public,omitempty"`
|
||||
PublicPort *int `json:"public_port,omitempty"`
|
||||
|
||||
// Resource limits (hidden from CLI output)
|
||||
LimitsMemory *string `json:"limits_memory,omitempty" table:"-"`
|
||||
LimitsMemorySwap *string `json:"limits_memory_swap,omitempty" table:"-"`
|
||||
LimitsMemorySwappiness *int `json:"limits_memory_swappiness,omitempty" table:"-"`
|
||||
LimitsMemoryReservation *string `json:"limits_memory_reservation,omitempty" table:"-"`
|
||||
LimitsCpus *string `json:"limits_cpus,omitempty" table:"-"`
|
||||
LimitsCpuset *string `json:"limits_cpuset,omitempty" table:"-"`
|
||||
LimitsCpuShares *int `json:"limits_cpu_shares,omitempty" table:"-"`
|
||||
|
||||
// PostgreSQL specific
|
||||
PostgresUser *string `json:"postgres_user,omitempty"`
|
||||
PostgresPassword *string `json:"postgres_password,omitempty"`
|
||||
PostgresDb *string `json:"postgres_db,omitempty" table:"-"`
|
||||
PostgresInitdbArgs *string `json:"postgres_initdb_args,omitempty"`
|
||||
PostgresHostAuthMethod *string `json:"postgres_host_auth_method,omitempty"`
|
||||
PostgresConf *string `json:"postgres_conf,omitempty"`
|
||||
|
||||
// MySQL specific
|
||||
MysqlRootPassword *string `json:"mysql_root_password,omitempty"`
|
||||
MysqlPassword *string `json:"mysql_password,omitempty"`
|
||||
MysqlUser *string `json:"mysql_user,omitempty"`
|
||||
MysqlDatabase *string `json:"mysql_database,omitempty" table:"-"`
|
||||
MysqlConf *string `json:"mysql_conf,omitempty"`
|
||||
|
||||
// MariaDB specific
|
||||
MariadbRootPassword *string `json:"mariadb_root_password,omitempty"`
|
||||
MariadbPassword *string `json:"mariadb_password,omitempty"`
|
||||
MariadbUser *string `json:"mariadb_user,omitempty"`
|
||||
MariadbDatabase *string `json:"mariadb_database,omitempty" table:"-"`
|
||||
MariadbConf *string `json:"mariadb_conf,omitempty"`
|
||||
|
||||
// MongoDB specific
|
||||
MongoInitdbRootUsername *string `json:"mongo_initdb_root_username,omitempty"`
|
||||
MongoInitdbRootPassword *string `json:"mongo_initdb_root_password,omitempty"`
|
||||
MongoInitdbDatabase *string `json:"mongo_initdb_database,omitempty" table:"-"`
|
||||
MongoConf *string `json:"mongo_conf,omitempty"`
|
||||
|
||||
// Redis specific
|
||||
RedisPassword *string `json:"redis_password,omitempty"`
|
||||
RedisConf *string `json:"redis_conf,omitempty"`
|
||||
|
||||
// KeyDB specific
|
||||
KeydbPassword *string `json:"keydb_password,omitempty"`
|
||||
KeydbConf *string `json:"keydb_conf,omitempty"`
|
||||
|
||||
// Clickhouse specific
|
||||
ClickhouseAdminUser *string `json:"clickhouse_admin_user,omitempty"`
|
||||
ClickhouseAdminPassword *string `json:"clickhouse_admin_password,omitempty"`
|
||||
|
||||
// Dragonfly specific
|
||||
DragonflyPassword *string `json:"dragonfly_password,omitempty"`
|
||||
}
|
||||
|
||||
// DatabaseUpdateRequest represents the request to update a database
|
||||
// Only common configuration fields that make sense to update after creation
|
||||
type DatabaseUpdateRequest struct {
|
||||
Name *string `json:"name,omitempty"`
|
||||
Image *string `json:"image,omitempty"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
IsPublic *bool `json:"is_public,omitempty"`
|
||||
PublicPort *int `json:"public_port,omitempty"`
|
||||
|
||||
// Resource limits
|
||||
LimitsMemory *string `json:"limits_memory,omitempty"`
|
||||
LimitsCpus *string `json:"limits_cpus,omitempty"`
|
||||
}
|
||||
|
||||
// DatabaseLifecycleResponse represents the response from lifecycle operations
|
||||
type DatabaseLifecycleResponse struct {
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// DatabaseBackup represents a scheduled database backup configuration
|
||||
type DatabaseBackup struct {
|
||||
ID int `json:"-" table:"-"`
|
||||
UUID string `json:"uuid"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
Enabled *bool `json:"enabled,omitempty"`
|
||||
Frequency *string `json:"frequency,omitempty"`
|
||||
SaveS3 *bool `json:"save_s3,omitempty"`
|
||||
S3StorageID *int `json:"-" table:"-"`
|
||||
DatabasesToBackup *string `json:"databases_to_backup,omitempty"`
|
||||
DumpAll *bool `json:"dump_all,omitempty"`
|
||||
DatabaseBackupRetentionAmountLocally *int `json:"database_backup_retention_amount_locally,omitempty"`
|
||||
DatabaseBackupRetentionDaysLocally *int `json:"database_backup_retention_days_locally,omitempty"`
|
||||
DatabaseBackupRetentionMaxStorageLocally *string `json:"database_backup_retention_max_storage_locally,omitempty"`
|
||||
DatabaseBackupRetentionAmountS3 *int `json:"database_backup_retention_amount_s3,omitempty"`
|
||||
DatabaseBackupRetentionDaysS3 *int `json:"database_backup_retention_days_s3,omitempty"`
|
||||
DatabaseBackupRetentionMaxStorageS3 *string `json:"database_backup_retention_max_storage_s3,omitempty"`
|
||||
DatabaseType *string `json:"database_type,omitempty" table:"-"`
|
||||
DatabaseID *int `json:"-" table:"-"`
|
||||
TeamID *int `json:"-" table:"-"`
|
||||
Timeout *int `json:"timeout,omitempty"`
|
||||
DisableLocalBackup *bool `json:"disable_local_backup,omitempty"`
|
||||
CreatedAt string `json:"-" table:"-"`
|
||||
UpdatedAt string `json:"-" table:"-"`
|
||||
}
|
||||
|
||||
// DatabaseBackupCreateRequest represents the request to create a backup configuration
|
||||
type DatabaseBackupCreateRequest struct {
|
||||
Frequency *string `json:"frequency,omitempty"`
|
||||
Enabled *bool `json:"enabled,omitempty"`
|
||||
SaveS3 *bool `json:"save_s3,omitempty"`
|
||||
S3StorageUUID *string `json:"s3_storage_uuid,omitempty"`
|
||||
DatabasesToBackup *string `json:"databases_to_backup,omitempty"`
|
||||
DumpAll *bool `json:"dump_all,omitempty"`
|
||||
DatabaseBackupRetentionAmountLocally *int `json:"database_backup_retention_amount_locally,omitempty"`
|
||||
DatabaseBackupRetentionDaysLocally *int `json:"database_backup_retention_days_locally,omitempty"`
|
||||
DatabaseBackupRetentionMaxStorageLocally *string `json:"database_backup_retention_max_storage_locally,omitempty"`
|
||||
DatabaseBackupRetentionAmountS3 *int `json:"database_backup_retention_amount_s3,omitempty"`
|
||||
DatabaseBackupRetentionDaysS3 *int `json:"database_backup_retention_days_s3,omitempty"`
|
||||
DatabaseBackupRetentionMaxStorageS3 *string `json:"database_backup_retention_max_storage_s3,omitempty"`
|
||||
Timeout *int `json:"timeout,omitempty"`
|
||||
DisableLocalBackup *bool `json:"disable_local_backup,omitempty"`
|
||||
}
|
||||
|
||||
// DatabaseBackupUpdateRequest represents the request to update a backup configuration
|
||||
type DatabaseBackupUpdateRequest struct {
|
||||
SaveS3 *bool `json:"save_s3,omitempty"`
|
||||
S3StorageUUID *string `json:"s3_storage_uuid,omitempty"`
|
||||
BackupNow *bool `json:"backup_now,omitempty"`
|
||||
Enabled *bool `json:"enabled,omitempty"`
|
||||
DatabasesToBackup *string `json:"databases_to_backup,omitempty"`
|
||||
DumpAll *bool `json:"dump_all,omitempty"`
|
||||
Frequency *string `json:"frequency,omitempty"`
|
||||
DatabaseBackupRetentionAmountLocally *int `json:"database_backup_retention_amount_locally,omitempty"`
|
||||
DatabaseBackupRetentionDaysLocally *int `json:"database_backup_retention_days_locally,omitempty"`
|
||||
DatabaseBackupRetentionMaxStorageLocally *int `json:"database_backup_retention_max_storage_locally,omitempty"`
|
||||
DatabaseBackupRetentionAmountS3 *int `json:"database_backup_retention_amount_s3,omitempty"`
|
||||
DatabaseBackupRetentionDaysS3 *int `json:"database_backup_retention_days_s3,omitempty"`
|
||||
DatabaseBackupRetentionMaxStorageS3 *int `json:"database_backup_retention_max_storage_s3,omitempty"`
|
||||
}
|
||||
|
||||
// DatabaseBackupExecution represents a single backup execution
|
||||
type DatabaseBackupExecution struct {
|
||||
UUID string `json:"uuid"`
|
||||
Filename *string `json:"filename,omitempty"`
|
||||
Size *int `json:"size,omitempty"`
|
||||
Status *string `json:"status,omitempty"`
|
||||
Message *string `json:"message,omitempty"`
|
||||
CreatedAt string `json:"-" table:"-"`
|
||||
}
|
||||
|
||||
// DatabaseBackupExecutionsResponse represents the response containing backup executions
|
||||
type DatabaseBackupExecutionsResponse struct {
|
||||
Executions []DatabaseBackupExecution `json:"executions"`
|
||||
}
|
||||
|
||||
// DatabaseBackupResponse represents a generic backup operation response
|
||||
type DatabaseBackupResponse struct {
|
||||
Message string `json:"message"`
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package models
|
||||
|
||||
// Deployment represents a deployment operation
|
||||
type Deployment struct {
|
||||
ID int `json:"id" table:"-"`
|
||||
UUID string `json:"deployment_uuid"`
|
||||
ApplicationID *string `json:"application_id,omitempty" table:"-"`
|
||||
ApplicationName *string `json:"application_name,omitempty"`
|
||||
ServerName *string `json:"server_name,omitempty"`
|
||||
Status string `json:"status"`
|
||||
Commit *string `json:"commit,omitempty"`
|
||||
CommitMessage *string `json:"commit_message,omitempty" table:"-"`
|
||||
// Additional fields from API that we want to ignore
|
||||
DeploymentURL *string `json:"deployment_url,omitempty" table:"-"`
|
||||
FinishedAt *string `json:"finished_at,omitempty" table:"-"`
|
||||
Logs *string `json:"logs,omitempty" table:"-"`
|
||||
CreatedAt *string `json:"created_at,omitempty" table:"-"`
|
||||
UpdatedAt *string `json:"updated_at,omitempty" table:"-"`
|
||||
}
|
||||
|
||||
// DeployResponse wraps deployment trigger responses
|
||||
type DeployResponse struct {
|
||||
Message string `json:"message"`
|
||||
DeploymentUUID string `json:"deployment_uuid,omitempty"`
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package models
|
||||
|
||||
// Domain represents a domain configuration
|
||||
type Domain struct {
|
||||
IP string `json:"ip"`
|
||||
Domains []string `json:"domains"`
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package models
|
||||
|
||||
// GitHubApp represents a GitHub App integration
|
||||
type GitHubApp struct {
|
||||
ID int `json:"id" table:"-"`
|
||||
UUID string `json:"uuid"`
|
||||
Name string `json:"name"`
|
||||
Organization *string `json:"organization,omitempty"`
|
||||
APIURL string `json:"api_url"`
|
||||
HTMLURL string `json:"html_url"`
|
||||
CustomUser string `json:"custom_user"`
|
||||
CustomPort int `json:"custom_port"`
|
||||
AppID int `json:"app_id" table:"-"`
|
||||
InstallationID int `json:"installation_id" table:"-"`
|
||||
ClientID string `json:"client_id" table:"-"`
|
||||
PrivateKeyID int `json:"private_key_id" table:"-"`
|
||||
IsSystemWide bool `json:"is_system_wide" table:"-"`
|
||||
TeamID int `json:"team_id" table:"-"`
|
||||
}
|
||||
|
||||
// GitHubAppCreateRequest represents a request to create a GitHub App
|
||||
type GitHubAppCreateRequest struct {
|
||||
Name string `json:"name"`
|
||||
Organization *string `json:"organization,omitempty"`
|
||||
APIURL string `json:"api_url"`
|
||||
HTMLURL string `json:"html_url"`
|
||||
CustomUser *string `json:"custom_user,omitempty"`
|
||||
CustomPort *int `json:"custom_port,omitempty"`
|
||||
AppID int `json:"app_id"`
|
||||
InstallationID int `json:"installation_id"`
|
||||
ClientID string `json:"client_id"`
|
||||
ClientSecret string `json:"client_secret"`
|
||||
WebhookSecret *string `json:"webhook_secret,omitempty"`
|
||||
PrivateKeyUUID string `json:"private_key_uuid"`
|
||||
IsSystemWide *bool `json:"is_system_wide,omitempty"`
|
||||
}
|
||||
|
||||
// GitHubAppUpdateRequest represents a request to update a GitHub App
|
||||
type GitHubAppUpdateRequest struct {
|
||||
Name *string `json:"name,omitempty"`
|
||||
Organization *string `json:"organization,omitempty"`
|
||||
APIURL *string `json:"api_url,omitempty"`
|
||||
HTMLURL *string `json:"html_url,omitempty"`
|
||||
CustomUser *string `json:"custom_user,omitempty"`
|
||||
CustomPort *int `json:"custom_port,omitempty"`
|
||||
AppID *int `json:"app_id,omitempty"`
|
||||
InstallationID *int `json:"installation_id,omitempty"`
|
||||
ClientID *string `json:"client_id,omitempty"`
|
||||
ClientSecret *string `json:"client_secret,omitempty"`
|
||||
WebhookSecret *string `json:"webhook_secret,omitempty"`
|
||||
PrivateKeyUUID *string `json:"private_key_uuid,omitempty"`
|
||||
IsSystemWide *bool `json:"is_system_wide,omitempty"`
|
||||
}
|
||||
|
||||
// GitHubRepository represents a repository from GitHub
|
||||
type GitHubRepository struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
FullName string `json:"full_name"`
|
||||
Private bool `json:"private"`
|
||||
HTMLURL string `json:"html_url"`
|
||||
CloneURL string `json:"clone_url"`
|
||||
}
|
||||
|
||||
// GitHubBranch represents a branch from GitHub
|
||||
type GitHubBranch struct {
|
||||
Name string `json:"name"`
|
||||
Protected bool `json:"protected"`
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestServer_MarshalUnmarshal(t *testing.T) {
|
||||
server := Server{
|
||||
ID: 1,
|
||||
UUID: "test-uuid",
|
||||
Name: "test-server",
|
||||
IP: "192.168.1.100",
|
||||
User: "root",
|
||||
Port: 22,
|
||||
Settings: Settings{
|
||||
IsReachable: true,
|
||||
IsUsable: true,
|
||||
},
|
||||
}
|
||||
|
||||
// Marshal
|
||||
data, err := json.Marshal(server)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Unmarshal
|
||||
var unmarshaled Server
|
||||
err = json.Unmarshal(data, &unmarshaled)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, server.UUID, unmarshaled.UUID)
|
||||
assert.Equal(t, server.Name, unmarshaled.Name)
|
||||
assert.Equal(t, server.IP, unmarshaled.IP)
|
||||
assert.True(t, unmarshaled.Settings.IsReachable)
|
||||
}
|
||||
|
||||
func TestServer_UnmarshalFromFixture(t *testing.T) {
|
||||
fixtureData, err := os.ReadFile(filepath.Join("..", "..", "test", "fixtures", "server.json"))
|
||||
require.NoError(t, err)
|
||||
|
||||
var server Server
|
||||
err = json.Unmarshal(fixtureData, &server)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "550e8400-e29b-41d4-a716-446655440000", server.UUID)
|
||||
assert.Equal(t, "production-server", server.Name)
|
||||
assert.Equal(t, "192.168.1.100", server.IP)
|
||||
assert.True(t, server.Settings.IsReachable)
|
||||
}
|
||||
|
||||
func TestProject_MarshalUnmarshal(t *testing.T) {
|
||||
desc := "Test project"
|
||||
project := Project{
|
||||
UUID: "proj-uuid",
|
||||
Name: "My Project",
|
||||
Description: &desc,
|
||||
Environments: []Environment{
|
||||
{
|
||||
ID: 1,
|
||||
UUID: "env-uuid",
|
||||
Name: "production",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Marshal
|
||||
data, err := json.Marshal(project)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Unmarshal
|
||||
var unmarshaled Project
|
||||
err = json.Unmarshal(data, &unmarshaled)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, project.UUID, unmarshaled.UUID)
|
||||
assert.Equal(t, project.Name, unmarshaled.Name)
|
||||
assert.NotNil(t, unmarshaled.Description)
|
||||
assert.Equal(t, "Test project", *unmarshaled.Description)
|
||||
assert.Len(t, unmarshaled.Environments, 1)
|
||||
}
|
||||
|
||||
func TestProject_UnmarshalFromFixture(t *testing.T) {
|
||||
fixtureData, err := os.ReadFile(filepath.Join("..", "..", "test", "fixtures", "project.json"))
|
||||
require.NoError(t, err)
|
||||
|
||||
var project Project
|
||||
err = json.Unmarshal(fixtureData, &project)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "proj-123-uuid", project.UUID)
|
||||
assert.Equal(t, "My Project", project.Name)
|
||||
assert.Len(t, project.Environments, 1)
|
||||
assert.Len(t, project.Environments[0].Applications, 1)
|
||||
assert.Equal(t, "running", project.Environments[0].Applications[0].Status)
|
||||
}
|
||||
|
||||
func TestResource_MarshalUnmarshal(t *testing.T) {
|
||||
resource := Resource{
|
||||
ID: 1,
|
||||
UUID: "resource-uuid",
|
||||
Name: "test-resource",
|
||||
Type: "application",
|
||||
Status: ResourceStatusRunning,
|
||||
}
|
||||
|
||||
// Marshal
|
||||
data, err := json.Marshal(resource)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Unmarshal
|
||||
var unmarshaled Resource
|
||||
err = json.Unmarshal(data, &unmarshaled)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, resource.UUID, unmarshaled.UUID)
|
||||
assert.Equal(t, resource.Status, unmarshaled.Status)
|
||||
}
|
||||
|
||||
func TestDeployment_MarshalUnmarshal(t *testing.T) {
|
||||
deployment := Deployment{
|
||||
Message: "Deployment started",
|
||||
ResourceUUID: "resource-uuid",
|
||||
DeploymentUUID: "deployment-uuid",
|
||||
}
|
||||
|
||||
// Marshal
|
||||
data, err := json.Marshal(deployment)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Unmarshal
|
||||
var unmarshaled Deployment
|
||||
err = json.Unmarshal(data, &unmarshaled)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, deployment.Message, unmarshaled.Message)
|
||||
assert.Equal(t, deployment.ResourceUUID, unmarshaled.ResourceUUID)
|
||||
}
|
||||
|
||||
func TestDomain_MarshalUnmarshal(t *testing.T) {
|
||||
domain := Domain{
|
||||
IP: "192.168.1.100",
|
||||
Domains: []string{"example.com", "www.example.com"},
|
||||
}
|
||||
|
||||
// Marshal
|
||||
data, err := json.Marshal(domain)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Unmarshal
|
||||
var unmarshaled Domain
|
||||
err = json.Unmarshal(data, &unmarshaled)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, domain.IP, unmarshaled.IP)
|
||||
assert.Len(t, unmarshaled.Domains, 2)
|
||||
}
|
||||
|
||||
func TestPrivateKey_MarshalUnmarshal(t *testing.T) {
|
||||
key := PrivateKey{
|
||||
ID: 1,
|
||||
UUID: "key-uuid",
|
||||
Name: "test-key",
|
||||
PublicKey: "ssh-rsa AAAA...",
|
||||
PrivateKey: "-----BEGIN RSA PRIVATE KEY-----\n...",
|
||||
}
|
||||
|
||||
// Marshal
|
||||
data, err := json.Marshal(key)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Unmarshal
|
||||
var unmarshaled PrivateKey
|
||||
err = json.Unmarshal(data, &unmarshaled)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, key.UUID, unmarshaled.UUID)
|
||||
assert.Equal(t, key.Name, unmarshaled.Name)
|
||||
}
|
||||
|
||||
func TestPrivateKeyCreateRequest_Marshal(t *testing.T) {
|
||||
request := PrivateKeyCreateRequest{
|
||||
Name: "my-key",
|
||||
PrivateKey: "-----BEGIN RSA PRIVATE KEY-----\n...",
|
||||
}
|
||||
|
||||
data, err := json.Marshal(request)
|
||||
require.NoError(t, err)
|
||||
|
||||
var unmarshaled PrivateKeyCreateRequest
|
||||
err = json.Unmarshal(data, &unmarshaled)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, request.Name, unmarshaled.Name)
|
||||
assert.Equal(t, request.PrivateKey, unmarshaled.PrivateKey)
|
||||
}
|
||||
|
||||
func TestServerCreateRequest_Marshal(t *testing.T) {
|
||||
request := ServerCreateRequest{
|
||||
Name: "new-server",
|
||||
IP: "192.168.1.200",
|
||||
Port: 22,
|
||||
User: "root",
|
||||
PrivateKeyUUID: "key-uuid",
|
||||
InstantValidate: true,
|
||||
}
|
||||
|
||||
data, err := json.Marshal(request)
|
||||
require.NoError(t, err)
|
||||
|
||||
var unmarshaled ServerCreateRequest
|
||||
err = json.Unmarshal(data, &unmarshaled)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, request.Name, unmarshaled.Name)
|
||||
assert.Equal(t, request.IP, unmarshaled.IP)
|
||||
assert.Equal(t, request.Port, unmarshaled.Port)
|
||||
assert.True(t, unmarshaled.InstantValidate)
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package models
|
||||
|
||||
// PrivateKey represents an SSH private key
|
||||
type PrivateKey struct {
|
||||
ID int `json:"-" table:"-"`
|
||||
UUID string `json:"uuid"`
|
||||
Name string `json:"name"`
|
||||
PublicKey string `json:"public_key" sensitive:"true"`
|
||||
PrivateKey string `json:"private_key" sensitive:"true"`
|
||||
}
|
||||
|
||||
// PrivateKeyCreateRequest for creating keys
|
||||
type PrivateKeyCreateRequest struct {
|
||||
Name string `json:"name"`
|
||||
PrivateKey string `json:"private_key"`
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package models
|
||||
|
||||
// Project represents a Coolify project
|
||||
type Project struct {
|
||||
UUID string `json:"uuid"`
|
||||
Name string `json:"name"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
Environments []Environment `json:"environments,omitempty"`
|
||||
}
|
||||
|
||||
// Environment within a project
|
||||
type Environment struct {
|
||||
ID int `json:"-" table:"-"`
|
||||
UUID string `json:"uuid"`
|
||||
Name string `json:"name"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
Applications []ApplicationInProject `json:"applications,omitempty"`
|
||||
CreatedAt string `json:"-" table:"-"`
|
||||
UpdatedAt string `json:"-" table:"-"`
|
||||
}
|
||||
|
||||
// ApplicationInProject represents a simplified application within an environment
|
||||
type ApplicationInProject struct {
|
||||
ID int `json:"-" table:"-"`
|
||||
UUID string `json:"uuid"`
|
||||
Name string `json:"name"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
// ProjectCreateRequest for creating projects
|
||||
type ProjectCreateRequest struct {
|
||||
Name string `json:"name"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package models
|
||||
|
||||
// Resource represents any deployable resource
|
||||
type Resource struct {
|
||||
ID int `json:"-" table:"-"`
|
||||
UUID string `json:"uuid"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
// ResourceStatus constants
|
||||
const (
|
||||
ResourceStatusRunning = "running"
|
||||
ResourceStatusStopped = "stopped"
|
||||
ResourceStatusError = "error"
|
||||
)
|
||||
|
||||
// Resources wraps a list of resources
|
||||
type Resources struct {
|
||||
Resources []Resource `json:"resources"`
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package models
|
||||
|
||||
// Server represents a Coolify server
|
||||
type Server struct {
|
||||
ID int `json:"-" table:"-"`
|
||||
UUID string `json:"uuid"`
|
||||
Name string `json:"name"`
|
||||
IP string `json:"ip" sensitive:"true"`
|
||||
User string `json:"user" sensitive:"true"`
|
||||
Port int `json:"port" sensitive:"true"`
|
||||
Settings Settings `json:"settings" table:"-"`
|
||||
}
|
||||
|
||||
// Settings for server
|
||||
type Settings struct {
|
||||
IsReachable bool `json:"is_reachable"`
|
||||
IsUsable bool `json:"is_usable"`
|
||||
}
|
||||
|
||||
// ServerCreateRequest for creating servers
|
||||
type ServerCreateRequest struct {
|
||||
Name string `json:"name"`
|
||||
IP string `json:"ip"`
|
||||
Port int `json:"port"`
|
||||
User string `json:"user"`
|
||||
PrivateKeyUUID string `json:"private_key_uuid"`
|
||||
InstantValidate bool `json:"instant_validate"`
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package models
|
||||
|
||||
// Service represents a Coolify one-click service
|
||||
type Service struct {
|
||||
ID int `json:"-" table:"-"`
|
||||
UUID string `json:"uuid"`
|
||||
Name string `json:"name"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
Status string `json:"status"`
|
||||
|
||||
// Relationship IDs - internal database IDs (hidden from output)
|
||||
ServerID *int `json:"-" table:"-"`
|
||||
EnvironmentID *int `json:"-" table:"-"`
|
||||
ProjectID *int `json:"-" table:"-"`
|
||||
|
||||
// Docker configuration (hidden from table output)
|
||||
DockerCompose *string `json:"docker_compose,omitempty" table:"-"`
|
||||
DockerComposeRaw *string `json:"docker_compose_raw,omitempty" table:"-"`
|
||||
|
||||
// Additional metadata
|
||||
CreatedAt string `json:"-" table:"-"`
|
||||
UpdatedAt string `json:"-" table:"-"`
|
||||
|
||||
// Nested resources
|
||||
Applications []ServiceApplication `json:"applications,omitempty"`
|
||||
Databases []ServiceDatabase `json:"databases,omitempty"`
|
||||
}
|
||||
|
||||
// ServiceApplication represents an application within a service
|
||||
type ServiceApplication struct {
|
||||
ID int `json:"-" table:"-"`
|
||||
UUID string `json:"uuid"`
|
||||
Name string `json:"name"`
|
||||
Status *string `json:"status,omitempty"`
|
||||
Fqdn *string `json:"fqdn,omitempty"`
|
||||
}
|
||||
|
||||
// ServiceDatabase represents a database within a service
|
||||
type ServiceDatabase struct {
|
||||
ID int `json:"-" table:"-"`
|
||||
UUID string `json:"uuid"`
|
||||
Name string `json:"name"`
|
||||
Type *string `json:"type,omitempty"`
|
||||
Status *string `json:"status,omitempty"`
|
||||
}
|
||||
|
||||
// ServiceCreateRequest represents the request to create a service
|
||||
type ServiceCreateRequest struct {
|
||||
Type string `json:"type"`
|
||||
Name *string `json:"name,omitempty"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
ServerUUID string `json:"server_uuid"`
|
||||
ProjectUUID string `json:"project_uuid"`
|
||||
EnvironmentName string `json:"environment_name"`
|
||||
InstantDeploy *bool `json:"instant_deploy,omitempty"`
|
||||
DockerCompose *string `json:"docker_compose,omitempty"`
|
||||
Destination *string `json:"destination,omitempty"`
|
||||
}
|
||||
|
||||
// ServiceUpdateRequest represents the request to update a service
|
||||
type ServiceUpdateRequest struct {
|
||||
Name *string `json:"name,omitempty"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
DockerCompose *string `json:"docker_compose,omitempty"`
|
||||
}
|
||||
|
||||
// ServiceLifecycleResponse represents the response from lifecycle operations
|
||||
type ServiceLifecycleResponse struct {
|
||||
Message string `json:"message"`
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package models
|
||||
|
||||
// Team represents a Coolify team
|
||||
type Team struct {
|
||||
ID int `json:"-" table:"-"`
|
||||
UUID string `json:"uuid"`
|
||||
Name string `json:"name"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
CreatedAt string `json:"-" table:"-"`
|
||||
UpdatedAt string `json:"-" table:"-"`
|
||||
}
|
||||
|
||||
// TeamMember represents a member of a team
|
||||
type TeamMember struct {
|
||||
ID int `json:"-" table:"-"`
|
||||
UUID string `json:"uuid" table:"-"`
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email" sensitive:"true"`
|
||||
Role *string `json:"role,omitempty" table:"-"`
|
||||
CreatedAt string `json:"-" table:"-"`
|
||||
UpdatedAt string `json:"-" table:"-"`
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package output
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
)
|
||||
|
||||
// Format types
|
||||
const (
|
||||
FormatTable = "table"
|
||||
FormatJSON = "json"
|
||||
FormatPretty = "pretty"
|
||||
)
|
||||
|
||||
// Formatter is the interface for output formatting
|
||||
type Formatter interface {
|
||||
// Format formats the data and writes it to the writer
|
||||
Format(data interface{}) error
|
||||
}
|
||||
|
||||
// Options for formatter configuration
|
||||
type Options struct {
|
||||
Writer io.Writer
|
||||
ShowSensitive bool
|
||||
Color bool
|
||||
}
|
||||
|
||||
// NewFormatter creates a formatter based on the format type
|
||||
func NewFormatter(format string, opts Options) (Formatter, error) {
|
||||
if opts.Writer == nil {
|
||||
opts.Writer = os.Stdout
|
||||
}
|
||||
|
||||
switch format {
|
||||
case FormatTable:
|
||||
return NewTableFormatter(opts), nil
|
||||
case FormatJSON:
|
||||
return NewJSONFormatter(opts), nil
|
||||
case FormatPretty:
|
||||
return NewPrettyFormatter(opts), nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported format: %s", format)
|
||||
}
|
||||
}
|
||||
|
||||
// SensitiveOverlay is the string used to hide sensitive information
|
||||
const SensitiveOverlay = "********"
|
||||
@@ -0,0 +1,267 @@
|
||||
package output
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type TestServer struct {
|
||||
UUID string `json:"uuid"`
|
||||
Name string `json:"name"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
func TestNewFormatter(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
format string
|
||||
wantErr bool
|
||||
}{
|
||||
{"table format", FormatTable, false},
|
||||
{"json format", FormatJSON, false},
|
||||
{"pretty format", FormatPretty, false},
|
||||
{"invalid format", "invalid", true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
opts := Options{Writer: &bytes.Buffer{}}
|
||||
formatter, err := NewFormatter(tt.format, opts)
|
||||
|
||||
if tt.wantErr {
|
||||
assert.Error(t, err)
|
||||
assert.Nil(t, formatter)
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, formatter)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestJSONFormatter(t *testing.T) {
|
||||
servers := []TestServer{
|
||||
{UUID: "uuid-1", Name: "server-1", Status: "running"},
|
||||
{UUID: "uuid-2", Name: "server-2", Status: "stopped"},
|
||||
}
|
||||
|
||||
buf := &bytes.Buffer{}
|
||||
formatter := NewJSONFormatter(Options{Writer: buf})
|
||||
|
||||
err := formatter.Format(servers)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify valid JSON
|
||||
var result []TestServer
|
||||
err = json.Unmarshal(buf.Bytes(), &result)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Len(t, result, 2)
|
||||
assert.Equal(t, "uuid-1", result[0].UUID)
|
||||
assert.Equal(t, "server-1", result[0].Name)
|
||||
}
|
||||
|
||||
func TestPrettyFormatter(t *testing.T) {
|
||||
servers := []TestServer{
|
||||
{UUID: "uuid-1", Name: "server-1", Status: "running"},
|
||||
}
|
||||
|
||||
buf := &bytes.Buffer{}
|
||||
formatter := NewPrettyFormatter(Options{Writer: buf})
|
||||
|
||||
err := formatter.Format(servers)
|
||||
require.NoError(t, err)
|
||||
|
||||
output := buf.String()
|
||||
|
||||
// Verify it's indented JSON
|
||||
assert.Contains(t, output, " ")
|
||||
assert.Contains(t, output, "uuid-1")
|
||||
assert.Contains(t, output, "server-1")
|
||||
|
||||
// Verify valid JSON
|
||||
var result []TestServer
|
||||
err = json.Unmarshal(buf.Bytes(), &result)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestTableFormatter_Slice(t *testing.T) {
|
||||
servers := []TestServer{
|
||||
{UUID: "uuid-1", Name: "server-1", Status: "running"},
|
||||
{UUID: "uuid-2", Name: "server-2", Status: "stopped"},
|
||||
}
|
||||
|
||||
buf := &bytes.Buffer{}
|
||||
formatter := NewTableFormatter(Options{Writer: buf})
|
||||
|
||||
err := formatter.Format(servers)
|
||||
require.NoError(t, err)
|
||||
|
||||
output := buf.String()
|
||||
|
||||
// Check headers
|
||||
assert.Contains(t, output, "uuid")
|
||||
assert.Contains(t, output, "name")
|
||||
assert.Contains(t, output, "status")
|
||||
|
||||
// Check data
|
||||
assert.Contains(t, output, "uuid-1")
|
||||
assert.Contains(t, output, "server-1")
|
||||
assert.Contains(t, output, "running")
|
||||
assert.Contains(t, output, "uuid-2")
|
||||
assert.Contains(t, output, "server-2")
|
||||
assert.Contains(t, output, "stopped")
|
||||
}
|
||||
|
||||
func TestTableFormatter_SingleStruct(t *testing.T) {
|
||||
server := TestServer{
|
||||
UUID: "uuid-1",
|
||||
Name: "server-1",
|
||||
Status: "running",
|
||||
}
|
||||
|
||||
buf := &bytes.Buffer{}
|
||||
formatter := NewTableFormatter(Options{Writer: buf})
|
||||
|
||||
err := formatter.Format(server)
|
||||
require.NoError(t, err)
|
||||
|
||||
output := buf.String()
|
||||
|
||||
// Check field names and values
|
||||
assert.Contains(t, output, "uuid")
|
||||
assert.Contains(t, output, "uuid-1")
|
||||
assert.Contains(t, output, "name")
|
||||
assert.Contains(t, output, "server-1")
|
||||
assert.Contains(t, output, "status")
|
||||
assert.Contains(t, output, "running")
|
||||
}
|
||||
|
||||
func TestTableFormatter_EmptySlice(t *testing.T) {
|
||||
var servers []TestServer
|
||||
|
||||
buf := &bytes.Buffer{}
|
||||
formatter := NewTableFormatter(Options{Writer: buf})
|
||||
|
||||
err := formatter.Format(servers)
|
||||
require.NoError(t, err)
|
||||
|
||||
output := buf.String()
|
||||
assert.Contains(t, output, "No data")
|
||||
}
|
||||
|
||||
func TestTableFormatter_Map(t *testing.T) {
|
||||
data := map[string]string{
|
||||
"key1": "value1",
|
||||
"key2": "value2",
|
||||
}
|
||||
|
||||
buf := &bytes.Buffer{}
|
||||
formatter := NewTableFormatter(Options{Writer: buf})
|
||||
|
||||
err := formatter.Format(data)
|
||||
require.NoError(t, err)
|
||||
|
||||
output := buf.String()
|
||||
|
||||
// Check headers
|
||||
assert.Contains(t, output, "Key")
|
||||
assert.Contains(t, output, "Value")
|
||||
}
|
||||
|
||||
func TestTableFormatter_SimpleSlice(t *testing.T) {
|
||||
data := []string{"item1", "item2", "item3"}
|
||||
|
||||
buf := &bytes.Buffer{}
|
||||
formatter := NewTableFormatter(Options{Writer: buf})
|
||||
|
||||
err := formatter.Format(data)
|
||||
require.NoError(t, err)
|
||||
|
||||
output := buf.String()
|
||||
|
||||
assert.Contains(t, output, "item1")
|
||||
assert.Contains(t, output, "item2")
|
||||
assert.Contains(t, output, "item3")
|
||||
}
|
||||
|
||||
func TestTableFormatter_BooleanValues(t *testing.T) {
|
||||
type TestStruct struct {
|
||||
Name string `json:"name"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Active bool `json:"active"`
|
||||
}
|
||||
|
||||
data := []TestStruct{
|
||||
{Name: "test1", Enabled: true, Active: false},
|
||||
{Name: "test2", Enabled: false, Active: true},
|
||||
}
|
||||
|
||||
buf := &bytes.Buffer{}
|
||||
formatter := NewTableFormatter(Options{Writer: buf})
|
||||
|
||||
err := formatter.Format(data)
|
||||
require.NoError(t, err)
|
||||
|
||||
output := buf.String()
|
||||
|
||||
// Check boolean formatting
|
||||
lines := strings.Split(output, "\n")
|
||||
assert.Contains(t, lines[1], "true")
|
||||
assert.Contains(t, lines[1], "false")
|
||||
assert.Contains(t, lines[2], "false")
|
||||
assert.Contains(t, lines[2], "true")
|
||||
}
|
||||
|
||||
func TestTableFormatter_NilPointer(t *testing.T) {
|
||||
type TestStruct struct {
|
||||
Name string `json:"name"`
|
||||
Description *string `json:"description"`
|
||||
}
|
||||
|
||||
desc := "test description"
|
||||
data := []TestStruct{
|
||||
{Name: "test1", Description: &desc},
|
||||
{Name: "test2", Description: nil},
|
||||
}
|
||||
|
||||
buf := &bytes.Buffer{}
|
||||
formatter := NewTableFormatter(Options{Writer: buf})
|
||||
|
||||
err := formatter.Format(data)
|
||||
require.NoError(t, err)
|
||||
|
||||
output := buf.String()
|
||||
|
||||
// First row should have description
|
||||
assert.Contains(t, output, "test description")
|
||||
// Second row should handle nil gracefully
|
||||
assert.Contains(t, output, "test2")
|
||||
}
|
||||
|
||||
func TestTableFormatter_SliceField(t *testing.T) {
|
||||
type TestStruct struct {
|
||||
Name string `json:"name"`
|
||||
Tags []string `json:"tags"`
|
||||
}
|
||||
|
||||
data := []TestStruct{
|
||||
{Name: "test1", Tags: []string{"tag1", "tag2", "tag3"}},
|
||||
}
|
||||
|
||||
buf := &bytes.Buffer{}
|
||||
formatter := NewTableFormatter(Options{Writer: buf})
|
||||
|
||||
err := formatter.Format(data)
|
||||
require.NoError(t, err)
|
||||
|
||||
output := buf.String()
|
||||
|
||||
// Tags should be comma-separated
|
||||
assert.Contains(t, output, "tag1, tag2, tag3")
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package output
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
)
|
||||
|
||||
// JSONFormatter formats output as compact JSON
|
||||
type JSONFormatter struct {
|
||||
opts Options
|
||||
}
|
||||
|
||||
// NewJSONFormatter creates a new JSON formatter
|
||||
func NewJSONFormatter(opts Options) *JSONFormatter {
|
||||
return &JSONFormatter{opts: opts}
|
||||
}
|
||||
|
||||
// Format formats the data as compact JSON
|
||||
func (f *JSONFormatter) Format(data interface{}) error {
|
||||
encoder := json.NewEncoder(f.opts.Writer)
|
||||
return encoder.Encode(data)
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package output
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
)
|
||||
|
||||
// PrettyFormatter formats output as indented JSON
|
||||
type PrettyFormatter struct {
|
||||
opts Options
|
||||
}
|
||||
|
||||
// NewPrettyFormatter creates a new pretty JSON formatter
|
||||
func NewPrettyFormatter(opts Options) *PrettyFormatter {
|
||||
return &PrettyFormatter{opts: opts}
|
||||
}
|
||||
|
||||
// Format formats the data as indented JSON
|
||||
func (f *PrettyFormatter) Format(data interface{}) error {
|
||||
encoder := json.NewEncoder(f.opts.Writer)
|
||||
encoder.SetIndent("", " ")
|
||||
return encoder.Encode(data)
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
package output
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
"text/tabwriter"
|
||||
)
|
||||
|
||||
// TableFormatter formats output as a table
|
||||
type TableFormatter struct {
|
||||
opts Options
|
||||
}
|
||||
|
||||
// NewTableFormatter creates a new table formatter
|
||||
func NewTableFormatter(opts Options) *TableFormatter {
|
||||
return &TableFormatter{opts: opts}
|
||||
}
|
||||
|
||||
// Format formats the data as a table
|
||||
func (f *TableFormatter) Format(data interface{}) error {
|
||||
w := tabwriter.NewWriter(f.opts.Writer, 0, 0, 2, ' ', tabwriter.Debug)
|
||||
defer w.Flush()
|
||||
|
||||
// Handle different data types
|
||||
val := reflect.ValueOf(data)
|
||||
|
||||
// Dereference pointer if needed
|
||||
if val.Kind() == reflect.Ptr {
|
||||
val = val.Elem()
|
||||
}
|
||||
|
||||
switch val.Kind() {
|
||||
case reflect.Slice, reflect.Array:
|
||||
return f.formatSlice(w, val)
|
||||
case reflect.Struct:
|
||||
return f.formatStruct(w, val)
|
||||
case reflect.Map:
|
||||
return f.formatMap(w, val)
|
||||
default:
|
||||
return fmt.Errorf("unsupported data type for table format: %v", val.Kind())
|
||||
}
|
||||
}
|
||||
|
||||
// formatSlice formats a slice of structs as a table
|
||||
func (f *TableFormatter) formatSlice(w *tabwriter.Writer, val reflect.Value) error {
|
||||
if val.Len() == 0 {
|
||||
fmt.Fprintln(w, "No data")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get the first element to determine columns
|
||||
firstElem := val.Index(0)
|
||||
if firstElem.Kind() == reflect.Ptr {
|
||||
firstElem = firstElem.Elem()
|
||||
}
|
||||
|
||||
if firstElem.Kind() != reflect.Struct {
|
||||
// Simple slice (e.g., []string)
|
||||
for i := 0; i < val.Len(); i++ {
|
||||
fmt.Fprintf(w, "%v\n", val.Index(i).Interface())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get column headers from struct tags or field names
|
||||
headers := f.getHeaders(firstElem.Type())
|
||||
// Add # as first column header
|
||||
headersWithNum := append([]string{"#"}, headers...)
|
||||
fmt.Fprintln(w, strings.Join(headersWithNum, "\t"))
|
||||
|
||||
// Print rows
|
||||
for i := 0; i < val.Len(); i++ {
|
||||
elem := val.Index(i)
|
||||
if elem.Kind() == reflect.Ptr {
|
||||
elem = elem.Elem()
|
||||
}
|
||||
row := f.formatStructRow(elem)
|
||||
// Add row number (1-indexed) as first column
|
||||
rowWithNum := append([]string{fmt.Sprintf("%d", i+1)}, row...)
|
||||
fmt.Fprintln(w, strings.Join(rowWithNum, "\t"))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// formatStruct formats a single struct as a table (horizontal layout with headers)
|
||||
func (f *TableFormatter) formatStruct(w *tabwriter.Writer, val reflect.Value) error {
|
||||
// Get headers
|
||||
headers := f.getHeaders(val.Type())
|
||||
fmt.Fprintln(w, strings.Join(headers, "\t"))
|
||||
|
||||
// Get row data
|
||||
row := f.formatStructRow(val)
|
||||
fmt.Fprintln(w, strings.Join(row, "\t"))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// formatMap formats a map as a table
|
||||
func (f *TableFormatter) formatMap(w *tabwriter.Writer, val reflect.Value) error {
|
||||
fmt.Fprintln(w, "Key\tValue")
|
||||
|
||||
iter := val.MapRange()
|
||||
for iter.Next() {
|
||||
key := iter.Key()
|
||||
value := iter.Value()
|
||||
fmt.Fprintf(w, "%v\t%v\n", key.Interface(), f.formatValue(value))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// getHeaders extracts column headers from struct type
|
||||
func (f *TableFormatter) getHeaders(typ reflect.Type) []string {
|
||||
var headers []string
|
||||
|
||||
for i := 0; i < typ.NumField(); i++ {
|
||||
field := typ.Field(i)
|
||||
|
||||
// Skip unexported fields
|
||||
if !field.IsExported() {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check table tag for skip
|
||||
if tableTag := field.Tag.Get("table"); tableTag == "-" {
|
||||
continue
|
||||
}
|
||||
|
||||
fieldName := field.Name
|
||||
// Use json tag if available
|
||||
if jsonTag := field.Tag.Get("json"); jsonTag != "" {
|
||||
fieldName = strings.Split(jsonTag, ",")[0]
|
||||
if fieldName == "-" || fieldName == "omitempty" {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
headers = append(headers, fieldName)
|
||||
}
|
||||
|
||||
return headers
|
||||
}
|
||||
|
||||
// formatStructRow extracts values from a struct as a row
|
||||
func (f *TableFormatter) formatStructRow(val reflect.Value) []string {
|
||||
var row []string
|
||||
|
||||
for i := 0; i < val.NumField(); i++ {
|
||||
field := val.Type().Field(i)
|
||||
|
||||
// Skip unexported fields
|
||||
if !field.IsExported() {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check table tag for skip
|
||||
if tableTag := field.Tag.Get("table"); tableTag == "-" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check json tag for skip
|
||||
if jsonTag := field.Tag.Get("json"); jsonTag != "" {
|
||||
fieldName := strings.Split(jsonTag, ",")[0]
|
||||
if fieldName == "-" {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
value := val.Field(i)
|
||||
|
||||
// Check if field is marked as sensitive
|
||||
isSensitive := field.Tag.Get("sensitive") == "true"
|
||||
if isSensitive && !f.opts.ShowSensitive {
|
||||
row = append(row, SensitiveOverlay)
|
||||
} else {
|
||||
row = append(row, f.formatValue(value))
|
||||
}
|
||||
}
|
||||
|
||||
return row
|
||||
}
|
||||
|
||||
// formatValue formats a reflect.Value for display
|
||||
func (f *TableFormatter) formatValue(val reflect.Value) string {
|
||||
// Handle nil pointers
|
||||
if val.Kind() == reflect.Ptr && val.IsNil() {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Dereference pointer
|
||||
if val.Kind() == reflect.Ptr {
|
||||
val = val.Elem()
|
||||
}
|
||||
|
||||
// Handle different types
|
||||
switch val.Kind() {
|
||||
case reflect.String:
|
||||
return val.String()
|
||||
case reflect.Bool:
|
||||
if val.Bool() {
|
||||
return "true"
|
||||
}
|
||||
return "false"
|
||||
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
|
||||
return fmt.Sprintf("%d", val.Int())
|
||||
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
|
||||
return fmt.Sprintf("%d", val.Uint())
|
||||
case reflect.Float32, reflect.Float64:
|
||||
return fmt.Sprintf("%.2f", val.Float())
|
||||
case reflect.Slice, reflect.Array:
|
||||
if val.Len() == 0 {
|
||||
return "[]"
|
||||
}
|
||||
// Check if it's a slice of structs
|
||||
elemType := val.Index(0).Kind()
|
||||
if elemType == reflect.Struct || elemType == reflect.Ptr {
|
||||
// For complex types, try to extract Name field from all elements
|
||||
var names []string
|
||||
for i := 0; i < val.Len(); i++ {
|
||||
elem := val.Index(i)
|
||||
if elem.Kind() == reflect.Ptr && !elem.IsNil() {
|
||||
elem = elem.Elem()
|
||||
}
|
||||
if elem.Kind() == reflect.Struct {
|
||||
nameField := elem.FieldByName("Name")
|
||||
if nameField.IsValid() && nameField.Kind() == reflect.String {
|
||||
names = append(names, nameField.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(names) > 0 {
|
||||
return strings.Join(names, ", ")
|
||||
}
|
||||
return fmt.Sprintf("(%d items)", val.Len())
|
||||
}
|
||||
// For simple types, show comma-separated values
|
||||
var items []string
|
||||
for i := 0; i < val.Len(); i++ {
|
||||
items = append(items, f.formatValue(val.Index(i)))
|
||||
}
|
||||
return strings.Join(items, ", ")
|
||||
case reflect.Struct:
|
||||
// For nested structs, try to show a name field if available
|
||||
nameField := val.FieldByName("Name")
|
||||
if nameField.IsValid() && nameField.Kind() == reflect.String {
|
||||
return nameField.String()
|
||||
}
|
||||
return fmt.Sprintf("(%s)", val.Type().Name())
|
||||
default:
|
||||
return fmt.Sprintf("%v", val.Interface())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package parser
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// EnvVar represents a parsed environment variable
|
||||
type EnvVar struct {
|
||||
Key string
|
||||
Value string
|
||||
}
|
||||
|
||||
// ParseEnvFile parses a .env file and returns a slice of environment variables
|
||||
// Supports:
|
||||
// - KEY=value
|
||||
// - KEY="value"
|
||||
// - KEY='value'
|
||||
// - Multiline values with quotes
|
||||
// - Comments (lines starting with #)
|
||||
// - Empty lines
|
||||
func ParseEnvFile(filepath string) ([]EnvVar, error) {
|
||||
file, err := os.Open(filepath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to open file: %w", err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
var envVars []EnvVar
|
||||
scanner := bufio.NewScanner(file)
|
||||
lineNum := 0
|
||||
var currentVar *EnvVar
|
||||
var inMultiline bool
|
||||
var quoteChar rune
|
||||
|
||||
for scanner.Scan() {
|
||||
lineNum++
|
||||
line := scanner.Text()
|
||||
|
||||
// Handle multiline continuation
|
||||
if inMultiline {
|
||||
currentVar.Value += "\n" + line
|
||||
// Check if this line closes the multiline value
|
||||
if strings.HasSuffix(line, string(quoteChar)) {
|
||||
// Remove the closing quote
|
||||
currentVar.Value = strings.TrimSuffix(currentVar.Value, string(quoteChar))
|
||||
envVars = append(envVars, *currentVar)
|
||||
currentVar = nil
|
||||
inMultiline = false
|
||||
quoteChar = 0
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip empty lines and comments
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if trimmed == "" || strings.HasPrefix(trimmed, "#") {
|
||||
continue
|
||||
}
|
||||
|
||||
// Find the first = sign
|
||||
parts := strings.SplitN(line, "=", 2)
|
||||
if len(parts) != 2 {
|
||||
return nil, fmt.Errorf("invalid format at line %d: missing '='", lineNum)
|
||||
}
|
||||
|
||||
key := strings.TrimSpace(parts[0])
|
||||
value := parts[1]
|
||||
|
||||
if key == "" {
|
||||
return nil, fmt.Errorf("invalid format at line %d: empty key", lineNum)
|
||||
}
|
||||
|
||||
// Handle quoted values
|
||||
if len(value) >= 2 {
|
||||
firstChar := rune(value[0])
|
||||
if firstChar == '"' || firstChar == '\'' {
|
||||
// Check if the closing quote is on the same line
|
||||
if strings.HasSuffix(value, string(firstChar)) && len(value) > 1 {
|
||||
// Single-line quoted value
|
||||
value = value[1 : len(value)-1]
|
||||
envVars = append(envVars, EnvVar{Key: key, Value: value})
|
||||
} else {
|
||||
// Start of multiline quoted value
|
||||
currentVar = &EnvVar{Key: key, Value: value[1:]} // Remove opening quote
|
||||
inMultiline = true
|
||||
quoteChar = firstChar
|
||||
}
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Unquoted value
|
||||
envVars = append(envVars, EnvVar{Key: key, Value: value})
|
||||
}
|
||||
|
||||
if err := scanner.Err(); err != nil {
|
||||
return nil, fmt.Errorf("error reading file: %w", err)
|
||||
}
|
||||
|
||||
if inMultiline {
|
||||
return nil, fmt.Errorf("unclosed quoted value for key '%s'", currentVar.Key)
|
||||
}
|
||||
|
||||
return envVars, nil
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
package parser
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestParseEnvFile_Simple(t *testing.T) {
|
||||
content := `KEY1=value1
|
||||
KEY2=value2
|
||||
KEY3=value3`
|
||||
|
||||
tmpFile := createTempEnvFile(t, content)
|
||||
defer os.Remove(tmpFile)
|
||||
|
||||
envVars, err := ParseEnvFile(tmpFile)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, envVars, 3)
|
||||
assert.Equal(t, "KEY1", envVars[0].Key)
|
||||
assert.Equal(t, "value1", envVars[0].Value)
|
||||
assert.Equal(t, "KEY2", envVars[1].Key)
|
||||
assert.Equal(t, "value2", envVars[1].Value)
|
||||
assert.Equal(t, "KEY3", envVars[2].Key)
|
||||
assert.Equal(t, "value3", envVars[2].Value)
|
||||
}
|
||||
|
||||
func TestParseEnvFile_WithQuotes(t *testing.T) {
|
||||
content := `KEY1="value with spaces"
|
||||
KEY2='single quoted value'
|
||||
KEY3=unquoted`
|
||||
|
||||
tmpFile := createTempEnvFile(t, content)
|
||||
defer os.Remove(tmpFile)
|
||||
|
||||
envVars, err := ParseEnvFile(tmpFile)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, envVars, 3)
|
||||
assert.Equal(t, "KEY1", envVars[0].Key)
|
||||
assert.Equal(t, "value with spaces", envVars[0].Value)
|
||||
assert.Equal(t, "KEY2", envVars[1].Key)
|
||||
assert.Equal(t, "single quoted value", envVars[1].Value)
|
||||
assert.Equal(t, "KEY3", envVars[2].Key)
|
||||
assert.Equal(t, "unquoted", envVars[2].Value)
|
||||
}
|
||||
|
||||
func TestParseEnvFile_WithComments(t *testing.T) {
|
||||
content := `# This is a comment
|
||||
KEY1=value1
|
||||
# Another comment
|
||||
KEY2=value2`
|
||||
|
||||
tmpFile := createTempEnvFile(t, content)
|
||||
defer os.Remove(tmpFile)
|
||||
|
||||
envVars, err := ParseEnvFile(tmpFile)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, envVars, 2)
|
||||
assert.Equal(t, "KEY1", envVars[0].Key)
|
||||
assert.Equal(t, "value1", envVars[0].Value)
|
||||
assert.Equal(t, "KEY2", envVars[1].Key)
|
||||
assert.Equal(t, "value2", envVars[1].Value)
|
||||
}
|
||||
|
||||
func TestParseEnvFile_WithEmptyLines(t *testing.T) {
|
||||
content := `KEY1=value1
|
||||
|
||||
KEY2=value2
|
||||
|
||||
`
|
||||
|
||||
tmpFile := createTempEnvFile(t, content)
|
||||
defer os.Remove(tmpFile)
|
||||
|
||||
envVars, err := ParseEnvFile(tmpFile)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, envVars, 2)
|
||||
assert.Equal(t, "KEY1", envVars[0].Key)
|
||||
assert.Equal(t, "value1", envVars[0].Value)
|
||||
assert.Equal(t, "KEY2", envVars[1].Key)
|
||||
assert.Equal(t, "value2", envVars[1].Value)
|
||||
}
|
||||
|
||||
func TestParseEnvFile_Multiline(t *testing.T) {
|
||||
content := `KEY1="line1
|
||||
line2
|
||||
line3"
|
||||
KEY2=single`
|
||||
|
||||
tmpFile := createTempEnvFile(t, content)
|
||||
defer os.Remove(tmpFile)
|
||||
|
||||
envVars, err := ParseEnvFile(tmpFile)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, envVars, 2)
|
||||
assert.Equal(t, "KEY1", envVars[0].Key)
|
||||
assert.Equal(t, "line1\nline2\nline3", envVars[0].Value)
|
||||
assert.Equal(t, "KEY2", envVars[1].Key)
|
||||
assert.Equal(t, "single", envVars[1].Value)
|
||||
}
|
||||
|
||||
func TestParseEnvFile_MultilineWithSingleQuotes(t *testing.T) {
|
||||
content := `PRIVATE_KEY='-----BEGIN PRIVATE KEY-----
|
||||
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC
|
||||
-----END PRIVATE KEY-----'
|
||||
OTHER=value`
|
||||
|
||||
tmpFile := createTempEnvFile(t, content)
|
||||
defer os.Remove(tmpFile)
|
||||
|
||||
envVars, err := ParseEnvFile(tmpFile)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, envVars, 2)
|
||||
assert.Equal(t, "PRIVATE_KEY", envVars[0].Key)
|
||||
assert.Contains(t, envVars[0].Value, "BEGIN PRIVATE KEY")
|
||||
assert.Contains(t, envVars[0].Value, "END PRIVATE KEY")
|
||||
assert.Equal(t, "OTHER", envVars[1].Key)
|
||||
assert.Equal(t, "value", envVars[1].Value)
|
||||
}
|
||||
|
||||
func TestParseEnvFile_EmptyValue(t *testing.T) {
|
||||
content := `KEY1=
|
||||
KEY2=value`
|
||||
|
||||
tmpFile := createTempEnvFile(t, content)
|
||||
defer os.Remove(tmpFile)
|
||||
|
||||
envVars, err := ParseEnvFile(tmpFile)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, envVars, 2)
|
||||
assert.Equal(t, "KEY1", envVars[0].Key)
|
||||
assert.Equal(t, "", envVars[0].Value)
|
||||
assert.Equal(t, "KEY2", envVars[1].Key)
|
||||
assert.Equal(t, "value", envVars[1].Value)
|
||||
}
|
||||
|
||||
func TestParseEnvFile_EqualsInValue(t *testing.T) {
|
||||
content := `KEY1=value=with=equals
|
||||
KEY2="quoted=value=with=equals"`
|
||||
|
||||
tmpFile := createTempEnvFile(t, content)
|
||||
defer os.Remove(tmpFile)
|
||||
|
||||
envVars, err := ParseEnvFile(tmpFile)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, envVars, 2)
|
||||
assert.Equal(t, "KEY1", envVars[0].Key)
|
||||
assert.Equal(t, "value=with=equals", envVars[0].Value)
|
||||
assert.Equal(t, "KEY2", envVars[1].Key)
|
||||
assert.Equal(t, "quoted=value=with=equals", envVars[1].Value)
|
||||
}
|
||||
|
||||
func TestParseEnvFile_InvalidFormat_MissingEquals(t *testing.T) {
|
||||
content := `KEY1
|
||||
KEY2=value`
|
||||
|
||||
tmpFile := createTempEnvFile(t, content)
|
||||
defer os.Remove(tmpFile)
|
||||
|
||||
_, err := ParseEnvFile(tmpFile)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "missing '='")
|
||||
}
|
||||
|
||||
func TestParseEnvFile_InvalidFormat_EmptyKey(t *testing.T) {
|
||||
content := `=value
|
||||
KEY2=value`
|
||||
|
||||
tmpFile := createTempEnvFile(t, content)
|
||||
defer os.Remove(tmpFile)
|
||||
|
||||
_, err := ParseEnvFile(tmpFile)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "empty key")
|
||||
}
|
||||
|
||||
func TestParseEnvFile_UnclosedQuote(t *testing.T) {
|
||||
content := `KEY1="unclosed quote
|
||||
KEY2=value`
|
||||
|
||||
tmpFile := createTempEnvFile(t, content)
|
||||
defer os.Remove(tmpFile)
|
||||
|
||||
_, err := ParseEnvFile(tmpFile)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "unclosed quoted value")
|
||||
}
|
||||
|
||||
func TestParseEnvFile_FileNotFound(t *testing.T) {
|
||||
_, err := ParseEnvFile("/nonexistent/file.env")
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "failed to open file")
|
||||
}
|
||||
|
||||
func TestParseEnvFile_EmptyFile(t *testing.T) {
|
||||
content := ``
|
||||
|
||||
tmpFile := createTempEnvFile(t, content)
|
||||
defer os.Remove(tmpFile)
|
||||
|
||||
envVars, err := ParseEnvFile(tmpFile)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, envVars, 0)
|
||||
}
|
||||
|
||||
func TestParseEnvFile_OnlyComments(t *testing.T) {
|
||||
content := `# Comment 1
|
||||
# Comment 2
|
||||
# Comment 3`
|
||||
|
||||
tmpFile := createTempEnvFile(t, content)
|
||||
defer os.Remove(tmpFile)
|
||||
|
||||
envVars, err := ParseEnvFile(tmpFile)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, envVars, 0)
|
||||
}
|
||||
|
||||
// Helper function to create a temporary .env file
|
||||
func createTempEnvFile(t *testing.T, content string) string {
|
||||
tmpDir := t.TempDir()
|
||||
tmpFile := filepath.Join(tmpDir, ".env")
|
||||
err := os.WriteFile(tmpFile, []byte(content), 0644)
|
||||
require.NoError(t, err)
|
||||
return tmpFile
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/coollabsio/coolify-cli/internal/api"
|
||||
"github.com/coollabsio/coolify-cli/internal/models"
|
||||
)
|
||||
|
||||
// ApplicationService handles application-related operations
|
||||
type ApplicationService struct {
|
||||
client *api.Client
|
||||
}
|
||||
|
||||
// NewApplicationService creates a new application service
|
||||
func NewApplicationService(client *api.Client) *ApplicationService {
|
||||
return &ApplicationService{
|
||||
client: client,
|
||||
}
|
||||
}
|
||||
|
||||
// List retrieves all applications
|
||||
func (s *ApplicationService) List(ctx context.Context) ([]models.Application, error) {
|
||||
var apps []models.Application
|
||||
err := s.client.Get(ctx, "applications", &apps)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list applications: %w", err)
|
||||
}
|
||||
return apps, nil
|
||||
}
|
||||
|
||||
// Get retrieves a specific application by UUID
|
||||
func (s *ApplicationService) Get(ctx context.Context, uuid string) (*models.Application, error) {
|
||||
var app models.Application
|
||||
err := s.client.Get(ctx, fmt.Sprintf("applications/%s", uuid), &app)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get application %s: %w", uuid, err)
|
||||
}
|
||||
return &app, nil
|
||||
}
|
||||
|
||||
// Update updates an application
|
||||
func (s *ApplicationService) Update(ctx context.Context, uuid string, req models.ApplicationUpdateRequest) (*models.Application, error) {
|
||||
var app models.Application
|
||||
err := s.client.Patch(ctx, fmt.Sprintf("applications/%s", uuid), req, &app)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to update application %s: %w", uuid, err)
|
||||
}
|
||||
return &app, nil
|
||||
}
|
||||
|
||||
// Delete deletes an application
|
||||
func (s *ApplicationService) Delete(ctx context.Context, uuid string) error {
|
||||
err := s.client.Delete(ctx, fmt.Sprintf("applications/%s", uuid))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete application %s: %w", uuid, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Start starts an application (initiates deployment)
|
||||
func (s *ApplicationService) Start(ctx context.Context, uuid string, force bool, instantDeploy bool) (*models.ApplicationLifecycleResponse, error) {
|
||||
var resp models.ApplicationLifecycleResponse
|
||||
|
||||
// Build URL with query parameters
|
||||
url := fmt.Sprintf("applications/%s/start", uuid)
|
||||
if force || instantDeploy {
|
||||
url += "?"
|
||||
if force {
|
||||
url += "force=true"
|
||||
}
|
||||
if instantDeploy {
|
||||
if force {
|
||||
url += "&"
|
||||
}
|
||||
url += "instant_deploy=true"
|
||||
}
|
||||
}
|
||||
|
||||
err := s.client.Get(ctx, url, &resp)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to start application %s: %w", uuid, err)
|
||||
}
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
// Stop stops an application
|
||||
func (s *ApplicationService) Stop(ctx context.Context, uuid string) (*models.ApplicationLifecycleResponse, error) {
|
||||
var resp models.ApplicationLifecycleResponse
|
||||
err := s.client.Get(ctx, fmt.Sprintf("applications/%s/stop", uuid), &resp)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to stop application %s: %w", uuid, err)
|
||||
}
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
// Restart restarts an application
|
||||
func (s *ApplicationService) Restart(ctx context.Context, uuid string) (*models.ApplicationLifecycleResponse, error) {
|
||||
var resp models.ApplicationLifecycleResponse
|
||||
err := s.client.Get(ctx, fmt.Sprintf("applications/%s/restart", uuid), &resp)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to restart application %s: %w", uuid, err)
|
||||
}
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
// Logs retrieves logs for an application
|
||||
func (s *ApplicationService) Logs(ctx context.Context, uuid string, lines int) (*models.ApplicationLogsResponse, error) {
|
||||
url := fmt.Sprintf("applications/%s/logs", uuid)
|
||||
|
||||
// Add lines parameter if specified
|
||||
if lines > 0 {
|
||||
url = fmt.Sprintf("%s?lines=%d", url, lines)
|
||||
}
|
||||
|
||||
var resp models.ApplicationLogsResponse
|
||||
err := s.client.Get(ctx, url, &resp)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get logs for application %s: %w", uuid, err)
|
||||
}
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
// ListEnvs retrieves all environment variables for an application
|
||||
func (s *ApplicationService) ListEnvs(ctx context.Context, uuid string) ([]models.EnvironmentVariable, error) {
|
||||
var envs []models.EnvironmentVariable
|
||||
err := s.client.Get(ctx, fmt.Sprintf("applications/%s/envs", uuid), &envs)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list environment variables for application %s: %w", uuid, err)
|
||||
}
|
||||
return envs, nil
|
||||
}
|
||||
|
||||
// CreateEnv creates a new environment variable for an application
|
||||
func (s *ApplicationService) CreateEnv(ctx context.Context, uuid string, req *models.EnvironmentVariableCreateRequest) (*models.EnvironmentVariable, error) {
|
||||
var env models.EnvironmentVariable
|
||||
err := s.client.Post(ctx, fmt.Sprintf("applications/%s/envs", uuid), req, &env)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create environment variable for application %s: %w", uuid, err)
|
||||
}
|
||||
return &env, nil
|
||||
}
|
||||
|
||||
// UpdateEnv updates an existing environment variable for an application
|
||||
func (s *ApplicationService) UpdateEnv(ctx context.Context, appUUID string, req *models.EnvironmentVariableUpdateRequest) (*models.EnvironmentVariable, error) {
|
||||
var env models.EnvironmentVariable
|
||||
err := s.client.Patch(ctx, fmt.Sprintf("applications/%s/envs", appUUID), req, &env)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to update environment variable for application %s: %w", appUUID, err)
|
||||
}
|
||||
return &env, nil
|
||||
}
|
||||
|
||||
// GetEnv retrieves a single environment variable by UUID or key
|
||||
func (s *ApplicationService) GetEnv(ctx context.Context, appUUID, envIdentifier string) (*models.EnvironmentVariable, error) {
|
||||
envs, err := s.ListEnvs(ctx, appUUID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Try to find by UUID first, then by key
|
||||
for _, env := range envs {
|
||||
if env.UUID == envIdentifier || env.Key == envIdentifier {
|
||||
return &env, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("environment variable '%s' not found in application %s", envIdentifier, appUUID)
|
||||
}
|
||||
|
||||
// DeleteEnv deletes an environment variable from an application
|
||||
func (s *ApplicationService) DeleteEnv(ctx context.Context, appUUID string, envUUID string) error {
|
||||
err := s.client.Delete(ctx, fmt.Sprintf("applications/%s/envs/%s", appUUID, envUUID))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete environment variable %s for application %s: %w", envUUID, appUUID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// BulkUpdateEnvsRequest represents a bulk update request for environment variables
|
||||
type BulkUpdateEnvsRequest struct {
|
||||
Data []models.EnvironmentVariableCreateRequest `json:"data"`
|
||||
}
|
||||
|
||||
// BulkUpdateEnvsResponse represents the response from bulk update
|
||||
type BulkUpdateEnvsResponse struct {
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// BulkUpdateEnvs updates multiple environment variables in a single request
|
||||
func (s *ApplicationService) BulkUpdateEnvs(ctx context.Context, appUUID string, req *BulkUpdateEnvsRequest) (*BulkUpdateEnvsResponse, error) {
|
||||
var response BulkUpdateEnvsResponse
|
||||
err := s.client.Patch(ctx, fmt.Sprintf("applications/%s/envs/bulk", appUUID), req, &response)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to bulk update environment variables for application %s: %w", appUUID, err)
|
||||
}
|
||||
return &response, nil
|
||||
}
|
||||
@@ -0,0 +1,800 @@
|
||||
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 TestApplicationService_List(t *testing.T) {
|
||||
desc1 := "App Description 1"
|
||||
desc2 := "App Description 2"
|
||||
branch1 := "main"
|
||||
branch2 := "develop"
|
||||
fqdn1 := "app1.example.com"
|
||||
fqdn2 := "app2.example.com"
|
||||
|
||||
applications := []models.Application{
|
||||
{
|
||||
ID: 1,
|
||||
UUID: "app-uuid-1",
|
||||
Name: "Test App 1",
|
||||
Description: &desc1,
|
||||
Status: "running",
|
||||
GitBranch: &branch1,
|
||||
FQDN: &fqdn1,
|
||||
CreatedAt: "2024-01-01T00:00:00Z",
|
||||
UpdatedAt: "2024-01-02T00:00:00Z",
|
||||
},
|
||||
{
|
||||
ID: 2,
|
||||
UUID: "app-uuid-2",
|
||||
Name: "Test App 2",
|
||||
Description: &desc2,
|
||||
Status: "stopped",
|
||||
GitBranch: &branch2,
|
||||
FQDN: &fqdn2,
|
||||
CreatedAt: "2024-01-03T00:00:00Z",
|
||||
UpdatedAt: "2024-01-04T00:00:00Z",
|
||||
},
|
||||
}
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "/api/v1/applications", r.URL.Path)
|
||||
assert.Equal(t, "GET", r.Method)
|
||||
assert.Equal(t, "Bearer test-token", r.Header.Get("Authorization"))
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(applications)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := api.NewClient(server.URL, "test-token")
|
||||
svc := NewApplicationService(client)
|
||||
|
||||
result, err := svc.List(context.Background())
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, result, 2)
|
||||
assert.Equal(t, "app-uuid-1", result[0].UUID)
|
||||
assert.Equal(t, "Test App 1", result[0].Name)
|
||||
assert.Equal(t, "running", result[0].Status)
|
||||
assert.Equal(t, "main", *result[0].GitBranch)
|
||||
assert.Equal(t, "app-uuid-2", result[1].UUID)
|
||||
assert.Equal(t, "Test App 2", result[1].Name)
|
||||
assert.Equal(t, "stopped", result[1].Status)
|
||||
}
|
||||
|
||||
func TestApplicationService_List_Empty(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "/api/v1/applications", r.URL.Path)
|
||||
assert.Equal(t, "GET", r.Method)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode([]models.Application{})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := api.NewClient(server.URL, "test-token")
|
||||
svc := NewApplicationService(client)
|
||||
|
||||
result, err := svc.List(context.Background())
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, result)
|
||||
}
|
||||
|
||||
func TestApplicationService_List_Error(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
w.Write([]byte(`{"message":"internal server error"}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := api.NewClient(server.URL, "test-token")
|
||||
svc := NewApplicationService(client)
|
||||
|
||||
result, err := svc.List(context.Background())
|
||||
require.Error(t, err)
|
||||
assert.Nil(t, result)
|
||||
assert.Contains(t, err.Error(), "failed to list applications")
|
||||
}
|
||||
|
||||
func TestApplicationService_Get(t *testing.T) {
|
||||
desc := "Test Application"
|
||||
branch := "main"
|
||||
fqdn := "test.example.com"
|
||||
|
||||
application := models.Application{
|
||||
ID: 1,
|
||||
UUID: "app-uuid-123",
|
||||
Name: "Test App",
|
||||
Description: &desc,
|
||||
Status: "running",
|
||||
GitBranch: &branch,
|
||||
FQDN: &fqdn,
|
||||
CreatedAt: "2024-01-01T00:00:00Z",
|
||||
UpdatedAt: "2024-01-02T00:00:00Z",
|
||||
}
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "/api/v1/applications/app-uuid-123", r.URL.Path)
|
||||
assert.Equal(t, "GET", r.Method)
|
||||
assert.Equal(t, "Bearer test-token", r.Header.Get("Authorization"))
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(application)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := api.NewClient(server.URL, "test-token")
|
||||
svc := NewApplicationService(client)
|
||||
|
||||
result, err := svc.Get(context.Background(), "app-uuid-123")
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, result)
|
||||
assert.Equal(t, "app-uuid-123", result.UUID)
|
||||
assert.Equal(t, "Test App", result.Name)
|
||||
assert.Equal(t, "running", result.Status)
|
||||
assert.Equal(t, "main", *result.GitBranch)
|
||||
assert.Equal(t, "test.example.com", *result.FQDN)
|
||||
}
|
||||
|
||||
func TestApplicationService_Get_NotFound(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
w.Write([]byte(`{"message":"application not found"}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := api.NewClient(server.URL, "test-token")
|
||||
svc := NewApplicationService(client)
|
||||
|
||||
result, err := svc.Get(context.Background(), "non-existent-uuid")
|
||||
require.Error(t, err)
|
||||
assert.Nil(t, result)
|
||||
assert.Contains(t, err.Error(), "failed to get application")
|
||||
}
|
||||
|
||||
func TestApplicationService_Get_Error(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
w.Write([]byte(`{"message":"internal server error"}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := api.NewClient(server.URL, "test-token")
|
||||
svc := NewApplicationService(client)
|
||||
|
||||
result, err := svc.Get(context.Background(), "app-uuid-123")
|
||||
require.Error(t, err)
|
||||
assert.Nil(t, result)
|
||||
assert.Contains(t, err.Error(), "failed to get application")
|
||||
}
|
||||
|
||||
func TestApplicationService_Update(t *testing.T) {
|
||||
newName := "Updated App Name"
|
||||
newBranch := "develop"
|
||||
newDesc := "Updated description"
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "/api/v1/applications/app-uuid-123", r.URL.Path)
|
||||
assert.Equal(t, "PATCH", r.Method)
|
||||
assert.Equal(t, "Bearer test-token", r.Header.Get("Authorization"))
|
||||
|
||||
// Verify request body
|
||||
var req models.ApplicationUpdateRequest
|
||||
json.NewDecoder(r.Body).Decode(&req)
|
||||
assert.NotNil(t, req.Name)
|
||||
assert.Equal(t, newName, *req.Name)
|
||||
assert.NotNil(t, req.GitBranch)
|
||||
assert.Equal(t, newBranch, *req.GitBranch)
|
||||
|
||||
// Return updated application
|
||||
updatedApp := models.Application{
|
||||
ID: 1,
|
||||
UUID: "app-uuid-123",
|
||||
Name: newName,
|
||||
Description: &newDesc,
|
||||
Status: "running",
|
||||
GitBranch: &newBranch,
|
||||
CreatedAt: "2024-01-01T00:00:00Z",
|
||||
UpdatedAt: "2024-01-05T00:00:00Z",
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(updatedApp)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := api.NewClient(server.URL, "test-token")
|
||||
svc := NewApplicationService(client)
|
||||
|
||||
req := models.ApplicationUpdateRequest{
|
||||
Name: &newName,
|
||||
GitBranch: &newBranch,
|
||||
}
|
||||
|
||||
result, err := svc.Update(context.Background(), "app-uuid-123", req)
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, result)
|
||||
assert.Equal(t, "app-uuid-123", result.UUID)
|
||||
assert.Equal(t, newName, result.Name)
|
||||
assert.Equal(t, newBranch, *result.GitBranch)
|
||||
}
|
||||
|
||||
func TestApplicationService_Update_PartialUpdate(t *testing.T) {
|
||||
newDomains := "app.example.com,www.example.com"
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "/api/v1/applications/app-uuid-456", r.URL.Path)
|
||||
assert.Equal(t, "PATCH", r.Method)
|
||||
|
||||
// Verify only domains field is in request
|
||||
var req models.ApplicationUpdateRequest
|
||||
json.NewDecoder(r.Body).Decode(&req)
|
||||
assert.Nil(t, req.Name)
|
||||
assert.Nil(t, req.GitBranch)
|
||||
assert.NotNil(t, req.Domains)
|
||||
assert.Equal(t, newDomains, *req.Domains)
|
||||
|
||||
// Return updated application
|
||||
fqdn := "app.example.com"
|
||||
updatedApp := models.Application{
|
||||
ID: 2,
|
||||
UUID: "app-uuid-456",
|
||||
Name: "Existing App",
|
||||
Status: "running",
|
||||
FQDN: &fqdn,
|
||||
CreatedAt: "2024-01-01T00:00:00Z",
|
||||
UpdatedAt: "2024-01-05T00:00:00Z",
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(updatedApp)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := api.NewClient(server.URL, "test-token")
|
||||
svc := NewApplicationService(client)
|
||||
|
||||
req := models.ApplicationUpdateRequest{
|
||||
Domains: &newDomains,
|
||||
}
|
||||
|
||||
result, err := svc.Update(context.Background(), "app-uuid-456", req)
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, result)
|
||||
assert.Equal(t, "app-uuid-456", result.UUID)
|
||||
assert.Equal(t, "app.example.com", *result.FQDN)
|
||||
}
|
||||
|
||||
func TestApplicationService_Update_NotFound(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
w.Write([]byte(`{"message":"application not found"}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := api.NewClient(server.URL, "test-token")
|
||||
svc := NewApplicationService(client)
|
||||
|
||||
newName := "Updated Name"
|
||||
req := models.ApplicationUpdateRequest{
|
||||
Name: &newName,
|
||||
}
|
||||
|
||||
result, err := svc.Update(context.Background(), "non-existent-uuid", req)
|
||||
require.Error(t, err)
|
||||
assert.Nil(t, result)
|
||||
assert.Contains(t, err.Error(), "failed to update application")
|
||||
}
|
||||
|
||||
func TestApplicationService_Update_Error(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
w.Write([]byte(`{"message":"internal server error"}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := api.NewClient(server.URL, "test-token")
|
||||
svc := NewApplicationService(client)
|
||||
|
||||
newName := "Updated Name"
|
||||
req := models.ApplicationUpdateRequest{
|
||||
Name: &newName,
|
||||
}
|
||||
|
||||
result, err := svc.Update(context.Background(), "app-uuid-123", req)
|
||||
require.Error(t, err)
|
||||
assert.Nil(t, result)
|
||||
assert.Contains(t, err.Error(), "failed to update application")
|
||||
}
|
||||
|
||||
func TestApplicationService_Delete(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "/api/v1/applications/app-uuid-123", r.URL.Path)
|
||||
assert.Equal(t, "DELETE", r.Method)
|
||||
assert.Equal(t, "Bearer test-token", r.Header.Get("Authorization"))
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := api.NewClient(server.URL, "test-token")
|
||||
svc := NewApplicationService(client)
|
||||
|
||||
err := svc.Delete(context.Background(), "app-uuid-123")
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestApplicationService_Delete_NotFound(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
w.Write([]byte(`{"message":"application not found"}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := api.NewClient(server.URL, "test-token")
|
||||
svc := NewApplicationService(client)
|
||||
|
||||
err := svc.Delete(context.Background(), "non-existent-uuid")
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "failed to delete application")
|
||||
}
|
||||
|
||||
func TestApplicationService_Delete_Error(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
w.Write([]byte(`{"message":"internal server error"}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := api.NewClient(server.URL, "test-token")
|
||||
svc := NewApplicationService(client)
|
||||
|
||||
err := svc.Delete(context.Background(), "app-uuid-123")
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "failed to delete application")
|
||||
}
|
||||
|
||||
func TestApplicationService_Start(t *testing.T) {
|
||||
deploymentUUID := "deploy-uuid-123"
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "/api/v1/applications/app-uuid-123/start", r.URL.Path)
|
||||
assert.Equal(t, "GET", r.Method)
|
||||
assert.Equal(t, "Bearer test-token", r.Header.Get("Authorization"))
|
||||
|
||||
resp := models.ApplicationLifecycleResponse{
|
||||
Message: "Deployment request queued.",
|
||||
DeploymentUUID: &deploymentUUID,
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(resp)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := api.NewClient(server.URL, "test-token")
|
||||
svc := NewApplicationService(client)
|
||||
|
||||
result, err := svc.Start(context.Background(), "app-uuid-123", false, false)
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, result)
|
||||
assert.Equal(t, "Deployment request queued.", result.Message)
|
||||
assert.NotNil(t, result.DeploymentUUID)
|
||||
assert.Equal(t, "deploy-uuid-123", *result.DeploymentUUID)
|
||||
}
|
||||
|
||||
func TestApplicationService_Start_WithForce(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "/api/v1/applications/app-uuid-123/start", r.URL.Path)
|
||||
assert.Equal(t, "force=true", r.URL.RawQuery)
|
||||
|
||||
resp := models.ApplicationLifecycleResponse{
|
||||
Message: "Deployment request queued.",
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(resp)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := api.NewClient(server.URL, "test-token")
|
||||
svc := NewApplicationService(client)
|
||||
|
||||
result, err := svc.Start(context.Background(), "app-uuid-123", true, false)
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, result)
|
||||
}
|
||||
|
||||
func TestApplicationService_Start_WithInstantDeploy(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "/api/v1/applications/app-uuid-123/start", r.URL.Path)
|
||||
assert.Equal(t, "instant_deploy=true", r.URL.RawQuery)
|
||||
|
||||
resp := models.ApplicationLifecycleResponse{
|
||||
Message: "Deployment request queued.",
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(resp)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := api.NewClient(server.URL, "test-token")
|
||||
svc := NewApplicationService(client)
|
||||
|
||||
result, err := svc.Start(context.Background(), "app-uuid-123", false, true)
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, result)
|
||||
}
|
||||
|
||||
func TestApplicationService_Start_Error(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
w.Write([]byte(`{"message":"failed to start application"}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := api.NewClient(server.URL, "test-token")
|
||||
svc := NewApplicationService(client)
|
||||
|
||||
result, err := svc.Start(context.Background(), "app-uuid-123", false, false)
|
||||
require.Error(t, err)
|
||||
assert.Nil(t, result)
|
||||
assert.Contains(t, err.Error(), "failed to start application")
|
||||
}
|
||||
|
||||
func TestApplicationService_Stop(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "/api/v1/applications/app-uuid-123/stop", r.URL.Path)
|
||||
assert.Equal(t, "GET", r.Method)
|
||||
assert.Equal(t, "Bearer test-token", r.Header.Get("Authorization"))
|
||||
|
||||
resp := models.ApplicationLifecycleResponse{
|
||||
Message: "Application stopped successfully",
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(resp)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := api.NewClient(server.URL, "test-token")
|
||||
svc := NewApplicationService(client)
|
||||
|
||||
result, err := svc.Stop(context.Background(), "app-uuid-123")
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, result)
|
||||
assert.Equal(t, "Application stopped successfully", result.Message)
|
||||
}
|
||||
|
||||
func TestApplicationService_Stop_Error(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
w.Write([]byte(`{"message":"failed to stop application"}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := api.NewClient(server.URL, "test-token")
|
||||
svc := NewApplicationService(client)
|
||||
|
||||
result, err := svc.Stop(context.Background(), "app-uuid-123")
|
||||
require.Error(t, err)
|
||||
assert.Nil(t, result)
|
||||
assert.Contains(t, err.Error(), "failed to stop application")
|
||||
}
|
||||
|
||||
func TestApplicationService_Restart(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "/api/v1/applications/app-uuid-123/restart", r.URL.Path)
|
||||
assert.Equal(t, "GET", r.Method)
|
||||
assert.Equal(t, "Bearer test-token", r.Header.Get("Authorization"))
|
||||
|
||||
resp := models.ApplicationLifecycleResponse{
|
||||
Message: "Application restarted successfully",
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(resp)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := api.NewClient(server.URL, "test-token")
|
||||
svc := NewApplicationService(client)
|
||||
|
||||
result, err := svc.Restart(context.Background(), "app-uuid-123")
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, result)
|
||||
assert.Equal(t, "Application restarted successfully", result.Message)
|
||||
}
|
||||
|
||||
func TestApplicationService_Restart_Error(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
w.Write([]byte(`{"message":"failed to restart application"}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := api.NewClient(server.URL, "test-token")
|
||||
svc := NewApplicationService(client)
|
||||
|
||||
result, err := svc.Restart(context.Background(), "app-uuid-123")
|
||||
require.Error(t, err)
|
||||
assert.Nil(t, result)
|
||||
assert.Contains(t, err.Error(), "failed to restart application")
|
||||
}
|
||||
|
||||
func TestApplicationService_Logs(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "/api/v1/applications/app-uuid-123/logs", r.URL.Path)
|
||||
assert.Equal(t, "GET", r.Method)
|
||||
assert.Equal(t, "Bearer test-token", r.Header.Get("Authorization"))
|
||||
|
||||
resp := models.ApplicationLogsResponse{
|
||||
Logs: "[2025-10-15 12:00:00] Application started\n[2025-10-15 12:00:01] Server listening on port 3000\n",
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(resp)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := api.NewClient(server.URL, "test-token")
|
||||
svc := NewApplicationService(client)
|
||||
|
||||
result, err := svc.Logs(context.Background(), "app-uuid-123", 0)
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, result)
|
||||
assert.Contains(t, result.Logs, "Application started")
|
||||
}
|
||||
|
||||
func TestApplicationService_Logs_WithLines(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "/api/v1/applications/app-uuid-123/logs", r.URL.Path)
|
||||
assert.Equal(t, "lines=50", r.URL.RawQuery)
|
||||
assert.Equal(t, "GET", r.Method)
|
||||
|
||||
resp := models.ApplicationLogsResponse{
|
||||
Logs: "[2025-10-15 12:00:00] Log line\n",
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(resp)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := api.NewClient(server.URL, "test-token")
|
||||
svc := NewApplicationService(client)
|
||||
|
||||
result, err := svc.Logs(context.Background(), "app-uuid-123", 50)
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, result)
|
||||
}
|
||||
|
||||
func TestApplicationService_Logs_Error(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
w.Write([]byte(`{"message":"application not found"}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := api.NewClient(server.URL, "test-token")
|
||||
svc := NewApplicationService(client)
|
||||
|
||||
result, err := svc.Logs(context.Background(), "app-uuid-123", 0)
|
||||
require.Error(t, err)
|
||||
assert.Nil(t, result)
|
||||
assert.Contains(t, err.Error(), "failed to get logs for application")
|
||||
}
|
||||
|
||||
func TestApplicationService_ListEnvs(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "/api/v1/applications/app-uuid-123/envs", r.URL.Path)
|
||||
assert.Equal(t, "GET", r.Method)
|
||||
assert.Equal(t, "Bearer test-token", r.Header.Get("Authorization"))
|
||||
|
||||
envs := []models.EnvironmentVariable{
|
||||
{
|
||||
ID: 1,
|
||||
UUID: "env-uuid-1",
|
||||
Key: "DATABASE_URL",
|
||||
Value: "********",
|
||||
IsBuildTime: false,
|
||||
IsPreview: false,
|
||||
},
|
||||
{
|
||||
ID: 2,
|
||||
UUID: "env-uuid-2",
|
||||
Key: "API_KEY",
|
||||
Value: "********",
|
||||
IsBuildTime: true,
|
||||
IsPreview: false,
|
||||
},
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(envs)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := api.NewClient(server.URL, "test-token")
|
||||
svc := NewApplicationService(client)
|
||||
|
||||
result, err := svc.ListEnvs(context.Background(), "app-uuid-123")
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, result, 2)
|
||||
assert.Equal(t, "DATABASE_URL", result[0].Key)
|
||||
assert.Equal(t, "API_KEY", result[1].Key)
|
||||
}
|
||||
|
||||
func TestApplicationService_ListEnvs_Empty(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "/api/v1/applications/app-uuid-123/envs", r.URL.Path)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode([]models.EnvironmentVariable{})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := api.NewClient(server.URL, "test-token")
|
||||
svc := NewApplicationService(client)
|
||||
|
||||
result, err := svc.ListEnvs(context.Background(), "app-uuid-123")
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, result, 0)
|
||||
}
|
||||
|
||||
func TestApplicationService_ListEnvs_Error(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
w.Write([]byte(`{"message":"application not found"}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := api.NewClient(server.URL, "test-token")
|
||||
svc := NewApplicationService(client)
|
||||
|
||||
result, err := svc.ListEnvs(context.Background(), "app-uuid-123")
|
||||
require.Error(t, err)
|
||||
assert.Nil(t, result)
|
||||
assert.Contains(t, err.Error(), "failed to list environment variables")
|
||||
}
|
||||
|
||||
func TestApplicationService_CreateEnv(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "/api/v1/applications/app-uuid-123/envs", r.URL.Path)
|
||||
assert.Equal(t, "POST", r.Method)
|
||||
assert.Equal(t, "Bearer test-token", r.Header.Get("Authorization"))
|
||||
|
||||
env := models.EnvironmentVariable{
|
||||
ID: 1,
|
||||
UUID: "env-uuid-1",
|
||||
Key: "API_KEY",
|
||||
Value: "secret123",
|
||||
IsBuildTime: false,
|
||||
IsPreview: false,
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
json.NewEncoder(w).Encode(env)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := api.NewClient(server.URL, "test-token")
|
||||
svc := NewApplicationService(client)
|
||||
|
||||
isBuildTime := false
|
||||
req := &models.EnvironmentVariableCreateRequest{
|
||||
Key: "API_KEY",
|
||||
Value: "secret123",
|
||||
IsBuildTime: &isBuildTime,
|
||||
}
|
||||
|
||||
result, err := svc.CreateEnv(context.Background(), "app-uuid-123", req)
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, result)
|
||||
assert.Equal(t, "API_KEY", result.Key)
|
||||
assert.Equal(t, "env-uuid-1", result.UUID)
|
||||
}
|
||||
|
||||
func TestApplicationService_CreateEnv_Error(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
w.Write([]byte(`{"message":"key already exists"}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := api.NewClient(server.URL, "test-token")
|
||||
svc := NewApplicationService(client)
|
||||
|
||||
req := &models.EnvironmentVariableCreateRequest{
|
||||
Key: "API_KEY",
|
||||
Value: "secret123",
|
||||
}
|
||||
|
||||
result, err := svc.CreateEnv(context.Background(), "app-uuid-123", req)
|
||||
require.Error(t, err)
|
||||
assert.Nil(t, result)
|
||||
assert.Contains(t, err.Error(), "failed to create environment variable")
|
||||
}
|
||||
|
||||
func TestApplicationService_UpdateEnv(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "/api/v1/applications/app-uuid-123/envs", r.URL.Path)
|
||||
assert.Equal(t, "PATCH", r.Method)
|
||||
|
||||
env := models.EnvironmentVariable{
|
||||
UUID: "env-uuid-1",
|
||||
Key: "API_KEY",
|
||||
Value: "newsecret456",
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(env)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := api.NewClient(server.URL, "test-token")
|
||||
svc := NewApplicationService(client)
|
||||
|
||||
newValue := "newsecret456"
|
||||
req := &models.EnvironmentVariableUpdateRequest{
|
||||
UUID: "env-uuid-1",
|
||||
Value: &newValue,
|
||||
}
|
||||
|
||||
result, err := svc.UpdateEnv(context.Background(), "app-uuid-123", req)
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, result)
|
||||
assert.Equal(t, "API_KEY", result.Key)
|
||||
assert.Equal(t, "newsecret456", result.Value)
|
||||
}
|
||||
|
||||
func TestApplicationService_UpdateEnv_Error(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
w.Write([]byte(`{"message":"environment variable not found"}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := api.NewClient(server.URL, "test-token")
|
||||
svc := NewApplicationService(client)
|
||||
|
||||
newValue := "newsecret456"
|
||||
req := &models.EnvironmentVariableUpdateRequest{
|
||||
UUID: "env-uuid-1",
|
||||
Value: &newValue,
|
||||
}
|
||||
|
||||
result, err := svc.UpdateEnv(context.Background(), "app-uuid-123", req)
|
||||
require.Error(t, err)
|
||||
assert.Nil(t, result)
|
||||
assert.Contains(t, err.Error(), "failed to update environment variable")
|
||||
}
|
||||
|
||||
func TestApplicationService_DeleteEnv(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "/api/v1/applications/app-uuid-123/envs/env-uuid-1", r.URL.Path)
|
||||
assert.Equal(t, "DELETE", r.Method)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := api.NewClient(server.URL, "test-token")
|
||||
svc := NewApplicationService(client)
|
||||
|
||||
err := svc.DeleteEnv(context.Background(), "app-uuid-123", "env-uuid-1")
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestApplicationService_DeleteEnv_Error(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
w.Write([]byte(`{"message":"environment variable not found"}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := api.NewClient(server.URL, "test-token")
|
||||
svc := NewApplicationService(client)
|
||||
|
||||
err := svc.DeleteEnv(context.Background(), "app-uuid-123", "env-uuid-1")
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "failed to delete environment variable")
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/coollabsio/coolify-cli/internal/api"
|
||||
"github.com/coollabsio/coolify-cli/internal/models"
|
||||
)
|
||||
|
||||
// DatabaseService handles database-related operations
|
||||
type DatabaseService struct {
|
||||
client *api.Client
|
||||
}
|
||||
|
||||
// NewDatabaseService creates a new database service
|
||||
func NewDatabaseService(client *api.Client) *DatabaseService {
|
||||
return &DatabaseService{client: client}
|
||||
}
|
||||
|
||||
// List retrieves all databases
|
||||
func (s *DatabaseService) List(ctx context.Context) ([]models.Database, error) {
|
||||
var databases []models.Database
|
||||
err := s.client.Get(ctx, "databases", &databases)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list databases: %w", err)
|
||||
}
|
||||
|
||||
// Infer database type if not provided by API
|
||||
for i := range databases {
|
||||
if databases[i].Type == "" {
|
||||
databases[i].Type = inferDatabaseType(&databases[i])
|
||||
}
|
||||
}
|
||||
|
||||
return databases, nil
|
||||
}
|
||||
|
||||
// Get retrieves a database by UUID
|
||||
func (s *DatabaseService) Get(ctx context.Context, uuid string) (*models.Database, error) {
|
||||
var database models.Database
|
||||
err := s.client.Get(ctx, fmt.Sprintf("databases/%s", uuid), &database)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get database %s: %w", uuid, err)
|
||||
}
|
||||
|
||||
// Infer database type if not provided by API
|
||||
if database.Type == "" {
|
||||
database.Type = inferDatabaseType(&database)
|
||||
}
|
||||
|
||||
return &database, nil
|
||||
}
|
||||
|
||||
// Create creates a new database of the specified type
|
||||
func (s *DatabaseService) Create(ctx context.Context, dbType string, req *models.DatabaseCreateRequest) (*models.Database, error) {
|
||||
var database models.Database
|
||||
err := s.client.Post(ctx, fmt.Sprintf("databases/%s", dbType), req, &database)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create %s database: %w", dbType, err)
|
||||
}
|
||||
return &database, nil
|
||||
}
|
||||
|
||||
// Update updates a database
|
||||
func (s *DatabaseService) Update(ctx context.Context, uuid string, req *models.DatabaseUpdateRequest) error {
|
||||
err := s.client.Patch(ctx, fmt.Sprintf("databases/%s", uuid), req, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to update database %s: %w", uuid, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Delete deletes a database
|
||||
func (s *DatabaseService) Delete(ctx context.Context, uuid string, deleteConfigurations, deleteVolumes, dockerCleanup, deleteConnectedNetworks bool) error {
|
||||
url := fmt.Sprintf("databases/%s?delete_configurations=%t&delete_volumes=%t&docker_cleanup=%t&delete_connected_networks=%t",
|
||||
uuid, deleteConfigurations, deleteVolumes, dockerCleanup, deleteConnectedNetworks)
|
||||
|
||||
err := s.client.Delete(ctx, url)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete database %s: %w", uuid, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Start starts a database
|
||||
func (s *DatabaseService) Start(ctx context.Context, uuid string) (*models.DatabaseLifecycleResponse, error) {
|
||||
var response models.DatabaseLifecycleResponse
|
||||
err := s.client.Get(ctx, fmt.Sprintf("databases/%s/start", uuid), &response)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to start database %s: %w", uuid, err)
|
||||
}
|
||||
return &response, nil
|
||||
}
|
||||
|
||||
// Stop stops a database
|
||||
func (s *DatabaseService) Stop(ctx context.Context, uuid string) (*models.DatabaseLifecycleResponse, error) {
|
||||
var response models.DatabaseLifecycleResponse
|
||||
err := s.client.Get(ctx, fmt.Sprintf("databases/%s/stop", uuid), &response)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to stop database %s: %w", uuid, err)
|
||||
}
|
||||
return &response, nil
|
||||
}
|
||||
|
||||
// Restart restarts a database
|
||||
func (s *DatabaseService) Restart(ctx context.Context, uuid string) (*models.DatabaseLifecycleResponse, error) {
|
||||
var response models.DatabaseLifecycleResponse
|
||||
err := s.client.Get(ctx, fmt.Sprintf("databases/%s/restart", uuid), &response)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to restart database %s: %w", uuid, err)
|
||||
}
|
||||
return &response, nil
|
||||
}
|
||||
|
||||
// ListBackups retrieves all backup configurations for a database
|
||||
func (s *DatabaseService) ListBackups(ctx context.Context, uuid string) ([]models.DatabaseBackup, error) {
|
||||
var backups []models.DatabaseBackup
|
||||
err := s.client.Get(ctx, fmt.Sprintf("databases/%s/backups", uuid), &backups)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list backups for database %s: %w", uuid, err)
|
||||
}
|
||||
return backups, nil
|
||||
}
|
||||
|
||||
// CreateBackup creates a new scheduled backup configuration
|
||||
// Note: This endpoint will be available in a future version of Coolify
|
||||
func (s *DatabaseService) CreateBackup(ctx context.Context, uuid string, req *models.DatabaseBackupCreateRequest) (*models.DatabaseBackup, error) {
|
||||
var backup models.DatabaseBackup
|
||||
err := s.client.Post(ctx, fmt.Sprintf("databases/%s/backups", uuid), req, &backup)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create backup for database %s: %w", uuid, err)
|
||||
}
|
||||
return &backup, nil
|
||||
}
|
||||
|
||||
// UpdateBackup updates a backup configuration
|
||||
func (s *DatabaseService) UpdateBackup(ctx context.Context, dbUUID, backupUUID string, req *models.DatabaseBackupUpdateRequest) error {
|
||||
err := s.client.Patch(ctx, fmt.Sprintf("databases/%s/backups/%s", dbUUID, backupUUID), req, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to update backup %s for database %s: %w", backupUUID, dbUUID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteBackup deletes a backup configuration
|
||||
func (s *DatabaseService) DeleteBackup(ctx context.Context, dbUUID, backupUUID string, deleteS3 bool) error {
|
||||
url := fmt.Sprintf("databases/%s/backups/%s?delete_s3=%t", dbUUID, backupUUID, deleteS3)
|
||||
err := s.client.Delete(ctx, url)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete backup %s for database %s: %w", backupUUID, dbUUID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListBackupExecutions retrieves all executions for a backup configuration
|
||||
func (s *DatabaseService) ListBackupExecutions(ctx context.Context, dbUUID, backupUUID string) ([]models.DatabaseBackupExecution, error) {
|
||||
var response models.DatabaseBackupExecutionsResponse
|
||||
err := s.client.Get(ctx, fmt.Sprintf("databases/%s/backups/%s/executions", dbUUID, backupUUID), &response)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list backup executions for backup %s: %w", backupUUID, err)
|
||||
}
|
||||
return response.Executions, nil
|
||||
}
|
||||
|
||||
// DeleteBackupExecution deletes a specific backup execution
|
||||
func (s *DatabaseService) DeleteBackupExecution(ctx context.Context, dbUUID, backupUUID, executionUUID string, deleteS3 bool) error {
|
||||
url := fmt.Sprintf("databases/%s/backups/%s/executions/%s?delete_s3=%t", dbUUID, backupUUID, executionUUID, deleteS3)
|
||||
err := s.client.Delete(ctx, url)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete backup execution %s: %w", executionUUID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// inferDatabaseType determines the database type from available fields
|
||||
func inferDatabaseType(db *models.Database) string {
|
||||
// Check for PostgreSQL
|
||||
if db.PostgresUser != nil || db.PostgresPassword != nil || db.PostgresDb != nil {
|
||||
return "postgresql"
|
||||
}
|
||||
|
||||
// Check for MySQL
|
||||
if db.MysqlUser != nil || db.MysqlPassword != nil || db.MysqlDatabase != nil {
|
||||
return "mysql"
|
||||
}
|
||||
|
||||
// Check for MariaDB
|
||||
if db.MariadbUser != nil || db.MariadbPassword != nil || db.MariadbDatabase != nil {
|
||||
return "mariadb"
|
||||
}
|
||||
|
||||
// Check for MongoDB
|
||||
if db.MongoInitdbRootUsername != nil || db.MongoInitdbRootPassword != nil || db.MongoInitdbDatabase != nil {
|
||||
return "mongodb"
|
||||
}
|
||||
|
||||
// Check for Redis
|
||||
if db.RedisPassword != nil || db.RedisConf != nil {
|
||||
return "redis"
|
||||
}
|
||||
|
||||
// Check for KeyDB
|
||||
if db.KeydbPassword != nil || db.KeydbConf != nil {
|
||||
return "keydb"
|
||||
}
|
||||
|
||||
// Check for Clickhouse
|
||||
if db.ClickhouseAdminUser != nil || db.ClickhouseAdminPassword != nil {
|
||||
return "clickhouse"
|
||||
}
|
||||
|
||||
// Check for Dragonfly
|
||||
if db.DragonflyPassword != nil {
|
||||
return "dragonfly"
|
||||
}
|
||||
|
||||
// Fallback: try to infer from image name
|
||||
if db.Image != nil {
|
||||
image := *db.Image
|
||||
if len(image) > 0 {
|
||||
// Extract base image name (e.g., "postgres:16-alpine" -> "postgres")
|
||||
for i := 0; i < len(image); i++ {
|
||||
if image[i] == ':' {
|
||||
return image[:i]
|
||||
}
|
||||
}
|
||||
return image
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,858 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"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 TestDatabaseService_List(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
serverResponse string
|
||||
statusCode int
|
||||
wantErr bool
|
||||
wantCount int
|
||||
}{
|
||||
{
|
||||
name: "successful list",
|
||||
serverResponse: `[
|
||||
{
|
||||
"id": 1,
|
||||
"uuid": "db-uuid-1",
|
||||
"name": "Production PostgreSQL",
|
||||
"description": "Main database",
|
||||
"status": "running",
|
||||
"type": "postgresql",
|
||||
"created_at": "2024-01-01T00:00:00Z",
|
||||
"updated_at": "2024-01-01T00:00:00Z"
|
||||
}
|
||||
]`,
|
||||
statusCode: http.StatusOK,
|
||||
wantErr: false,
|
||||
wantCount: 1,
|
||||
},
|
||||
{
|
||||
name: "empty list",
|
||||
serverResponse: `[]`,
|
||||
statusCode: http.StatusOK,
|
||||
wantErr: false,
|
||||
wantCount: 0,
|
||||
},
|
||||
{
|
||||
name: "server error",
|
||||
serverResponse: `{"error":"internal server error"}`,
|
||||
statusCode: http.StatusInternalServerError,
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "/api/v1/databases", r.URL.Path)
|
||||
assert.Equal(t, http.MethodGet, r.Method)
|
||||
w.WriteHeader(tt.statusCode)
|
||||
w.Write([]byte(tt.serverResponse))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := api.NewClient(server.URL, "test-token")
|
||||
dbService := NewDatabaseService(client)
|
||||
|
||||
databases, err := dbService.List(context.Background())
|
||||
|
||||
if tt.wantErr {
|
||||
assert.Error(t, err)
|
||||
return
|
||||
}
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, databases, tt.wantCount)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDatabaseService_Get(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
uuid string
|
||||
serverResponse string
|
||||
statusCode int
|
||||
wantErr bool
|
||||
wantName string
|
||||
}{
|
||||
{
|
||||
name: "successful get",
|
||||
uuid: "db-uuid-1",
|
||||
serverResponse: `{
|
||||
"id": 1,
|
||||
"uuid": "db-uuid-1",
|
||||
"name": "Production PostgreSQL",
|
||||
"description": "Main database",
|
||||
"status": "running",
|
||||
"type": "postgresql",
|
||||
"postgres_db": "myapp",
|
||||
"created_at": "2024-01-01T00:00:00Z",
|
||||
"updated_at": "2024-01-01T00:00:00Z"
|
||||
}`,
|
||||
statusCode: http.StatusOK,
|
||||
wantErr: false,
|
||||
wantName: "Production PostgreSQL",
|
||||
},
|
||||
{
|
||||
name: "not found",
|
||||
uuid: "nonexistent",
|
||||
serverResponse: `{"error":"not found"}`,
|
||||
statusCode: http.StatusNotFound,
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "/api/v1/databases/"+tt.uuid, r.URL.Path)
|
||||
assert.Equal(t, http.MethodGet, r.Method)
|
||||
w.WriteHeader(tt.statusCode)
|
||||
w.Write([]byte(tt.serverResponse))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := api.NewClient(server.URL, "test-token")
|
||||
dbService := NewDatabaseService(client)
|
||||
|
||||
database, err := dbService.Get(context.Background(), tt.uuid)
|
||||
|
||||
if tt.wantErr {
|
||||
assert.Error(t, err)
|
||||
return
|
||||
}
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tt.wantName, database.Name)
|
||||
assert.Equal(t, tt.uuid, database.UUID)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDatabaseService_Create(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
dbType string
|
||||
request *models.DatabaseCreateRequest
|
||||
serverResponse string
|
||||
statusCode int
|
||||
wantErr bool
|
||||
wantUUID string
|
||||
}{
|
||||
{
|
||||
name: "create postgresql",
|
||||
dbType: "postgresql",
|
||||
request: &models.DatabaseCreateRequest{
|
||||
ServerUUID: "server-uuid-1",
|
||||
ProjectUUID: "project-uuid-1",
|
||||
},
|
||||
serverResponse: `{
|
||||
"id": 1,
|
||||
"uuid": "db-uuid-new",
|
||||
"name": "New Database",
|
||||
"status": "starting",
|
||||
"type": "postgresql",
|
||||
"created_at": "2024-01-01T00:00:00Z",
|
||||
"updated_at": "2024-01-01T00:00:00Z"
|
||||
}`,
|
||||
statusCode: http.StatusOK,
|
||||
wantErr: false,
|
||||
wantUUID: "db-uuid-new",
|
||||
},
|
||||
{
|
||||
name: "validation error",
|
||||
dbType: "mysql",
|
||||
request: &models.DatabaseCreateRequest{
|
||||
ServerUUID: "server-uuid-1",
|
||||
},
|
||||
serverResponse: `{"error":"project_uuid is required"}`,
|
||||
statusCode: http.StatusUnprocessableEntity,
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "/api/v1/databases/"+tt.dbType, r.URL.Path)
|
||||
assert.Equal(t, http.MethodPost, r.Method)
|
||||
|
||||
var req models.DatabaseCreateRequest
|
||||
err := json.NewDecoder(r.Body).Decode(&req)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tt.request.ServerUUID, req.ServerUUID)
|
||||
|
||||
w.WriteHeader(tt.statusCode)
|
||||
w.Write([]byte(tt.serverResponse))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := api.NewClient(server.URL, "test-token")
|
||||
dbService := NewDatabaseService(client)
|
||||
|
||||
database, err := dbService.Create(context.Background(), tt.dbType, tt.request)
|
||||
|
||||
if tt.wantErr {
|
||||
assert.Error(t, err)
|
||||
return
|
||||
}
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tt.wantUUID, database.UUID)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDatabaseService_Update(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
uuid string
|
||||
request *models.DatabaseUpdateRequest
|
||||
statusCode int
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "successful update",
|
||||
uuid: "db-uuid-1",
|
||||
request: &models.DatabaseUpdateRequest{
|
||||
Name: stringPtr("Updated Name"),
|
||||
},
|
||||
statusCode: http.StatusOK,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "not found",
|
||||
uuid: "nonexistent",
|
||||
request: &models.DatabaseUpdateRequest{
|
||||
Name: stringPtr("Updated Name"),
|
||||
},
|
||||
statusCode: http.StatusNotFound,
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "/api/v1/databases/"+tt.uuid, r.URL.Path)
|
||||
assert.Equal(t, http.MethodPatch, r.Method)
|
||||
|
||||
var req models.DatabaseUpdateRequest
|
||||
err := json.NewDecoder(r.Body).Decode(&req)
|
||||
require.NoError(t, err)
|
||||
|
||||
w.WriteHeader(tt.statusCode)
|
||||
if tt.statusCode == http.StatusNotFound {
|
||||
w.Write([]byte(`{"error":"not found"}`))
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := api.NewClient(server.URL, "test-token")
|
||||
dbService := NewDatabaseService(client)
|
||||
|
||||
err := dbService.Update(context.Background(), tt.uuid, tt.request)
|
||||
|
||||
if tt.wantErr {
|
||||
assert.Error(t, err)
|
||||
return
|
||||
}
|
||||
|
||||
require.NoError(t, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDatabaseService_Delete(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
uuid string
|
||||
deleteConfigurations bool
|
||||
deleteVolumes bool
|
||||
dockerCleanup bool
|
||||
deleteConnectedNetworks bool
|
||||
statusCode int
|
||||
wantErr bool
|
||||
expectedQueryString string
|
||||
}{
|
||||
{
|
||||
name: "successful delete with all cleanup",
|
||||
uuid: "db-uuid-1",
|
||||
deleteConfigurations: true,
|
||||
deleteVolumes: true,
|
||||
dockerCleanup: true,
|
||||
deleteConnectedNetworks: true,
|
||||
statusCode: http.StatusOK,
|
||||
wantErr: false,
|
||||
expectedQueryString: "delete_configurations=true&delete_volumes=true&docker_cleanup=true&delete_connected_networks=true",
|
||||
},
|
||||
{
|
||||
name: "successful delete without cleanup",
|
||||
uuid: "db-uuid-2",
|
||||
deleteConfigurations: false,
|
||||
deleteVolumes: false,
|
||||
dockerCleanup: false,
|
||||
deleteConnectedNetworks: false,
|
||||
statusCode: http.StatusOK,
|
||||
wantErr: false,
|
||||
expectedQueryString: "delete_configurations=false&delete_volumes=false&docker_cleanup=false&delete_connected_networks=false",
|
||||
},
|
||||
{
|
||||
name: "not found",
|
||||
uuid: "nonexistent",
|
||||
deleteConfigurations: true,
|
||||
deleteVolumes: true,
|
||||
dockerCleanup: true,
|
||||
deleteConnectedNetworks: true,
|
||||
statusCode: http.StatusNotFound,
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "/api/v1/databases/"+tt.uuid, r.URL.Path)
|
||||
assert.Equal(t, http.MethodDelete, r.Method)
|
||||
if tt.expectedQueryString != "" {
|
||||
assert.Equal(t, tt.expectedQueryString, r.URL.RawQuery)
|
||||
}
|
||||
|
||||
w.WriteHeader(tt.statusCode)
|
||||
if tt.statusCode == http.StatusOK {
|
||||
w.Write([]byte(`{"message":"Database deleted"}`))
|
||||
} else {
|
||||
w.Write([]byte(`{"error":"not found"}`))
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := api.NewClient(server.URL, "test-token")
|
||||
dbService := NewDatabaseService(client)
|
||||
|
||||
err := dbService.Delete(context.Background(), tt.uuid, tt.deleteConfigurations, tt.deleteVolumes, tt.dockerCleanup, tt.deleteConnectedNetworks)
|
||||
|
||||
if tt.wantErr {
|
||||
assert.Error(t, err)
|
||||
return
|
||||
}
|
||||
|
||||
require.NoError(t, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDatabaseService_Start(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
uuid string
|
||||
serverResponse string
|
||||
statusCode int
|
||||
wantErr bool
|
||||
wantMessage string
|
||||
}{
|
||||
{
|
||||
name: "successful start",
|
||||
uuid: "db-uuid-1",
|
||||
serverResponse: `{"message":"Database started successfully"}`,
|
||||
statusCode: http.StatusOK,
|
||||
wantErr: false,
|
||||
wantMessage: "Database started successfully",
|
||||
},
|
||||
{
|
||||
name: "not found",
|
||||
uuid: "nonexistent",
|
||||
serverResponse: `{"error":"not found"}`,
|
||||
statusCode: http.StatusNotFound,
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "/api/v1/databases/"+tt.uuid+"/start", r.URL.Path)
|
||||
assert.Equal(t, http.MethodGet, r.Method)
|
||||
w.WriteHeader(tt.statusCode)
|
||||
w.Write([]byte(tt.serverResponse))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := api.NewClient(server.URL, "test-token")
|
||||
dbService := NewDatabaseService(client)
|
||||
|
||||
response, err := dbService.Start(context.Background(), tt.uuid)
|
||||
|
||||
if tt.wantErr {
|
||||
assert.Error(t, err)
|
||||
return
|
||||
}
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tt.wantMessage, response.Message)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDatabaseService_Stop(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
uuid string
|
||||
serverResponse string
|
||||
statusCode int
|
||||
wantErr bool
|
||||
wantMessage string
|
||||
}{
|
||||
{
|
||||
name: "successful stop",
|
||||
uuid: "db-uuid-1",
|
||||
serverResponse: `{"message":"Database stopped successfully"}`,
|
||||
statusCode: http.StatusOK,
|
||||
wantErr: false,
|
||||
wantMessage: "Database stopped successfully",
|
||||
},
|
||||
{
|
||||
name: "not found",
|
||||
uuid: "nonexistent",
|
||||
serverResponse: `{"error":"not found"}`,
|
||||
statusCode: http.StatusNotFound,
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "/api/v1/databases/"+tt.uuid+"/stop", r.URL.Path)
|
||||
assert.Equal(t, http.MethodGet, r.Method)
|
||||
w.WriteHeader(tt.statusCode)
|
||||
w.Write([]byte(tt.serverResponse))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := api.NewClient(server.URL, "test-token")
|
||||
dbService := NewDatabaseService(client)
|
||||
|
||||
response, err := dbService.Stop(context.Background(), tt.uuid)
|
||||
|
||||
if tt.wantErr {
|
||||
assert.Error(t, err)
|
||||
return
|
||||
}
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tt.wantMessage, response.Message)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDatabaseService_Restart(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
uuid string
|
||||
serverResponse string
|
||||
statusCode int
|
||||
wantErr bool
|
||||
wantMessage string
|
||||
}{
|
||||
{
|
||||
name: "successful restart",
|
||||
uuid: "db-uuid-1",
|
||||
serverResponse: `{"message":"Database restarted successfully"}`,
|
||||
statusCode: http.StatusOK,
|
||||
wantErr: false,
|
||||
wantMessage: "Database restarted successfully",
|
||||
},
|
||||
{
|
||||
name: "not found",
|
||||
uuid: "nonexistent",
|
||||
serverResponse: `{"error":"not found"}`,
|
||||
statusCode: http.StatusNotFound,
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "/api/v1/databases/"+tt.uuid+"/restart", r.URL.Path)
|
||||
assert.Equal(t, http.MethodGet, r.Method)
|
||||
w.WriteHeader(tt.statusCode)
|
||||
w.Write([]byte(tt.serverResponse))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := api.NewClient(server.URL, "test-token")
|
||||
dbService := NewDatabaseService(client)
|
||||
|
||||
response, err := dbService.Restart(context.Background(), tt.uuid)
|
||||
|
||||
if tt.wantErr {
|
||||
assert.Error(t, err)
|
||||
return
|
||||
}
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tt.wantMessage, response.Message)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDatabaseService_ListBackups(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
dbUUID string
|
||||
serverResponse string
|
||||
statusCode int
|
||||
wantErr bool
|
||||
wantCount int
|
||||
}{
|
||||
{
|
||||
name: "successful list",
|
||||
dbUUID: "db-uuid-1",
|
||||
serverResponse: `[
|
||||
{
|
||||
"uuid": "backup-uuid-1",
|
||||
"enabled": true,
|
||||
"frequency": "0 2 * * *",
|
||||
"created_at": "2024-01-01T00:00:00Z",
|
||||
"updated_at": "2024-01-01T00:00:00Z"
|
||||
}
|
||||
]`,
|
||||
statusCode: http.StatusOK,
|
||||
wantErr: false,
|
||||
wantCount: 1,
|
||||
},
|
||||
{
|
||||
name: "empty list",
|
||||
dbUUID: "db-uuid-2",
|
||||
serverResponse: `[]`,
|
||||
statusCode: http.StatusOK,
|
||||
wantErr: false,
|
||||
wantCount: 0,
|
||||
},
|
||||
{
|
||||
name: "not found",
|
||||
dbUUID: "nonexistent",
|
||||
serverResponse: `{"error":"not found"}`,
|
||||
statusCode: http.StatusNotFound,
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "/api/v1/databases/"+tt.dbUUID+"/backups", r.URL.Path)
|
||||
assert.Equal(t, http.MethodGet, r.Method)
|
||||
w.WriteHeader(tt.statusCode)
|
||||
w.Write([]byte(tt.serverResponse))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := api.NewClient(server.URL, "test-token")
|
||||
dbService := NewDatabaseService(client)
|
||||
|
||||
backups, err := dbService.ListBackups(context.Background(), tt.dbUUID)
|
||||
|
||||
if tt.wantErr {
|
||||
assert.Error(t, err)
|
||||
return
|
||||
}
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, backups, tt.wantCount)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDatabaseService_UpdateBackup(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
dbUUID string
|
||||
backupUUID string
|
||||
request *models.DatabaseBackupUpdateRequest
|
||||
statusCode int
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "successful update",
|
||||
dbUUID: "db-uuid-1",
|
||||
backupUUID: "backup-uuid-1",
|
||||
request: &models.DatabaseBackupUpdateRequest{
|
||||
Enabled: boolPtr(true),
|
||||
},
|
||||
statusCode: http.StatusOK,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "not found",
|
||||
dbUUID: "db-uuid-1",
|
||||
backupUUID: "nonexistent",
|
||||
request: &models.DatabaseBackupUpdateRequest{
|
||||
Enabled: boolPtr(false),
|
||||
},
|
||||
statusCode: http.StatusNotFound,
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "/api/v1/databases/"+tt.dbUUID+"/backups/"+tt.backupUUID, r.URL.Path)
|
||||
assert.Equal(t, http.MethodPatch, r.Method)
|
||||
w.WriteHeader(tt.statusCode)
|
||||
if tt.statusCode == http.StatusNotFound {
|
||||
w.Write([]byte(`{"error":"not found"}`))
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := api.NewClient(server.URL, "test-token")
|
||||
dbService := NewDatabaseService(client)
|
||||
|
||||
err := dbService.UpdateBackup(context.Background(), tt.dbUUID, tt.backupUUID, tt.request)
|
||||
|
||||
if tt.wantErr {
|
||||
assert.Error(t, err)
|
||||
return
|
||||
}
|
||||
|
||||
require.NoError(t, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDatabaseService_DeleteBackup(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
dbUUID string
|
||||
backupUUID string
|
||||
deleteS3 bool
|
||||
statusCode int
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "successful delete with S3",
|
||||
dbUUID: "db-uuid-1",
|
||||
backupUUID: "backup-uuid-1",
|
||||
deleteS3: true,
|
||||
statusCode: http.StatusOK,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "successful delete without S3",
|
||||
dbUUID: "db-uuid-1",
|
||||
backupUUID: "backup-uuid-2",
|
||||
deleteS3: false,
|
||||
statusCode: http.StatusOK,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "not found",
|
||||
dbUUID: "db-uuid-1",
|
||||
backupUUID: "nonexistent",
|
||||
deleteS3: false,
|
||||
statusCode: http.StatusNotFound,
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "/api/v1/databases/"+tt.dbUUID+"/backups/"+tt.backupUUID, r.URL.Path)
|
||||
assert.Equal(t, http.MethodDelete, r.Method)
|
||||
assert.Contains(t, r.URL.RawQuery, fmt.Sprintf("delete_s3=%t", tt.deleteS3))
|
||||
w.WriteHeader(tt.statusCode)
|
||||
if tt.statusCode == http.StatusOK {
|
||||
w.Write([]byte(`{"message":"Backup deleted"}`))
|
||||
} else {
|
||||
w.Write([]byte(`{"error":"not found"}`))
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := api.NewClient(server.URL, "test-token")
|
||||
dbService := NewDatabaseService(client)
|
||||
|
||||
err := dbService.DeleteBackup(context.Background(), tt.dbUUID, tt.backupUUID, tt.deleteS3)
|
||||
|
||||
if tt.wantErr {
|
||||
assert.Error(t, err)
|
||||
return
|
||||
}
|
||||
|
||||
require.NoError(t, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDatabaseService_ListBackupExecutions(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
dbUUID string
|
||||
backupUUID string
|
||||
serverResponse string
|
||||
statusCode int
|
||||
wantErr bool
|
||||
wantCount int
|
||||
}{
|
||||
{
|
||||
name: "successful list",
|
||||
dbUUID: "db-uuid-1",
|
||||
backupUUID: "backup-uuid-1",
|
||||
serverResponse: `{
|
||||
"executions": [
|
||||
{
|
||||
"uuid": "exec-uuid-1",
|
||||
"filename": "backup.sql",
|
||||
"size": 1024,
|
||||
"status": "success",
|
||||
"created_at": "2024-01-01T00:00:00Z"
|
||||
}
|
||||
]
|
||||
}`,
|
||||
statusCode: http.StatusOK,
|
||||
wantErr: false,
|
||||
wantCount: 1,
|
||||
},
|
||||
{
|
||||
name: "empty list",
|
||||
dbUUID: "db-uuid-2",
|
||||
backupUUID: "backup-uuid-2",
|
||||
serverResponse: `{
|
||||
"executions": []
|
||||
}`,
|
||||
statusCode: http.StatusOK,
|
||||
wantErr: false,
|
||||
wantCount: 0,
|
||||
},
|
||||
{
|
||||
name: "not found",
|
||||
dbUUID: "db-uuid-1",
|
||||
backupUUID: "nonexistent",
|
||||
serverResponse: `{"error":"not found"}`,
|
||||
statusCode: http.StatusNotFound,
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "/api/v1/databases/"+tt.dbUUID+"/backups/"+tt.backupUUID+"/executions", r.URL.Path)
|
||||
assert.Equal(t, http.MethodGet, r.Method)
|
||||
w.WriteHeader(tt.statusCode)
|
||||
w.Write([]byte(tt.serverResponse))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := api.NewClient(server.URL, "test-token")
|
||||
dbService := NewDatabaseService(client)
|
||||
|
||||
executions, err := dbService.ListBackupExecutions(context.Background(), tt.dbUUID, tt.backupUUID)
|
||||
|
||||
if tt.wantErr {
|
||||
assert.Error(t, err)
|
||||
return
|
||||
}
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, executions, tt.wantCount)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDatabaseService_DeleteBackupExecution(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
dbUUID string
|
||||
backupUUID string
|
||||
executionUUID string
|
||||
deleteS3 bool
|
||||
statusCode int
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "successful delete with S3",
|
||||
dbUUID: "db-uuid-1",
|
||||
backupUUID: "backup-uuid-1",
|
||||
executionUUID: "exec-uuid-1",
|
||||
deleteS3: true,
|
||||
statusCode: http.StatusOK,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "successful delete without S3",
|
||||
dbUUID: "db-uuid-1",
|
||||
backupUUID: "backup-uuid-1",
|
||||
executionUUID: "exec-uuid-2",
|
||||
deleteS3: false,
|
||||
statusCode: http.StatusOK,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "not found",
|
||||
dbUUID: "db-uuid-1",
|
||||
backupUUID: "backup-uuid-1",
|
||||
executionUUID: "nonexistent",
|
||||
deleteS3: false,
|
||||
statusCode: http.StatusNotFound,
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "/api/v1/databases/"+tt.dbUUID+"/backups/"+tt.backupUUID+"/executions/"+tt.executionUUID, r.URL.Path)
|
||||
assert.Equal(t, http.MethodDelete, r.Method)
|
||||
assert.Contains(t, r.URL.RawQuery, fmt.Sprintf("delete_s3=%t", tt.deleteS3))
|
||||
w.WriteHeader(tt.statusCode)
|
||||
if tt.statusCode == http.StatusOK {
|
||||
w.Write([]byte(`{"message":"Backup execution deleted"}`))
|
||||
} else {
|
||||
w.Write([]byte(`{"error":"not found"}`))
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := api.NewClient(server.URL, "test-token")
|
||||
dbService := NewDatabaseService(client)
|
||||
|
||||
err := dbService.DeleteBackupExecution(context.Background(), tt.dbUUID, tt.backupUUID, tt.executionUUID, tt.deleteS3)
|
||||
|
||||
if tt.wantErr {
|
||||
assert.Error(t, err)
|
||||
return
|
||||
}
|
||||
|
||||
require.NoError(t, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func stringPtr(s string) *string {
|
||||
return &s
|
||||
}
|
||||
|
||||
func boolPtr(b bool) *bool {
|
||||
return &b
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/coollabsio/coolify-cli/internal/api"
|
||||
"github.com/coollabsio/coolify-cli/internal/models"
|
||||
)
|
||||
|
||||
// DeploymentService handles deployment-related operations
|
||||
type DeploymentService struct {
|
||||
client *api.Client
|
||||
}
|
||||
|
||||
// NewDeploymentService creates a new deployment service
|
||||
func NewDeploymentService(client *api.Client) *DeploymentService {
|
||||
return &DeploymentService{
|
||||
client: client,
|
||||
}
|
||||
}
|
||||
|
||||
// DeploymentInfo represents a single deployment in the deploy response
|
||||
type DeploymentInfo struct {
|
||||
Message string `json:"message"`
|
||||
ResourceUUID string `json:"resource_uuid"`
|
||||
DeploymentUUID string `json:"deployment_uuid"`
|
||||
}
|
||||
|
||||
// DeployResponse represents the response from a deploy operation
|
||||
type DeployResponse struct {
|
||||
Deployments []DeploymentInfo `json:"deployments"`
|
||||
}
|
||||
|
||||
// Deploy triggers a deployment for a resource
|
||||
func (s *DeploymentService) Deploy(ctx context.Context, uuid string, force bool) (*DeployResponse, error) {
|
||||
endpoint := fmt.Sprintf("deploy?uuid=%s", uuid)
|
||||
if force {
|
||||
endpoint += "&force=true"
|
||||
}
|
||||
|
||||
var response DeployResponse
|
||||
err := s.client.Get(ctx, endpoint, &response)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to deploy resource %s: %w", uuid, err)
|
||||
}
|
||||
return &response, nil
|
||||
}
|
||||
|
||||
// List retrieves all deployments
|
||||
func (s *DeploymentService) List(ctx context.Context) ([]models.Deployment, error) {
|
||||
var deployments []models.Deployment
|
||||
err := s.client.Get(ctx, "deployments", &deployments)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list deployments: %w", err)
|
||||
}
|
||||
return deployments, nil
|
||||
}
|
||||
|
||||
// Get retrieves a deployment by UUID
|
||||
func (s *DeploymentService) Get(ctx context.Context, uuid string) (*models.Deployment, error) {
|
||||
var deployment models.Deployment
|
||||
err := s.client.Get(ctx, fmt.Sprintf("deployments/%s", uuid), &deployment)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get deployment %s: %w", uuid, err)
|
||||
}
|
||||
return &deployment, nil
|
||||
}
|
||||
|
||||
// CancelResponse represents the response from canceling a deployment
|
||||
type CancelResponse struct {
|
||||
Message string `json:"message"`
|
||||
DeploymentUUID string `json:"deployment_uuid"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
// Cancel cancels an in-progress deployment
|
||||
// Note: This endpoint will be available in a future version of Coolify
|
||||
func (s *DeploymentService) Cancel(ctx context.Context, uuid string) (*CancelResponse, error) {
|
||||
var response CancelResponse
|
||||
err := s.client.Post(ctx, fmt.Sprintf("deployments/%s/cancel", uuid), nil, &response)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to cancel deployment %s: %w", uuid, err)
|
||||
}
|
||||
return &response, nil
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/coollabsio/coolify-cli/internal/api"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestDeploymentService_Deploy(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
uuid string
|
||||
force bool
|
||||
expectedPath string
|
||||
response DeployResponse
|
||||
}{
|
||||
{
|
||||
name: "deploy without force",
|
||||
uuid: "res-123",
|
||||
force: false,
|
||||
expectedPath: "/api/v1/deploy?uuid=res-123",
|
||||
response: DeployResponse{
|
||||
Message: "Deployment started",
|
||||
DeploymentID: "dep-456",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "deploy with force",
|
||||
uuid: "res-789",
|
||||
force: true,
|
||||
expectedPath: "/api/v1/deploy?uuid=res-789&force=true",
|
||||
response: DeployResponse{
|
||||
Message: "Force deployment started",
|
||||
DeploymentID: "dep-999",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, tt.expectedPath, r.URL.Path+"?"+r.URL.RawQuery)
|
||||
assert.Equal(t, "GET", r.Method)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(tt.response)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := api.NewClient(server.URL, "test-token")
|
||||
svc := NewDeploymentService(client)
|
||||
|
||||
result, err := svc.Deploy(context.Background(), tt.uuid, tt.force)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tt.response.Message, result.Message)
|
||||
assert.Equal(t, tt.response.DeploymentID, result.DeploymentID)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/coollabsio/coolify-cli/internal/api"
|
||||
"github.com/coollabsio/coolify-cli/internal/models"
|
||||
)
|
||||
|
||||
// DomainService handles domain-related operations
|
||||
type DomainService struct {
|
||||
client *api.Client
|
||||
}
|
||||
|
||||
// NewDomainService creates a new domain service
|
||||
func NewDomainService(client *api.Client) *DomainService {
|
||||
return &DomainService{
|
||||
client: client,
|
||||
}
|
||||
}
|
||||
|
||||
// List retrieves all domains
|
||||
func (s *DomainService) List(ctx context.Context) ([]models.Domain, error) {
|
||||
var domains []models.Domain
|
||||
err := s.client.Get(ctx, "domains", &domains)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list domains: %w", err)
|
||||
}
|
||||
return domains, nil
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
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 TestDomainService_List(t *testing.T) {
|
||||
domains := []models.Domain{
|
||||
{
|
||||
IP: "192.168.1.1",
|
||||
Domains: []string{"example.com", "www.example.com"},
|
||||
},
|
||||
{
|
||||
IP: "192.168.1.2",
|
||||
Domains: []string{"test.com"},
|
||||
},
|
||||
}
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "/api/v1/domains", r.URL.Path)
|
||||
assert.Equal(t, "GET", r.Method)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(domains)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := api.NewClient(server.URL, "test-token")
|
||||
svc := NewDomainService(client)
|
||||
|
||||
result, err := svc.List(context.Background())
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, result, 2)
|
||||
assert.Equal(t, "192.168.1.1", result[0].IP)
|
||||
assert.Equal(t, []string{"example.com", "www.example.com"}, result[0].Domains)
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/coollabsio/coolify-cli/internal/api"
|
||||
"github.com/coollabsio/coolify-cli/internal/models"
|
||||
)
|
||||
|
||||
// GitHubAppService handles GitHub App-related operations
|
||||
type GitHubAppService struct {
|
||||
client *api.Client
|
||||
}
|
||||
|
||||
// NewGitHubAppService creates a new GitHub App service
|
||||
func NewGitHubAppService(client *api.Client) *GitHubAppService {
|
||||
return &GitHubAppService{
|
||||
client: client,
|
||||
}
|
||||
}
|
||||
|
||||
// List retrieves all GitHub Apps
|
||||
// Note: This endpoint will be available in a future version of Coolify
|
||||
func (s *GitHubAppService) List(ctx context.Context) ([]models.GitHubApp, error) {
|
||||
var apps []models.GitHubApp
|
||||
err := s.client.Get(ctx, "github-apps", &apps)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list GitHub Apps: %w", err)
|
||||
}
|
||||
return apps, nil
|
||||
}
|
||||
|
||||
// Get retrieves a specific GitHub App by UUID
|
||||
// Note: This endpoint will be available in a future version of Coolify
|
||||
func (s *GitHubAppService) Get(ctx context.Context, uuid string) (*models.GitHubApp, error) {
|
||||
var app models.GitHubApp
|
||||
err := s.client.Get(ctx, fmt.Sprintf("github-apps/%s", uuid), &app)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get GitHub App %s: %w", uuid, err)
|
||||
}
|
||||
return &app, nil
|
||||
}
|
||||
|
||||
// Create creates a new GitHub App
|
||||
func (s *GitHubAppService) Create(ctx context.Context, req *models.GitHubAppCreateRequest) (*models.GitHubApp, error) {
|
||||
var app models.GitHubApp
|
||||
err := s.client.Post(ctx, "github-apps", req, &app)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create GitHub App: %w", err)
|
||||
}
|
||||
return &app, nil
|
||||
}
|
||||
|
||||
// Update updates an existing GitHub App
|
||||
func (s *GitHubAppService) Update(ctx context.Context, uuid string, req *models.GitHubAppUpdateRequest) error {
|
||||
type response struct {
|
||||
Message string `json:"message"`
|
||||
}
|
||||
var resp response
|
||||
err := s.client.Patch(ctx, fmt.Sprintf("github-apps/%s", uuid), req, &resp)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to update GitHub App %s: %w", uuid, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Delete deletes a GitHub App
|
||||
func (s *GitHubAppService) Delete(ctx context.Context, uuid string) error {
|
||||
err := s.client.Delete(ctx, fmt.Sprintf("github-apps/%s", uuid))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete GitHub App %s: %w", uuid, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListRepositories lists all repositories accessible by a GitHub App
|
||||
func (s *GitHubAppService) ListRepositories(ctx context.Context, appUUID string) ([]models.GitHubRepository, error) {
|
||||
type response struct {
|
||||
Repositories []models.GitHubRepository `json:"repositories"`
|
||||
}
|
||||
var resp response
|
||||
err := s.client.Get(ctx, fmt.Sprintf("github-apps/%s/repositories", appUUID), &resp)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list repositories for GitHub App %s: %w", appUUID, err)
|
||||
}
|
||||
return resp.Repositories, nil
|
||||
}
|
||||
|
||||
// ListBranches lists all branches for a repository
|
||||
func (s *GitHubAppService) ListBranches(ctx context.Context, appUUID string, owner, repo string) ([]models.GitHubBranch, error) {
|
||||
type response struct {
|
||||
Branches []models.GitHubBranch `json:"branches"`
|
||||
}
|
||||
var resp response
|
||||
err := s.client.Get(ctx, fmt.Sprintf("github-apps/%s/repositories/%s/%s/branches", appUUID, owner, repo), &resp)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list branches for %s/%s: %w", owner, repo, err)
|
||||
}
|
||||
return resp.Branches, nil
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/coollabsio/coolify-cli/internal/api"
|
||||
"github.com/coollabsio/coolify-cli/internal/models"
|
||||
)
|
||||
|
||||
// PrivateKeyService handles private key-related operations
|
||||
type PrivateKeyService struct {
|
||||
client *api.Client
|
||||
}
|
||||
|
||||
// NewPrivateKeyService creates a new private key service
|
||||
func NewPrivateKeyService(client *api.Client) *PrivateKeyService {
|
||||
return &PrivateKeyService{
|
||||
client: client,
|
||||
}
|
||||
}
|
||||
|
||||
// List retrieves all private keys
|
||||
func (s *PrivateKeyService) List(ctx context.Context) ([]models.PrivateKey, error) {
|
||||
var keys []models.PrivateKey
|
||||
err := s.client.Get(ctx, "security/keys", &keys)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list private keys: %w", err)
|
||||
}
|
||||
return keys, nil
|
||||
}
|
||||
|
||||
// Create creates a new private key
|
||||
func (s *PrivateKeyService) Create(ctx context.Context, req models.PrivateKeyCreateRequest) (*models.PrivateKey, error) {
|
||||
var key models.PrivateKey
|
||||
err := s.client.Post(ctx, "security/keys", req, &key)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create private key: %w", err)
|
||||
}
|
||||
return &key, nil
|
||||
}
|
||||
|
||||
// Delete deletes a private key by UUID
|
||||
func (s *PrivateKeyService) Delete(ctx context.Context, uuid string) error {
|
||||
err := s.client.Delete(ctx, fmt.Sprintf("security/keys/%s", uuid))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete private key %s: %w", uuid, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
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 TestPrivateKeyService_List(t *testing.T) {
|
||||
keys := []models.PrivateKey{
|
||||
{
|
||||
UUID: "key-1",
|
||||
Name: "Test Key 1",
|
||||
},
|
||||
{
|
||||
UUID: "key-2",
|
||||
Name: "Test Key 2",
|
||||
},
|
||||
}
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "/api/v1/security/keys", r.URL.Path)
|
||||
assert.Equal(t, "GET", r.Method)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(keys)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := api.NewClient(server.URL, "test-token")
|
||||
svc := NewPrivateKeyService(client)
|
||||
|
||||
result, err := svc.List(context.Background())
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, result, 2)
|
||||
assert.Equal(t, "key-1", result[0].UUID)
|
||||
assert.Equal(t, "Test Key 1", result[0].Name)
|
||||
}
|
||||
|
||||
func TestPrivateKeyService_Create(t *testing.T) {
|
||||
req := models.PrivateKeyCreateRequest{
|
||||
Name: "New Key",
|
||||
PrivateKey: "ssh-rsa AAAAB3...",
|
||||
}
|
||||
|
||||
key := models.PrivateKey{
|
||||
UUID: "key-123",
|
||||
Name: req.Name,
|
||||
}
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "/api/v1/security/keys", r.URL.Path)
|
||||
assert.Equal(t, "POST", r.Method)
|
||||
|
||||
var receivedReq models.PrivateKeyCreateRequest
|
||||
json.NewDecoder(r.Body).Decode(&receivedReq)
|
||||
assert.Equal(t, req.Name, receivedReq.Name)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(key)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := api.NewClient(server.URL, "test-token")
|
||||
svc := NewPrivateKeyService(client)
|
||||
|
||||
result, err := svc.Create(context.Background(), req)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "key-123", result.UUID)
|
||||
assert.Equal(t, "New Key", result.Name)
|
||||
}
|
||||
|
||||
func TestPrivateKeyService_Delete(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "/api/v1/security/keys/key-123", r.URL.Path)
|
||||
assert.Equal(t, "DELETE", r.Method)
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := api.NewClient(server.URL, "test-token")
|
||||
svc := NewPrivateKeyService(client)
|
||||
|
||||
err := svc.Delete(context.Background(), "key-123")
|
||||
require.NoError(t, err)
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/coollabsio/coolify-cli/internal/api"
|
||||
"github.com/coollabsio/coolify-cli/internal/models"
|
||||
)
|
||||
|
||||
// ProjectService handles project-related operations
|
||||
type ProjectService struct {
|
||||
client *api.Client
|
||||
}
|
||||
|
||||
// NewProjectService creates a new project service
|
||||
func NewProjectService(client *api.Client) *ProjectService {
|
||||
return &ProjectService{
|
||||
client: client,
|
||||
}
|
||||
}
|
||||
|
||||
// List retrieves all projects
|
||||
func (s *ProjectService) List(ctx context.Context) ([]models.Project, error) {
|
||||
var projects []models.Project
|
||||
err := s.client.Get(ctx, "projects", &projects)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list projects: %w", err)
|
||||
}
|
||||
return projects, nil
|
||||
}
|
||||
|
||||
// Get retrieves a specific project by UUID
|
||||
func (s *ProjectService) Get(ctx context.Context, uuid string) (*models.Project, error) {
|
||||
var project models.Project
|
||||
err := s.client.Get(ctx, fmt.Sprintf("projects/%s", uuid), &project)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get project %s: %w", uuid, err)
|
||||
}
|
||||
return &project, nil
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
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 TestProjectService_List(t *testing.T) {
|
||||
desc1 := "Description 1"
|
||||
desc2 := "Description 2"
|
||||
projects := []models.Project{
|
||||
{
|
||||
UUID: "proj-1",
|
||||
Name: "Test Project 1",
|
||||
Description: &desc1,
|
||||
},
|
||||
{
|
||||
UUID: "proj-2",
|
||||
Name: "Test Project 2",
|
||||
Description: &desc2,
|
||||
},
|
||||
}
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "/api/v1/projects", r.URL.Path)
|
||||
assert.Equal(t, "GET", r.Method)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(projects)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := api.NewClient(server.URL, "test-token")
|
||||
svc := NewProjectService(client)
|
||||
|
||||
result, err := svc.List(context.Background())
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, result, 2)
|
||||
assert.Equal(t, "proj-1", result[0].UUID)
|
||||
assert.Equal(t, "Test Project 1", result[0].Name)
|
||||
}
|
||||
|
||||
func TestProjectService_Get(t *testing.T) {
|
||||
desc := "Test Description"
|
||||
project := models.Project{
|
||||
UUID: "proj-1",
|
||||
Name: "Test Project",
|
||||
Description: &desc,
|
||||
}
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "/api/v1/projects/proj-1", r.URL.Path)
|
||||
assert.Equal(t, "GET", r.Method)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(project)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := api.NewClient(server.URL, "test-token")
|
||||
svc := NewProjectService(client)
|
||||
|
||||
result, err := svc.Get(context.Background(), "proj-1")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "proj-1", result.UUID)
|
||||
assert.Equal(t, "Test Project", result.Name)
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/coollabsio/coolify-cli/internal/api"
|
||||
"github.com/coollabsio/coolify-cli/internal/models"
|
||||
)
|
||||
|
||||
// ResourceService handles resource-related operations
|
||||
type ResourceService struct {
|
||||
client *api.Client
|
||||
}
|
||||
|
||||
// NewResourceService creates a new resource service
|
||||
func NewResourceService(client *api.Client) *ResourceService {
|
||||
return &ResourceService{
|
||||
client: client,
|
||||
}
|
||||
}
|
||||
|
||||
// List retrieves all resources
|
||||
func (s *ResourceService) List(ctx context.Context) ([]models.Resource, error) {
|
||||
var resources []models.Resource
|
||||
err := s.client.Get(ctx, "resources", &resources)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list resources: %w", err)
|
||||
}
|
||||
return resources, nil
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
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 TestResourceService_List(t *testing.T) {
|
||||
resources := []models.Resource{
|
||||
{
|
||||
UUID: "res-1",
|
||||
Name: "Test Resource 1",
|
||||
Type: "application",
|
||||
},
|
||||
{
|
||||
UUID: "res-2",
|
||||
Name: "Test Resource 2",
|
||||
Type: "database",
|
||||
},
|
||||
}
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "/api/v1/resources", r.URL.Path)
|
||||
assert.Equal(t, "GET", r.Method)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(resources)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := api.NewClient(server.URL, "test-token")
|
||||
svc := NewResourceService(client)
|
||||
|
||||
result, err := svc.List(context.Background())
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, result, 2)
|
||||
assert.Equal(t, "res-1", result[0].UUID)
|
||||
assert.Equal(t, "Test Resource 1", result[0].Name)
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/coollabsio/coolify-cli/internal/api"
|
||||
"github.com/coollabsio/coolify-cli/internal/models"
|
||||
)
|
||||
|
||||
// ServerService handles server-related operations
|
||||
type ServerService struct {
|
||||
client *api.Client
|
||||
}
|
||||
|
||||
// NewServerService creates a new server service
|
||||
func NewServerService(client *api.Client) *ServerService {
|
||||
return &ServerService{client: client}
|
||||
}
|
||||
|
||||
// List returns all servers
|
||||
func (s *ServerService) List(ctx context.Context) ([]models.Server, error) {
|
||||
var servers []models.Server
|
||||
err := s.client.Get(ctx, "servers", &servers)
|
||||
return servers, err
|
||||
}
|
||||
|
||||
// Get returns a single server by UUID
|
||||
func (s *ServerService) Get(ctx context.Context, uuid string) (*models.Server, error) {
|
||||
var server models.Server
|
||||
err := s.client.Get(ctx, "servers/"+uuid, &server)
|
||||
return &server, err
|
||||
}
|
||||
|
||||
// GetResources returns resources for a server
|
||||
func (s *ServerService) GetResources(ctx context.Context, uuid string) (*models.Resources, error) {
|
||||
var resources models.Resources
|
||||
err := s.client.Get(ctx, "servers/"+uuid+"?resources=true", &resources)
|
||||
return &resources, err
|
||||
}
|
||||
|
||||
// Create creates a new server
|
||||
func (s *ServerService) Create(ctx context.Context, req models.ServerCreateRequest) (*models.Response, error) {
|
||||
var response models.Response
|
||||
err := s.client.Post(ctx, "servers", req, &response)
|
||||
return &response, err
|
||||
}
|
||||
|
||||
// Delete deletes a server by UUID
|
||||
func (s *ServerService) Delete(ctx context.Context, uuid string) error {
|
||||
return s.client.Delete(ctx, "servers/"+uuid)
|
||||
}
|
||||
|
||||
// Validate validates a server by UUID
|
||||
func (s *ServerService) Validate(ctx context.Context, uuid string) (*models.Response, error) {
|
||||
var response models.Response
|
||||
err := s.client.Get(ctx, "servers/"+uuid+"/validate", &response)
|
||||
return &response, err
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
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 TestServerService_List(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "/api/v1/servers", r.URL.Path)
|
||||
assert.Equal(t, "GET", r.Method)
|
||||
|
||||
servers := []models.Server{
|
||||
{UUID: "uuid-1", Name: "server-1"},
|
||||
{UUID: "uuid-2", Name: "server-2"},
|
||||
}
|
||||
json.NewEncoder(w).Encode(servers)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := api.NewClient(server.URL, "test-token")
|
||||
svc := NewServerService(client)
|
||||
|
||||
servers, err := svc.List(context.Background())
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, servers, 2)
|
||||
assert.Equal(t, "uuid-1", servers[0].UUID)
|
||||
assert.Equal(t, "server-1", servers[0].Name)
|
||||
}
|
||||
|
||||
func TestServerService_Get(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "/api/v1/servers/test-uuid", r.URL.Path)
|
||||
assert.Equal(t, "GET", r.Method)
|
||||
|
||||
server := models.Server{
|
||||
UUID: "test-uuid",
|
||||
Name: "test-server",
|
||||
IP: "192.168.1.100",
|
||||
}
|
||||
json.NewEncoder(w).Encode(server)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := api.NewClient(server.URL, "test-token")
|
||||
svc := NewServerService(client)
|
||||
|
||||
result, err := svc.Get(context.Background(), "test-uuid")
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "test-uuid", result.UUID)
|
||||
assert.Equal(t, "test-server", result.Name)
|
||||
assert.Equal(t, "192.168.1.100", result.IP)
|
||||
}
|
||||
|
||||
func TestServerService_GetResources(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "/api/v1/servers/test-uuid", r.URL.Path)
|
||||
assert.Equal(t, "resources=true", r.URL.RawQuery)
|
||||
assert.Equal(t, "GET", r.Method)
|
||||
|
||||
resources := models.Resources{
|
||||
Resources: []models.Resource{
|
||||
{UUID: "res-1", Name: "resource-1", Type: "application"},
|
||||
},
|
||||
}
|
||||
json.NewEncoder(w).Encode(resources)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := api.NewClient(server.URL, "test-token")
|
||||
svc := NewServerService(client)
|
||||
|
||||
result, err := svc.GetResources(context.Background(), "test-uuid")
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, result.Resources, 1)
|
||||
assert.Equal(t, "res-1", result.Resources[0].UUID)
|
||||
}
|
||||
|
||||
func TestServerService_Create(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "/api/v1/servers", r.URL.Path)
|
||||
assert.Equal(t, "POST", r.Method)
|
||||
|
||||
var req models.ServerCreateRequest
|
||||
json.NewDecoder(r.Body).Decode(&req)
|
||||
|
||||
assert.Equal(t, "new-server", req.Name)
|
||||
assert.Equal(t, "192.168.1.200", req.IP)
|
||||
|
||||
response := models.Response{Message: "Server created"}
|
||||
json.NewEncoder(w).Encode(response)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := api.NewClient(server.URL, "test-token")
|
||||
svc := NewServerService(client)
|
||||
|
||||
req := models.ServerCreateRequest{
|
||||
Name: "new-server",
|
||||
IP: "192.168.1.200",
|
||||
Port: 22,
|
||||
User: "root",
|
||||
PrivateKeyUUID: "key-uuid",
|
||||
}
|
||||
|
||||
result, err := svc.Create(context.Background(), req)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "Server created", result.Message)
|
||||
}
|
||||
|
||||
func TestServerService_Delete(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "/api/v1/servers/test-uuid", r.URL.Path)
|
||||
assert.Equal(t, "DELETE", r.Method)
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode(models.Response{Message: "Server deleted"})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := api.NewClient(server.URL, "test-token")
|
||||
svc := NewServerService(client)
|
||||
|
||||
err := svc.Delete(context.Background(), "test-uuid")
|
||||
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestServerService_Validate(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "/api/v1/servers/test-uuid/validate", r.URL.Path)
|
||||
assert.Equal(t, "GET", r.Method)
|
||||
|
||||
response := models.Response{Message: "Server is valid"}
|
||||
json.NewEncoder(w).Encode(response)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := api.NewClient(server.URL, "test-token")
|
||||
svc := NewServerService(client)
|
||||
|
||||
result, err := svc.Validate(context.Background(), "test-uuid")
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "Server is valid", result.Message)
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/coollabsio/coolify-cli/internal/api"
|
||||
"github.com/coollabsio/coolify-cli/internal/models"
|
||||
)
|
||||
|
||||
// ServiceService handles service-related operations
|
||||
type ServiceService struct {
|
||||
client *api.Client
|
||||
}
|
||||
|
||||
// NewServiceService creates a new service service instance
|
||||
func NewServiceService(client *api.Client) *ServiceService {
|
||||
return &ServiceService{client: client}
|
||||
}
|
||||
|
||||
// List retrieves all services
|
||||
func (s *ServiceService) List(ctx context.Context) ([]models.Service, error) {
|
||||
var services []models.Service
|
||||
err := s.client.Get(ctx, "services", &services)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list services: %w", err)
|
||||
}
|
||||
return services, nil
|
||||
}
|
||||
|
||||
// Get retrieves a service by UUID
|
||||
func (s *ServiceService) Get(ctx context.Context, uuid string) (*models.Service, error) {
|
||||
var service models.Service
|
||||
err := s.client.Get(ctx, fmt.Sprintf("services/%s", uuid), &service)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get service %s: %w", uuid, err)
|
||||
}
|
||||
return &service, nil
|
||||
}
|
||||
|
||||
// Create creates a new service
|
||||
func (s *ServiceService) Create(ctx context.Context, req *models.ServiceCreateRequest) (*models.Service, error) {
|
||||
var service models.Service
|
||||
err := s.client.Post(ctx, "services", req, &service)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create service: %w", err)
|
||||
}
|
||||
return &service, nil
|
||||
}
|
||||
|
||||
// Update updates a service
|
||||
func (s *ServiceService) Update(ctx context.Context, uuid string, req *models.ServiceUpdateRequest) (*models.Service, error) {
|
||||
var service models.Service
|
||||
err := s.client.Patch(ctx, fmt.Sprintf("services/%s", uuid), req, &service)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to update service %s: %w", uuid, err)
|
||||
}
|
||||
return &service, nil
|
||||
}
|
||||
|
||||
// Delete deletes a service
|
||||
func (s *ServiceService) Delete(ctx context.Context, uuid string, deleteConfigurations, deleteVolumes, dockerCleanup, deleteConnectedNetworks bool) error {
|
||||
url := fmt.Sprintf("services/%s?delete_configurations=%t&delete_volumes=%t&docker_cleanup=%t&delete_connected_networks=%t",
|
||||
uuid, deleteConfigurations, deleteVolumes, dockerCleanup, deleteConnectedNetworks)
|
||||
|
||||
err := s.client.Delete(ctx, url)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete service %s: %w", uuid, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Start starts a service
|
||||
func (s *ServiceService) Start(ctx context.Context, uuid string) (*models.ServiceLifecycleResponse, error) {
|
||||
var resp models.ServiceLifecycleResponse
|
||||
err := s.client.Post(ctx, fmt.Sprintf("services/%s/start", uuid), nil, &resp)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to start service %s: %w", uuid, err)
|
||||
}
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
// Stop stops a service
|
||||
func (s *ServiceService) Stop(ctx context.Context, uuid string) (*models.ServiceLifecycleResponse, error) {
|
||||
var resp models.ServiceLifecycleResponse
|
||||
err := s.client.Post(ctx, fmt.Sprintf("services/%s/stop", uuid), nil, &resp)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to stop service %s: %w", uuid, err)
|
||||
}
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
// Restart restarts a service
|
||||
func (s *ServiceService) Restart(ctx context.Context, uuid string) (*models.ServiceLifecycleResponse, error) {
|
||||
var resp models.ServiceLifecycleResponse
|
||||
err := s.client.Post(ctx, fmt.Sprintf("services/%s/restart", uuid), nil, &resp)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to restart service %s: %w", uuid, err)
|
||||
}
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
// ListEnvs retrieves all environment variables for a service
|
||||
func (s *ServiceService) ListEnvs(ctx context.Context, uuid string) ([]models.EnvironmentVariable, error) {
|
||||
var envs []models.EnvironmentVariable
|
||||
err := s.client.Get(ctx, fmt.Sprintf("services/%s/envs", uuid), &envs)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list environment variables for service %s: %w", uuid, err)
|
||||
}
|
||||
return envs, nil
|
||||
}
|
||||
|
||||
// GetEnv retrieves a single environment variable by UUID or key
|
||||
func (s *ServiceService) GetEnv(ctx context.Context, serviceUUID, envIdentifier string) (*models.EnvironmentVariable, error) {
|
||||
envs, err := s.ListEnvs(ctx, serviceUUID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Try to find by UUID first, then by key
|
||||
for _, env := range envs {
|
||||
if env.UUID == envIdentifier || env.Key == envIdentifier {
|
||||
return &env, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("environment variable '%s' not found in service %s", envIdentifier, serviceUUID)
|
||||
}
|
||||
|
||||
// CreateEnv creates a new environment variable for a service
|
||||
func (s *ServiceService) CreateEnv(ctx context.Context, uuid string, req *models.EnvironmentVariableCreateRequest) (*models.EnvironmentVariable, error) {
|
||||
var env models.EnvironmentVariable
|
||||
err := s.client.Post(ctx, fmt.Sprintf("services/%s/envs", uuid), req, &env)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create environment variable for service %s: %w", uuid, err)
|
||||
}
|
||||
return &env, nil
|
||||
}
|
||||
|
||||
// UpdateEnv updates an environment variable for a service
|
||||
func (s *ServiceService) UpdateEnv(ctx context.Context, serviceUUID string, req *models.EnvironmentVariableUpdateRequest) (*models.EnvironmentVariable, error) {
|
||||
var env models.EnvironmentVariable
|
||||
err := s.client.Patch(ctx, fmt.Sprintf("services/%s/envs", serviceUUID), req, &env)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to update environment variable for service %s: %w", serviceUUID, err)
|
||||
}
|
||||
return &env, nil
|
||||
}
|
||||
|
||||
// DeleteEnv deletes an environment variable from a service
|
||||
func (s *ServiceService) DeleteEnv(ctx context.Context, serviceUUID, envUUID string) error {
|
||||
err := s.client.Delete(ctx, fmt.Sprintf("services/%s/envs/%s", serviceUUID, envUUID))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete environment variable %s from service %s: %w", envUUID, serviceUUID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// BulkUpdateEnvs updates multiple environment variables in a single request
|
||||
func (s *ServiceService) BulkUpdateEnvs(ctx context.Context, serviceUUID string, req *BulkUpdateEnvsRequest) (*BulkUpdateEnvsResponse, error) {
|
||||
var response BulkUpdateEnvsResponse
|
||||
err := s.client.Patch(ctx, fmt.Sprintf("services/%s/envs/bulk", serviceUUID), req, &response)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to bulk update environment variables for service %s: %w", serviceUUID, err)
|
||||
}
|
||||
return &response, nil
|
||||
}
|
||||
@@ -0,0 +1,355 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"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 TestServiceService_List(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "/api/v1/services", r.URL.Path)
|
||||
assert.Equal(t, "GET", r.Method)
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`[
|
||||
{
|
||||
"id": 1,
|
||||
"uuid": "service-uuid-1",
|
||||
"name": "PostgreSQL",
|
||||
"status": "running",
|
||||
"created_at": "2024-01-01T00:00:00Z",
|
||||
"updated_at": "2024-01-01T00:00:00Z"
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"uuid": "service-uuid-2",
|
||||
"name": "Redis",
|
||||
"status": "stopped",
|
||||
"created_at": "2024-01-01T00:00:00Z",
|
||||
"updated_at": "2024-01-01T00:00:00Z"
|
||||
}
|
||||
]`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := api.NewClient(server.URL, "test-token")
|
||||
svc := NewServiceService(client)
|
||||
|
||||
services, err := svc.List(context.Background())
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, services, 2)
|
||||
assert.Equal(t, "service-uuid-1", services[0].UUID)
|
||||
assert.Equal(t, "PostgreSQL", services[0].Name)
|
||||
assert.Equal(t, "running", services[0].Status)
|
||||
assert.Equal(t, "service-uuid-2", services[1].UUID)
|
||||
assert.Equal(t, "Redis", services[1].Name)
|
||||
assert.Equal(t, "stopped", services[1].Status)
|
||||
}
|
||||
|
||||
func TestServiceService_List_Empty(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`[]`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := api.NewClient(server.URL, "test-token")
|
||||
svc := NewServiceService(client)
|
||||
|
||||
services, err := svc.List(context.Background())
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, services, 0)
|
||||
}
|
||||
|
||||
func TestServiceService_List_Error(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
w.Write([]byte(`{"error": "internal server error"}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := api.NewClient(server.URL, "test-token")
|
||||
svc := NewServiceService(client)
|
||||
|
||||
_, err := svc.List(context.Background())
|
||||
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestServiceService_Get(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "/api/v1/services/service-uuid-123", r.URL.Path)
|
||||
assert.Equal(t, "GET", r.Method)
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`{
|
||||
"id": 1,
|
||||
"uuid": "service-uuid-123",
|
||||
"name": "PostgreSQL 16",
|
||||
"description": "Production database",
|
||||
"status": "running",
|
||||
"created_at": "2024-01-01T00:00:00Z",
|
||||
"updated_at": "2024-01-01T00:00:00Z",
|
||||
"databases": [
|
||||
{
|
||||
"id": 10,
|
||||
"uuid": "db-uuid-1",
|
||||
"name": "main_db",
|
||||
"type": "postgresql"
|
||||
}
|
||||
]
|
||||
}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := api.NewClient(server.URL, "test-token")
|
||||
svc := NewServiceService(client)
|
||||
|
||||
service, err := svc.Get(context.Background(), "service-uuid-123")
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "service-uuid-123", service.UUID)
|
||||
assert.Equal(t, "PostgreSQL 16", service.Name)
|
||||
assert.Equal(t, "running", service.Status)
|
||||
assert.NotNil(t, service.Description)
|
||||
assert.Equal(t, "Production database", *service.Description)
|
||||
assert.Len(t, service.Databases, 1)
|
||||
assert.Equal(t, "db-uuid-1", service.Databases[0].UUID)
|
||||
}
|
||||
|
||||
func TestServiceService_Get_Error(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
w.Write([]byte(`{"error": "service not found"}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := api.NewClient(server.URL, "test-token")
|
||||
svc := NewServiceService(client)
|
||||
|
||||
_, err := svc.Get(context.Background(), "nonexistent")
|
||||
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestServiceService_Start(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "/api/v1/services/service-uuid-123/start", r.URL.Path)
|
||||
assert.Equal(t, "POST", r.Method)
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`{"message": "Service starting request queued."}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := api.NewClient(server.URL, "test-token")
|
||||
svc := NewServiceService(client)
|
||||
|
||||
resp, err := svc.Start(context.Background(), "service-uuid-123")
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "Service starting request queued.", resp.Message)
|
||||
}
|
||||
|
||||
func TestServiceService_Start_Error(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
w.Write([]byte(`{"error": "service already running"}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := api.NewClient(server.URL, "test-token")
|
||||
svc := NewServiceService(client)
|
||||
|
||||
_, err := svc.Start(context.Background(), "service-uuid-123")
|
||||
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestServiceService_Stop(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "/api/v1/services/service-uuid-123/stop", r.URL.Path)
|
||||
assert.Equal(t, "POST", r.Method)
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`{"message": "Service stopping request queued."}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := api.NewClient(server.URL, "test-token")
|
||||
svc := NewServiceService(client)
|
||||
|
||||
resp, err := svc.Stop(context.Background(), "service-uuid-123")
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "Service stopping request queued.", resp.Message)
|
||||
}
|
||||
|
||||
func TestServiceService_Stop_Error(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
w.Write([]byte(`{"error": "service already stopped"}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := api.NewClient(server.URL, "test-token")
|
||||
svc := NewServiceService(client)
|
||||
|
||||
_, err := svc.Stop(context.Background(), "service-uuid-123")
|
||||
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestServiceService_Restart(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "/api/v1/services/service-uuid-123/restart", r.URL.Path)
|
||||
assert.Equal(t, "POST", r.Method)
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`{"message": "Service restarting request queued."}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := api.NewClient(server.URL, "test-token")
|
||||
svc := NewServiceService(client)
|
||||
|
||||
resp, err := svc.Restart(context.Background(), "service-uuid-123")
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "Service restarting request queued.", resp.Message)
|
||||
}
|
||||
|
||||
func TestServiceService_Restart_Error(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
w.Write([]byte(`{"error": "service not found"}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := api.NewClient(server.URL, "test-token")
|
||||
svc := NewServiceService(client)
|
||||
|
||||
_, err := svc.Restart(context.Background(), "service-uuid-123")
|
||||
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestServiceService_ListEnvs(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "/api/v1/services/service-uuid-123/envs", r.URL.Path)
|
||||
assert.Equal(t, "GET", r.Method)
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`[
|
||||
{
|
||||
"uuid": "env-1",
|
||||
"key": "DATABASE_URL",
|
||||
"value": "postgres://localhost",
|
||||
"is_build_time": false,
|
||||
"is_preview": false
|
||||
},
|
||||
{
|
||||
"uuid": "env-2",
|
||||
"key": "API_KEY",
|
||||
"value": "secret",
|
||||
"is_build_time": true,
|
||||
"is_preview": false
|
||||
}
|
||||
]`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := api.NewClient(server.URL, "test-token")
|
||||
svc := NewServiceService(client)
|
||||
|
||||
envs, err := svc.ListEnvs(context.Background(), "service-uuid-123")
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, envs, 2)
|
||||
assert.Equal(t, "DATABASE_URL", envs[0].Key)
|
||||
assert.Equal(t, "API_KEY", envs[1].Key)
|
||||
}
|
||||
|
||||
func TestServiceService_CreateEnv(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "/api/v1/services/service-uuid-123/envs", r.URL.Path)
|
||||
assert.Equal(t, "POST", r.Method)
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`{
|
||||
"uuid": "env-new",
|
||||
"key": "NEW_VAR",
|
||||
"value": "new_value",
|
||||
"is_build_time": false,
|
||||
"is_preview": false
|
||||
}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := api.NewClient(server.URL, "test-token")
|
||||
svc := NewServiceService(client)
|
||||
|
||||
env, err := svc.CreateEnv(context.Background(), "service-uuid-123", &models.EnvironmentVariableCreateRequest{
|
||||
Key: "NEW_VAR",
|
||||
Value: "new_value",
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "NEW_VAR", env.Key)
|
||||
assert.Equal(t, "new_value", env.Value)
|
||||
}
|
||||
|
||||
func TestServiceService_UpdateEnv(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "/api/v1/services/service-uuid-123/envs", r.URL.Path)
|
||||
assert.Equal(t, "PATCH", r.Method)
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`{
|
||||
"uuid": "env-123",
|
||||
"key": "UPDATED_VAR",
|
||||
"value": "updated_value",
|
||||
"is_build_time": true,
|
||||
"is_preview": false
|
||||
}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := api.NewClient(server.URL, "test-token")
|
||||
svc := NewServiceService(client)
|
||||
|
||||
newKey := "UPDATED_VAR"
|
||||
env, err := svc.UpdateEnv(context.Background(), "service-uuid-123", &models.EnvironmentVariableUpdateRequest{
|
||||
UUID: "env-123",
|
||||
Key: &newKey,
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "UPDATED_VAR", env.Key)
|
||||
}
|
||||
|
||||
func TestServiceService_DeleteEnv(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "/api/v1/services/service-uuid-123/envs/env-456", r.URL.Path)
|
||||
assert.Equal(t, "DELETE", r.Method)
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := api.NewClient(server.URL, "test-token")
|
||||
svc := NewServiceService(client)
|
||||
|
||||
err := svc.DeleteEnv(context.Background(), "service-uuid-123", "env-456")
|
||||
|
||||
require.NoError(t, err)
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/coollabsio/coolify-cli/internal/api"
|
||||
"github.com/coollabsio/coolify-cli/internal/models"
|
||||
)
|
||||
|
||||
// TeamService handles team-related operations
|
||||
type TeamService struct {
|
||||
client *api.Client
|
||||
}
|
||||
|
||||
// NewTeamService creates a new team service
|
||||
func NewTeamService(client *api.Client) *TeamService {
|
||||
return &TeamService{client: client}
|
||||
}
|
||||
|
||||
// List retrieves all teams
|
||||
func (s *TeamService) List(ctx context.Context) ([]models.Team, error) {
|
||||
var teams []models.Team
|
||||
err := s.client.Get(ctx, "teams", &teams)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list teams: %w", err)
|
||||
}
|
||||
return teams, nil
|
||||
}
|
||||
|
||||
// Get retrieves a team by ID
|
||||
func (s *TeamService) Get(ctx context.Context, id string) (*models.Team, error) {
|
||||
var team models.Team
|
||||
err := s.client.Get(ctx, fmt.Sprintf("teams/%s", id), &team)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get team %s: %w", id, err)
|
||||
}
|
||||
return &team, nil
|
||||
}
|
||||
|
||||
// Current retrieves the currently authenticated team
|
||||
func (s *TeamService) Current(ctx context.Context) (*models.Team, error) {
|
||||
var team models.Team
|
||||
err := s.client.Get(ctx, "teams/current", &team)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get current team: %w", err)
|
||||
}
|
||||
return &team, nil
|
||||
}
|
||||
|
||||
// ListMembers retrieves members of a specific team
|
||||
func (s *TeamService) ListMembers(ctx context.Context, teamID string) ([]models.TeamMember, error) {
|
||||
var members []models.TeamMember
|
||||
err := s.client.Get(ctx, fmt.Sprintf("teams/%s/members", teamID), &members)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list members for team %s: %w", teamID, err)
|
||||
}
|
||||
return members, nil
|
||||
}
|
||||
|
||||
// CurrentMembers retrieves members of the currently authenticated team
|
||||
func (s *TeamService) CurrentMembers(ctx context.Context) ([]models.TeamMember, error) {
|
||||
var members []models.TeamMember
|
||||
err := s.client.Get(ctx, "teams/current/members", &members)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list current team members: %w", err)
|
||||
}
|
||||
return members, nil
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/coollabsio/coolify-cli/internal/api"
|
||||
)
|
||||
|
||||
func TestTeamService_List(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
response string
|
||||
statusCode int
|
||||
wantErr bool
|
||||
wantCount int
|
||||
}{
|
||||
{
|
||||
name: "successful list",
|
||||
response: `[
|
||||
{"uuid": "team-1", "name": "Team 1", "description": "First team"},
|
||||
{"uuid": "team-2", "name": "Team 2", "description": "Second team"}
|
||||
]`,
|
||||
statusCode: http.StatusOK,
|
||||
wantErr: false,
|
||||
wantCount: 2,
|
||||
},
|
||||
{
|
||||
name: "empty list",
|
||||
response: `[]`,
|
||||
statusCode: http.StatusOK,
|
||||
wantErr: false,
|
||||
wantCount: 0,
|
||||
},
|
||||
{
|
||||
name: "server error",
|
||||
response: `{"error": "internal server error"}`,
|
||||
statusCode: http.StatusInternalServerError,
|
||||
wantErr: true,
|
||||
wantCount: 0,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/api/v1/teams" {
|
||||
t.Errorf("Expected path /api/v1/teams, got %s", r.URL.Path)
|
||||
}
|
||||
w.WriteHeader(tt.statusCode)
|
||||
w.Write([]byte(tt.response))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := api.NewClient(server.URL, "test-token")
|
||||
svc := NewTeamService(client)
|
||||
|
||||
teams, err := svc.List(context.Background())
|
||||
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("TeamService.List() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
|
||||
if !tt.wantErr && len(teams) != tt.wantCount {
|
||||
t.Errorf("TeamService.List() got %d teams, want %d", len(teams), tt.wantCount)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTeamService_Get(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
teamID string
|
||||
response string
|
||||
statusCode int
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "successful get",
|
||||
teamID: "team-123",
|
||||
response: `{"uuid": "team-123", "name": "Test Team", "description": "A test team"}`,
|
||||
statusCode: http.StatusOK,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "not found",
|
||||
teamID: "nonexistent",
|
||||
response: `{"error": "team not found"}`,
|
||||
statusCode: http.StatusNotFound,
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
expectedPath := "/api/v1/teams/" + tt.teamID
|
||||
if r.URL.Path != expectedPath {
|
||||
t.Errorf("Expected path %s, got %s", expectedPath, r.URL.Path)
|
||||
}
|
||||
w.WriteHeader(tt.statusCode)
|
||||
w.Write([]byte(tt.response))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := api.NewClient(server.URL, "test-token")
|
||||
svc := NewTeamService(client)
|
||||
|
||||
team, err := svc.Get(context.Background(), tt.teamID)
|
||||
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("TeamService.Get() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
|
||||
if !tt.wantErr && team.UUID != tt.teamID {
|
||||
t.Errorf("TeamService.Get() got UUID %s, want %s", team.UUID, tt.teamID)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTeamService_Current(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
response string
|
||||
statusCode int
|
||||
wantErr bool
|
||||
wantName string
|
||||
}{
|
||||
{
|
||||
name: "successful get current",
|
||||
response: `{"uuid": "current-team", "name": "Current Team", "description": "The current team"}`,
|
||||
statusCode: http.StatusOK,
|
||||
wantErr: false,
|
||||
wantName: "Current Team",
|
||||
},
|
||||
{
|
||||
name: "unauthorized",
|
||||
response: `{"error": "unauthorized"}`,
|
||||
statusCode: http.StatusUnauthorized,
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/api/v1/teams/current" {
|
||||
t.Errorf("Expected path /api/v1/teams/current, got %s", r.URL.Path)
|
||||
}
|
||||
w.WriteHeader(tt.statusCode)
|
||||
w.Write([]byte(tt.response))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := api.NewClient(server.URL, "test-token")
|
||||
svc := NewTeamService(client)
|
||||
|
||||
team, err := svc.Current(context.Background())
|
||||
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("TeamService.Current() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
|
||||
if !tt.wantErr && team.Name != tt.wantName {
|
||||
t.Errorf("TeamService.Current() got name %s, want %s", team.Name, tt.wantName)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTeamService_ListMembers(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
teamID string
|
||||
response string
|
||||
statusCode int
|
||||
wantErr bool
|
||||
wantCount int
|
||||
}{
|
||||
{
|
||||
name: "successful list members",
|
||||
teamID: "team-123",
|
||||
response: `[
|
||||
{"uuid": "user-1", "name": "Alice", "email": "alice@example.com", "role": "admin"},
|
||||
{"uuid": "user-2", "name": "Bob", "email": "bob@example.com", "role": "member"}
|
||||
]`,
|
||||
statusCode: http.StatusOK,
|
||||
wantErr: false,
|
||||
wantCount: 2,
|
||||
},
|
||||
{
|
||||
name: "empty members list",
|
||||
teamID: "team-456",
|
||||
response: `[]`,
|
||||
statusCode: http.StatusOK,
|
||||
wantErr: false,
|
||||
wantCount: 0,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
expectedPath := "/api/v1/teams/" + tt.teamID + "/members"
|
||||
if r.URL.Path != expectedPath {
|
||||
t.Errorf("Expected path %s, got %s", expectedPath, r.URL.Path)
|
||||
}
|
||||
w.WriteHeader(tt.statusCode)
|
||||
w.Write([]byte(tt.response))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := api.NewClient(server.URL, "test-token")
|
||||
svc := NewTeamService(client)
|
||||
|
||||
members, err := svc.ListMembers(context.Background(), tt.teamID)
|
||||
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("TeamService.ListMembers() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
|
||||
if !tt.wantErr && len(members) != tt.wantCount {
|
||||
t.Errorf("TeamService.ListMembers() got %d members, want %d", len(members), tt.wantCount)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTeamService_CurrentMembers(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
response string
|
||||
statusCode int
|
||||
wantErr bool
|
||||
wantCount int
|
||||
}{
|
||||
{
|
||||
name: "successful list current members",
|
||||
response: `[
|
||||
{"uuid": "user-1", "name": "Alice", "email": "alice@example.com"},
|
||||
{"uuid": "user-2", "name": "Bob", "email": "bob@example.com"}
|
||||
]`,
|
||||
statusCode: http.StatusOK,
|
||||
wantErr: false,
|
||||
wantCount: 2,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/api/v1/teams/current/members" {
|
||||
t.Errorf("Expected path /api/v1/teams/current/members, got %s", r.URL.Path)
|
||||
}
|
||||
w.WriteHeader(tt.statusCode)
|
||||
w.Write([]byte(tt.response))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := api.NewClient(server.URL, "test-token")
|
||||
svc := NewTeamService(client)
|
||||
|
||||
members, err := svc.CurrentMembers(context.Background())
|
||||
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("TeamService.CurrentMembers() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
|
||||
if !tt.wantErr && len(members) != tt.wantCount {
|
||||
t.Errorf("TeamService.CurrentMembers() got %d members, want %d", len(members), tt.wantCount)
|
||||
}
|
||||
|
||||
// Verify JSON marshaling works
|
||||
if !tt.wantErr && len(members) > 0 {
|
||||
_, err := json.Marshal(members[0])
|
||||
if err != nil {
|
||||
t.Errorf("Failed to marshal team member: %v", err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Regular → Executable
+267
-38
@@ -1,11 +1,103 @@
|
||||
#!/bin/bash
|
||||
# This script installs the coolify-cli to /usr/local/bin/coolify from Github release
|
||||
# This script installs the coolify-cli from GitHub releases
|
||||
# Supports Linux and macOS on amd64/arm64 architectures
|
||||
# Windows is not supported by this installer
|
||||
|
||||
args=("$@")
|
||||
custom_version=${args[0]}
|
||||
if [ -z "$custom_version" ]; then
|
||||
custom_version="0.0.1"
|
||||
fi
|
||||
set -e # Exit on error
|
||||
|
||||
# Configuration
|
||||
REPO="coollabsio/coolify-cli"
|
||||
BINARY_NAME="coolify"
|
||||
GLOBAL_INSTALL_DIR="/usr/local/bin"
|
||||
USER_INSTALL_DIR="$HOME/.local/bin"
|
||||
|
||||
# Colors for output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# Cleanup trap
|
||||
TEMP_FILE=""
|
||||
cleanup() {
|
||||
if [ -n "$TEMP_FILE" ] && [ -f "$TEMP_FILE" ]; then
|
||||
rm -f "$TEMP_FILE"
|
||||
fi
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
# Error handler
|
||||
error_exit() {
|
||||
echo -e "${RED}Error: $1${NC}" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Show help
|
||||
show_help() {
|
||||
cat << EOF
|
||||
Coolify CLI Installer
|
||||
|
||||
Usage: $0 [OPTIONS] [VERSION]
|
||||
|
||||
OPTIONS:
|
||||
--user Install to ~/.local/bin (no sudo required)
|
||||
--help Show this help message
|
||||
--version Show installer version
|
||||
|
||||
ARGUMENTS:
|
||||
VERSION Specific version to install (e.g., v1.0.0)
|
||||
If not specified, installs the latest release
|
||||
|
||||
EXAMPLES:
|
||||
$0 Install latest version to /usr/local/bin
|
||||
$0 --user Install latest version to ~/.local/bin
|
||||
$0 v1.0.0 Install specific version to /usr/local/bin
|
||||
$0 --user v1.0.0 Install specific version to ~/.local/bin
|
||||
|
||||
EOF
|
||||
exit 0
|
||||
}
|
||||
|
||||
# Parse arguments
|
||||
USER_INSTALL=false
|
||||
CUSTOM_VERSION=""
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case $1 in
|
||||
--user)
|
||||
USER_INSTALL=true
|
||||
shift
|
||||
;;
|
||||
--help|-h)
|
||||
show_help
|
||||
;;
|
||||
--version)
|
||||
echo "Coolify CLI Installer v1.0.0"
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
CUSTOM_VERSION="$1"
|
||||
shift
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Check required tools
|
||||
check_requirements() {
|
||||
local missing_tools=()
|
||||
|
||||
if ! command -v curl &> /dev/null; then
|
||||
missing_tools+=("curl")
|
||||
fi
|
||||
|
||||
if ! command -v tar &> /dev/null; then
|
||||
missing_tools+=("tar")
|
||||
fi
|
||||
|
||||
if [ ${#missing_tools[@]} -gt 0 ]; then
|
||||
error_exit "Missing required tools: ${missing_tools[*]}\nPlease install them and try again."
|
||||
fi
|
||||
}
|
||||
|
||||
# Function to detect platform, architecture, etc.
|
||||
detect_platform() {
|
||||
@@ -13,54 +105,191 @@ detect_platform() {
|
||||
ARCH=$(uname -m)
|
||||
|
||||
case $OS in
|
||||
Linux) OS="linux" ;;
|
||||
Darwin) OS="darwin" ;;
|
||||
*)
|
||||
echo "Unsupported operating system: $OS"
|
||||
exit 1
|
||||
;;
|
||||
Linux) OS="linux" ;;
|
||||
Darwin) OS="darwin" ;;
|
||||
*)
|
||||
error_exit "Unsupported operating system: $OS\nSupported: Linux, macOS"
|
||||
;;
|
||||
esac
|
||||
|
||||
case $ARCH in
|
||||
x86_64) ARCH="amd64" ;;
|
||||
aarch64 | arm64) ARCH="arm64" ;;
|
||||
*)
|
||||
echo "Unsupported architecture: $ARCH"
|
||||
exit 1
|
||||
;;
|
||||
x86_64) ARCH="amd64" ;;
|
||||
aarch64 | arm64) ARCH="arm64" ;;
|
||||
*)
|
||||
error_exit "Unsupported architecture: $ARCH\nSupported: x86_64/amd64, aarch64/arm64"
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
# Function to download file from GitHub release
|
||||
# Fetch latest release version from GitHub
|
||||
get_latest_version() {
|
||||
echo "Fetching latest release version..." >&2
|
||||
local latest_version
|
||||
latest_version=$(curl -sSf "https://api.github.com/repos/${REPO}/releases/latest" | grep '"tag_name":' | sed -E 's/.*"([^"]+)".*/\1/')
|
||||
|
||||
if [ -z "$latest_version" ]; then
|
||||
error_exit "Failed to fetch latest release version from GitHub"
|
||||
fi
|
||||
|
||||
echo "$latest_version"
|
||||
}
|
||||
|
||||
# Validate version format (should start with v or be a semantic version)
|
||||
validate_version() {
|
||||
local version=$1
|
||||
# Check if version starts with 'v' or is a plain semantic version
|
||||
if [[ ! "$version" =~ ^v?[0-9]+\.[0-9]+\.[0-9]+(-.*)?$ ]]; then
|
||||
error_exit "Invalid version format: $version\nExpected format: v1.0.0 or 1.0.0"
|
||||
fi
|
||||
|
||||
# Ensure version starts with 'v' for GitHub releases
|
||||
if [[ ! "$version" =~ ^v ]]; then
|
||||
version="v${version}"
|
||||
fi
|
||||
|
||||
echo "$version"
|
||||
}
|
||||
|
||||
# Check if coolify is already installed
|
||||
check_existing_installation() {
|
||||
if command -v coolify &> /dev/null; then
|
||||
local current_version
|
||||
current_version=$(coolify version 2>/dev/null | head -n1 || echo "unknown")
|
||||
echo -e "${YELLOW}Coolify CLI is already installed: ${current_version}${NC}"
|
||||
echo -e "This will upgrade/reinstall to version ${GREEN}${1}${NC}"
|
||||
read -p "Continue? [y/N] " -n 1 -r
|
||||
echo
|
||||
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
|
||||
echo "Installation cancelled."
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# Download and verify file from GitHub release
|
||||
download_from_github() {
|
||||
local repo=$1
|
||||
local release=$2
|
||||
local name=$3
|
||||
local filename=${name}_${release}_${OS}_${ARCH}.tar.gz
|
||||
# https://github.com/coollabsio/coolify-cli/releases/download/0.0.1/coolify-cli_0.0.1_linux_amd64.tar.gz
|
||||
# Construct download URL
|
||||
local install_dir=$4
|
||||
|
||||
local filename="${name}_${release}_${OS}_${ARCH}.tar.gz"
|
||||
local download_url="https://github.com/${repo}/releases/download/${release}/${filename}"
|
||||
|
||||
# Use curl to download the file quietly
|
||||
echo "Downloading ${name} from ${download_url}"
|
||||
curl -sL -o "${filename}" "${download_url}"
|
||||
echo -e "${GREEN}Downloading ${name} ${release}${NC}"
|
||||
echo "Platform: ${OS}/${ARCH}"
|
||||
echo "URL: ${download_url}"
|
||||
|
||||
# Determine the binary directory
|
||||
local binary_dir=""
|
||||
if [ "$OS" == "linux" ] || [ "$OS" == "darwin" ]; then
|
||||
binary_dir="/usr/local/bin"
|
||||
# Create temp file
|
||||
TEMP_FILE=$(mktemp)
|
||||
|
||||
# Download with progress and error handling
|
||||
if ! curl -fSL --progress-bar -o "${TEMP_FILE}" "${download_url}"; then
|
||||
error_exit "Failed to download from ${download_url}\nPlease check if the version exists or try again later."
|
||||
fi
|
||||
echo "Installing ${name} to ${binary_dir}/coolify"
|
||||
sudo tar -xzvf "${filename}" -C "${binary_dir}" > /dev/null
|
||||
|
||||
# Make the binary executable
|
||||
sudo chmod +x "${binary_dir}/coolify"
|
||||
# Verify downloaded file is not empty
|
||||
if [ ! -s "$TEMP_FILE" ]; then
|
||||
error_exit "Downloaded file is empty"
|
||||
fi
|
||||
|
||||
# Cleanup
|
||||
rm "${filename}"
|
||||
# Check if file is actually a tar.gz (basic check)
|
||||
if ! file "$TEMP_FILE" | grep -q "gzip compressed"; then
|
||||
error_exit "Downloaded file is not a valid gzip archive"
|
||||
fi
|
||||
|
||||
echo "${name} installed successfully to ${binary_dir}/coolify"
|
||||
# Create install directory if it doesn't exist (for user install)
|
||||
if [ "$USER_INSTALL" = true ] && [ ! -d "$install_dir" ]; then
|
||||
echo "Creating directory: ${install_dir}"
|
||||
mkdir -p "$install_dir" || error_exit "Failed to create directory ${install_dir}"
|
||||
fi
|
||||
|
||||
# Extract binary
|
||||
echo "Installing ${name} to ${install_dir}/${BINARY_NAME}"
|
||||
|
||||
if [ "$USER_INSTALL" = true ]; then
|
||||
if ! tar -xzf "${TEMP_FILE}" -C "${install_dir}"; then
|
||||
error_exit "Failed to extract binary"
|
||||
fi
|
||||
chmod +x "${install_dir}/${BINARY_NAME}" || error_exit "Failed to make binary executable"
|
||||
else
|
||||
if ! sudo tar -xzf "${TEMP_FILE}" -C "${install_dir}"; then
|
||||
error_exit "Failed to extract binary (sudo required)"
|
||||
fi
|
||||
if ! sudo chmod +x "${install_dir}/${BINARY_NAME}"; then
|
||||
error_exit "Failed to make binary executable (sudo required)"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Verify installation
|
||||
if [ ! -f "${install_dir}/${BINARY_NAME}" ]; then
|
||||
error_exit "Binary was not installed to ${install_dir}/${BINARY_NAME}"
|
||||
fi
|
||||
|
||||
echo -e "${GREEN}✓ ${name} installed successfully to ${install_dir}/${BINARY_NAME}${NC}"
|
||||
|
||||
# Check if install directory is in PATH
|
||||
if [[ ":$PATH:" != *":${install_dir}:"* ]]; then
|
||||
echo -e "${YELLOW}Warning: ${install_dir} is not in your PATH${NC}"
|
||||
if [ "$USER_INSTALL" = true ]; then
|
||||
echo "Add it to your PATH by adding this line to your ~/.bashrc or ~/.zshrc:"
|
||||
echo " export PATH=\"\$HOME/.local/bin:\$PATH\""
|
||||
fi
|
||||
fi
|
||||
|
||||
# Show installed version
|
||||
local installed_version
|
||||
if installed_version=$("${install_dir}/${BINARY_NAME}" version 2>/dev/null | head -n1); then
|
||||
echo -e "Installed version: ${GREEN}${installed_version}${NC}"
|
||||
fi
|
||||
}
|
||||
|
||||
detect_platform
|
||||
download_from_github "coollabsio/coolify-cli" $custom_version "coolify-cli"
|
||||
# Main installation flow
|
||||
main() {
|
||||
echo "Coolify CLI Installer"
|
||||
echo "===================="
|
||||
echo
|
||||
|
||||
# Check requirements first
|
||||
check_requirements
|
||||
|
||||
# Detect platform
|
||||
detect_platform
|
||||
|
||||
# Determine version to install
|
||||
local version_to_install
|
||||
if [ -z "$CUSTOM_VERSION" ]; then
|
||||
version_to_install=$(get_latest_version)
|
||||
else
|
||||
version_to_install=$(validate_version "$CUSTOM_VERSION")
|
||||
fi
|
||||
|
||||
echo "Version to install: ${version_to_install}"
|
||||
echo
|
||||
|
||||
# Check existing installation
|
||||
check_existing_installation "$version_to_install"
|
||||
|
||||
# Determine install directory
|
||||
local install_dir
|
||||
if [ "$USER_INSTALL" = true ]; then
|
||||
install_dir="$USER_INSTALL_DIR"
|
||||
echo "Install mode: User (no sudo required)"
|
||||
else
|
||||
install_dir="$GLOBAL_INSTALL_DIR"
|
||||
echo "Install mode: Global (requires sudo)"
|
||||
fi
|
||||
|
||||
echo "Install directory: ${install_dir}"
|
||||
echo
|
||||
|
||||
# Download and install
|
||||
download_from_github "$REPO" "$version_to_install" "coolify-cli" "$install_dir"
|
||||
|
||||
echo
|
||||
echo -e "${GREEN}Installation complete!${NC}"
|
||||
echo "Run 'coolify --help' to get started"
|
||||
}
|
||||
|
||||
# Run main installation
|
||||
main
|
||||
|
||||
Vendored
+24
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"uuid": "proj-123-uuid",
|
||||
"name": "My Project",
|
||||
"description": "Test project",
|
||||
"environments": [
|
||||
{
|
||||
"id": 1,
|
||||
"uuid": "env-123-uuid",
|
||||
"name": "production",
|
||||
"description": "Production environment",
|
||||
"created_at": "2025-10-14T12:00:00Z",
|
||||
"updated_at": "2025-10-14T12:00:00Z",
|
||||
"applications": [
|
||||
{
|
||||
"id": 1,
|
||||
"uuid": "app-123-uuid",
|
||||
"name": "web-app",
|
||||
"description": "Web application",
|
||||
"status": "running"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
Vendored
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"id": 1,
|
||||
"uuid": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"name": "production-server",
|
||||
"ip": "192.168.1.100",
|
||||
"user": "root",
|
||||
"port": 22,
|
||||
"settings": {
|
||||
"is_reachable": true,
|
||||
"is_usable": true
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user