feat(php): add Coolify deployment integration

Commands:
- core php deploy - deploy to production
- core php deploy --staging - deploy to staging
- core php deploy:status - show deployment status
- core php deploy:rollback - rollback to previous
- core php deploy:list - list recent deployments

Features:
- Coolify API client
- Config from .env (COOLIFY_URL, TOKEN, APP_ID)
- Wait for deployment completion with --wait

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Snider 2026-01-28 19:39:04 +00:00
parent 312fce343d
commit 605ee023ca
4 changed files with 1407 additions and 0 deletions

View file

@ -67,6 +67,7 @@ func AddPHPCommands(parent *clir.Cli) {
addPHPFmtCommand(phpCmd)
addPHPAnalyseCommand(phpCmd)
addPHPPackagesCommands(phpCmd)
addPHPDeployCommands(phpCmd)
}
func addPHPDevCommand(parent *clir.Command) {
@ -1151,3 +1152,392 @@ func addPHPPackagesListCommand(parent *clir.Command) {
return nil
})
}
// Deploy command styles
var (
phpDeployStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#10b981")) // emerald-500
phpDeployPendingStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#f59e0b")) // amber-500
phpDeployFailedStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#ef4444")) // red-500
)
func addPHPDeployCommands(parent *clir.Command) {
// Main deploy command
addPHPDeployCommand(parent)
// Deploy status subcommand (using colon notation: deploy:status)
addPHPDeployStatusCommand(parent)
// Deploy rollback subcommand
addPHPDeployRollbackCommand(parent)
// Deploy list subcommand
addPHPDeployListCommand(parent)
}
func addPHPDeployCommand(parent *clir.Command) {
var (
staging bool
force bool
wait bool
)
deployCmd := parent.NewSubCommand("deploy", "Deploy to Coolify")
deployCmd.LongDescription("Deploy the PHP application to Coolify.\n\n" +
"Requires configuration in .env:\n" +
" COOLIFY_URL=https://coolify.example.com\n" +
" COOLIFY_TOKEN=your-api-token\n" +
" COOLIFY_APP_ID=production-app-id\n" +
" COOLIFY_STAGING_APP_ID=staging-app-id (optional)\n\n" +
"Examples:\n" +
" core php deploy # Deploy to production\n" +
" core php deploy --staging # Deploy to staging\n" +
" core php deploy --force # Force deployment\n" +
" core php deploy --wait # Wait for deployment to complete")
deployCmd.BoolFlag("staging", "Deploy to staging environment", &staging)
deployCmd.BoolFlag("force", "Force deployment even if no changes detected", &force)
deployCmd.BoolFlag("wait", "Wait for deployment to complete", &wait)
deployCmd.Action(func() error {
cwd, err := os.Getwd()
if err != nil {
return fmt.Errorf("failed to get working directory: %w", err)
}
env := php.EnvProduction
if staging {
env = php.EnvStaging
}
fmt.Printf("%s Deploying to %s...\n\n", dimStyle.Render("Deploy:"), env)
ctx := context.Background()
opts := php.DeployOptions{
Dir: cwd,
Environment: env,
Force: force,
Wait: wait,
}
status, err := php.Deploy(ctx, opts)
if err != nil {
return fmt.Errorf("deployment failed: %w", err)
}
printDeploymentStatus(status)
if wait {
if php.IsDeploymentSuccessful(status.Status) {
fmt.Printf("\n%s Deployment completed successfully\n", successStyle.Render("Done:"))
} else {
fmt.Printf("\n%s Deployment ended with status: %s\n", errorStyle.Render("Warning:"), status.Status)
}
} else {
fmt.Printf("\n%s Deployment triggered. Use 'core php deploy:status' to check progress.\n", successStyle.Render("Done:"))
}
return nil
})
}
func addPHPDeployStatusCommand(parent *clir.Command) {
var (
staging bool
deploymentID string
)
statusCmd := parent.NewSubCommand("deploy:status", "Show deployment status")
statusCmd.LongDescription("Show the status of a deployment.\n\n" +
"Examples:\n" +
" core php deploy:status # Latest production deployment\n" +
" core php deploy:status --staging # Latest staging deployment\n" +
" core php deploy:status --id abc123 # Specific deployment")
statusCmd.BoolFlag("staging", "Check staging environment", &staging)
statusCmd.StringFlag("id", "Specific deployment ID", &deploymentID)
statusCmd.Action(func() error {
cwd, err := os.Getwd()
if err != nil {
return fmt.Errorf("failed to get working directory: %w", err)
}
env := php.EnvProduction
if staging {
env = php.EnvStaging
}
fmt.Printf("%s Checking %s deployment status...\n\n", dimStyle.Render("Deploy:"), env)
ctx := context.Background()
opts := php.StatusOptions{
Dir: cwd,
Environment: env,
DeploymentID: deploymentID,
}
status, err := php.DeployStatus(ctx, opts)
if err != nil {
return fmt.Errorf("failed to get status: %w", err)
}
printDeploymentStatus(status)
return nil
})
}
func addPHPDeployRollbackCommand(parent *clir.Command) {
var (
staging bool
deploymentID string
wait bool
)
rollbackCmd := parent.NewSubCommand("deploy:rollback", "Rollback to previous deployment")
rollbackCmd.LongDescription("Rollback to a previous deployment.\n\n" +
"If no deployment ID is specified, rolls back to the most recent\n" +
"successful deployment.\n\n" +
"Examples:\n" +
" core php deploy:rollback # Rollback to previous\n" +
" core php deploy:rollback --staging # Rollback staging\n" +
" core php deploy:rollback --id abc123 # Rollback to specific deployment")
rollbackCmd.BoolFlag("staging", "Rollback staging environment", &staging)
rollbackCmd.StringFlag("id", "Specific deployment ID to rollback to", &deploymentID)
rollbackCmd.BoolFlag("wait", "Wait for rollback to complete", &wait)
rollbackCmd.Action(func() error {
cwd, err := os.Getwd()
if err != nil {
return fmt.Errorf("failed to get working directory: %w", err)
}
env := php.EnvProduction
if staging {
env = php.EnvStaging
}
fmt.Printf("%s Rolling back %s...\n\n", dimStyle.Render("Deploy:"), env)
ctx := context.Background()
opts := php.RollbackOptions{
Dir: cwd,
Environment: env,
DeploymentID: deploymentID,
Wait: wait,
}
status, err := php.Rollback(ctx, opts)
if err != nil {
return fmt.Errorf("rollback failed: %w", err)
}
printDeploymentStatus(status)
if wait {
if php.IsDeploymentSuccessful(status.Status) {
fmt.Printf("\n%s Rollback completed successfully\n", successStyle.Render("Done:"))
} else {
fmt.Printf("\n%s Rollback ended with status: %s\n", errorStyle.Render("Warning:"), status.Status)
}
} else {
fmt.Printf("\n%s Rollback triggered. Use 'core php deploy:status' to check progress.\n", successStyle.Render("Done:"))
}
return nil
})
}
func addPHPDeployListCommand(parent *clir.Command) {
var (
staging bool
limit int
)
listCmd := parent.NewSubCommand("deploy:list", "List recent deployments")
listCmd.LongDescription("List recent deployments.\n\n" +
"Examples:\n" +
" core php deploy:list # List production deployments\n" +
" core php deploy:list --staging # List staging deployments\n" +
" core php deploy:list --limit 20 # List more deployments")
listCmd.BoolFlag("staging", "List staging deployments", &staging)
listCmd.IntFlag("limit", "Number of deployments to list (default: 10)", &limit)
listCmd.Action(func() error {
cwd, err := os.Getwd()
if err != nil {
return fmt.Errorf("failed to get working directory: %w", err)
}
env := php.EnvProduction
if staging {
env = php.EnvStaging
}
if limit == 0 {
limit = 10
}
fmt.Printf("%s Recent %s deployments:\n\n", dimStyle.Render("Deploy:"), env)
ctx := context.Background()
deployments, err := php.ListDeployments(ctx, cwd, env, limit)
if err != nil {
return fmt.Errorf("failed to list deployments: %w", err)
}
if len(deployments) == 0 {
fmt.Printf("%s No deployments found\n", dimStyle.Render("Info:"))
return nil
}
for i, d := range deployments {
printDeploymentSummary(i+1, &d)
}
return nil
})
}
func printDeploymentStatus(status *php.DeploymentStatus) {
// Status with color
statusStyle := phpDeployStyle
switch status.Status {
case "queued", "building", "deploying", "pending", "rolling_back":
statusStyle = phpDeployPendingStyle
case "failed", "error", "cancelled":
statusStyle = phpDeployFailedStyle
}
fmt.Printf("%s %s\n", dimStyle.Render("Status:"), statusStyle.Render(status.Status))
if status.ID != "" {
fmt.Printf("%s %s\n", dimStyle.Render("ID:"), status.ID)
}
if status.URL != "" {
fmt.Printf("%s %s\n", dimStyle.Render("URL:"), linkStyle.Render(status.URL))
}
if status.Branch != "" {
fmt.Printf("%s %s\n", dimStyle.Render("Branch:"), status.Branch)
}
if status.Commit != "" {
commit := status.Commit
if len(commit) > 7 {
commit = commit[:7]
}
fmt.Printf("%s %s\n", dimStyle.Render("Commit:"), commit)
if status.CommitMessage != "" {
// Truncate long messages
msg := status.CommitMessage
if len(msg) > 60 {
msg = msg[:57] + "..."
}
fmt.Printf("%s %s\n", dimStyle.Render("Message:"), msg)
}
}
if !status.StartedAt.IsZero() {
fmt.Printf("%s %s\n", dimStyle.Render("Started:"), status.StartedAt.Format(time.RFC3339))
}
if !status.CompletedAt.IsZero() {
fmt.Printf("%s %s\n", dimStyle.Render("Completed:"), status.CompletedAt.Format(time.RFC3339))
if !status.StartedAt.IsZero() {
duration := status.CompletedAt.Sub(status.StartedAt)
fmt.Printf("%s %s\n", dimStyle.Render("Duration:"), duration.Round(time.Second))
}
}
}
func printDeploymentSummary(index int, status *php.DeploymentStatus) {
// Status with color
statusStyle := phpDeployStyle
switch status.Status {
case "queued", "building", "deploying", "pending", "rolling_back":
statusStyle = phpDeployPendingStyle
case "failed", "error", "cancelled":
statusStyle = phpDeployFailedStyle
}
// Format: #1 [finished] abc1234 - commit message (2 hours ago)
id := status.ID
if len(id) > 8 {
id = id[:8]
}
commit := status.Commit
if len(commit) > 7 {
commit = commit[:7]
}
msg := status.CommitMessage
if len(msg) > 40 {
msg = msg[:37] + "..."
}
age := ""
if !status.StartedAt.IsZero() {
age = formatTimeAgo(status.StartedAt)
}
fmt.Printf(" %s %s %s",
dimStyle.Render(fmt.Sprintf("#%d", index)),
statusStyle.Render(fmt.Sprintf("[%s]", status.Status)),
id,
)
if commit != "" {
fmt.Printf(" %s", commit)
}
if msg != "" {
fmt.Printf(" - %s", msg)
}
if age != "" {
fmt.Printf(" %s", dimStyle.Render(fmt.Sprintf("(%s)", age)))
}
fmt.Println()
}
func formatTimeAgo(t time.Time) string {
duration := time.Since(t)
switch {
case duration < time.Minute:
return "just now"
case duration < time.Hour:
mins := int(duration.Minutes())
if mins == 1 {
return "1 minute ago"
}
return fmt.Sprintf("%d minutes ago", mins)
case duration < 24*time.Hour:
hours := int(duration.Hours())
if hours == 1 {
return "1 hour ago"
}
return fmt.Sprintf("%d hours ago", hours)
default:
days := int(duration.Hours() / 24)
if days == 1 {
return "1 day ago"
}
return fmt.Sprintf("%d days ago", days)
}
}

354
pkg/php/coolify.go Normal file
View file

@ -0,0 +1,354 @@
package php
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"time"
)
// CoolifyClient is an HTTP client for the Coolify API.
type CoolifyClient struct {
BaseURL string
Token string
HTTPClient *http.Client
}
// CoolifyConfig holds configuration loaded from environment.
type CoolifyConfig struct {
URL string
Token string
AppID string
StagingAppID string
}
// CoolifyDeployment represents a deployment from the Coolify API.
type CoolifyDeployment struct {
ID string `json:"id"`
Status string `json:"status"`
CommitSHA string `json:"commit_sha,omitempty"`
CommitMsg string `json:"commit_message,omitempty"`
Branch string `json:"branch,omitempty"`
CreatedAt time.Time `json:"created_at"`
FinishedAt time.Time `json:"finished_at,omitempty"`
Log string `json:"log,omitempty"`
DeployedURL string `json:"deployed_url,omitempty"`
}
// CoolifyApp represents an application from the Coolify API.
type CoolifyApp struct {
ID string `json:"id"`
Name string `json:"name"`
FQDN string `json:"fqdn,omitempty"`
Status string `json:"status,omitempty"`
Repository string `json:"repository,omitempty"`
Branch string `json:"branch,omitempty"`
Environment string `json:"environment,omitempty"`
}
// NewCoolifyClient creates a new Coolify API client.
func NewCoolifyClient(baseURL, token string) *CoolifyClient {
// Ensure baseURL doesn't have trailing slash
baseURL = strings.TrimSuffix(baseURL, "/")
return &CoolifyClient{
BaseURL: baseURL,
Token: token,
HTTPClient: &http.Client{
Timeout: 30 * time.Second,
},
}
}
// LoadCoolifyConfig loads Coolify configuration from .env file in the given directory.
func LoadCoolifyConfig(dir string) (*CoolifyConfig, error) {
envPath := filepath.Join(dir, ".env")
return LoadCoolifyConfigFromFile(envPath)
}
// LoadCoolifyConfigFromFile loads Coolify configuration from a specific .env file.
func LoadCoolifyConfigFromFile(path string) (*CoolifyConfig, error) {
config := &CoolifyConfig{}
// First try environment variables
config.URL = os.Getenv("COOLIFY_URL")
config.Token = os.Getenv("COOLIFY_TOKEN")
config.AppID = os.Getenv("COOLIFY_APP_ID")
config.StagingAppID = os.Getenv("COOLIFY_STAGING_APP_ID")
// Then try .env file
file, err := os.Open(path)
if err != nil {
if os.IsNotExist(err) {
// No .env file, just use env vars
return validateCoolifyConfig(config)
}
return nil, fmt.Errorf("failed to open .env file: %w", err)
}
defer file.Close()
content, err := io.ReadAll(file)
if err != nil {
return nil, fmt.Errorf("failed to read .env file: %w", err)
}
// Parse .env file
lines := strings.Split(string(content), "\n")
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
parts := strings.SplitN(line, "=", 2)
if len(parts) != 2 {
continue
}
key := strings.TrimSpace(parts[0])
value := strings.TrimSpace(parts[1])
// Remove quotes if present
value = strings.Trim(value, `"'`)
// Only override if not already set from env
switch key {
case "COOLIFY_URL":
if config.URL == "" {
config.URL = value
}
case "COOLIFY_TOKEN":
if config.Token == "" {
config.Token = value
}
case "COOLIFY_APP_ID":
if config.AppID == "" {
config.AppID = value
}
case "COOLIFY_STAGING_APP_ID":
if config.StagingAppID == "" {
config.StagingAppID = value
}
}
}
return validateCoolifyConfig(config)
}
// validateCoolifyConfig checks that required fields are set.
func validateCoolifyConfig(config *CoolifyConfig) (*CoolifyConfig, error) {
if config.URL == "" {
return nil, fmt.Errorf("COOLIFY_URL is not set")
}
if config.Token == "" {
return nil, fmt.Errorf("COOLIFY_TOKEN is not set")
}
return config, nil
}
// TriggerDeploy triggers a deployment for the specified application.
func (c *CoolifyClient) TriggerDeploy(ctx context.Context, appID string, force bool) (*CoolifyDeployment, error) {
endpoint := fmt.Sprintf("%s/api/v1/applications/%s/deploy", c.BaseURL, appID)
payload := map[string]interface{}{}
if force {
payload["force"] = true
}
body, err := json.Marshal(payload)
if err != nil {
return nil, fmt.Errorf("failed to marshal request: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
c.setHeaders(req)
resp, err := c.HTTPClient.Do(req)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusAccepted {
return nil, c.parseError(resp)
}
var deployment CoolifyDeployment
if err := json.NewDecoder(resp.Body).Decode(&deployment); err != nil {
// Some Coolify versions return minimal response
return &CoolifyDeployment{
Status: "queued",
CreatedAt: time.Now(),
}, nil
}
return &deployment, nil
}
// GetDeployment retrieves a specific deployment by ID.
func (c *CoolifyClient) GetDeployment(ctx context.Context, appID, deploymentID string) (*CoolifyDeployment, error) {
endpoint := fmt.Sprintf("%s/api/v1/applications/%s/deployments/%s", c.BaseURL, appID, deploymentID)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
c.setHeaders(req)
resp, err := c.HTTPClient.Do(req)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, c.parseError(resp)
}
var deployment CoolifyDeployment
if err := json.NewDecoder(resp.Body).Decode(&deployment); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}
return &deployment, nil
}
// ListDeployments retrieves deployments for an application.
func (c *CoolifyClient) ListDeployments(ctx context.Context, appID string, limit int) ([]CoolifyDeployment, error) {
endpoint := fmt.Sprintf("%s/api/v1/applications/%s/deployments", c.BaseURL, appID)
if limit > 0 {
endpoint = fmt.Sprintf("%s?limit=%d", endpoint, limit)
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
c.setHeaders(req)
resp, err := c.HTTPClient.Do(req)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, c.parseError(resp)
}
var deployments []CoolifyDeployment
if err := json.NewDecoder(resp.Body).Decode(&deployments); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}
return deployments, nil
}
// Rollback triggers a rollback to a previous deployment.
func (c *CoolifyClient) Rollback(ctx context.Context, appID, deploymentID string) (*CoolifyDeployment, error) {
endpoint := fmt.Sprintf("%s/api/v1/applications/%s/rollback", c.BaseURL, appID)
payload := map[string]interface{}{
"deployment_id": deploymentID,
}
body, err := json.Marshal(payload)
if err != nil {
return nil, fmt.Errorf("failed to marshal request: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
c.setHeaders(req)
resp, err := c.HTTPClient.Do(req)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusAccepted {
return nil, c.parseError(resp)
}
var deployment CoolifyDeployment
if err := json.NewDecoder(resp.Body).Decode(&deployment); err != nil {
return &CoolifyDeployment{
Status: "rolling_back",
CreatedAt: time.Now(),
}, nil
}
return &deployment, nil
}
// GetApp retrieves application details.
func (c *CoolifyClient) GetApp(ctx context.Context, appID string) (*CoolifyApp, error) {
endpoint := fmt.Sprintf("%s/api/v1/applications/%s", c.BaseURL, appID)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
c.setHeaders(req)
resp, err := c.HTTPClient.Do(req)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, c.parseError(resp)
}
var app CoolifyApp
if err := json.NewDecoder(resp.Body).Decode(&app); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}
return &app, nil
}
// setHeaders sets common headers for API requests.
func (c *CoolifyClient) setHeaders(req *http.Request) {
req.Header.Set("Authorization", "Bearer "+c.Token)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
}
// parseError extracts error information from an API response.
func (c *CoolifyClient) parseError(resp *http.Response) error {
body, _ := io.ReadAll(resp.Body)
var errResp struct {
Message string `json:"message"`
Error string `json:"error"`
}
if err := json.Unmarshal(body, &errResp); err == nil {
if errResp.Message != "" {
return fmt.Errorf("API error (%d): %s", resp.StatusCode, errResp.Message)
}
if errResp.Error != "" {
return fmt.Errorf("API error (%d): %s", resp.StatusCode, errResp.Error)
}
}
return fmt.Errorf("API error (%d): %s", resp.StatusCode, string(body))
}

406
pkg/php/deploy.go Normal file
View file

@ -0,0 +1,406 @@
package php
import (
"context"
"fmt"
"time"
)
// Environment represents a deployment environment.
type Environment string
const (
// EnvProduction is the production environment.
EnvProduction Environment = "production"
// EnvStaging is the staging environment.
EnvStaging Environment = "staging"
)
// DeployOptions configures a deployment.
type DeployOptions struct {
// Dir is the project directory containing .env config.
Dir string
// Environment is the target environment (production or staging).
Environment Environment
// Force triggers a deployment even if no changes are detected.
Force bool
// Wait blocks until deployment completes.
Wait bool
// WaitTimeout is the maximum time to wait for deployment.
// Defaults to 10 minutes.
WaitTimeout time.Duration
// PollInterval is how often to check deployment status when waiting.
// Defaults to 5 seconds.
PollInterval time.Duration
}
// StatusOptions configures a status check.
type StatusOptions struct {
// Dir is the project directory containing .env config.
Dir string
// Environment is the target environment (production or staging).
Environment Environment
// DeploymentID is a specific deployment to check.
// If empty, returns the latest deployment.
DeploymentID string
}
// RollbackOptions configures a rollback.
type RollbackOptions struct {
// Dir is the project directory containing .env config.
Dir string
// Environment is the target environment (production or staging).
Environment Environment
// DeploymentID is the deployment to rollback to.
// If empty, rolls back to the previous successful deployment.
DeploymentID string
// Wait blocks until rollback completes.
Wait bool
// WaitTimeout is the maximum time to wait for rollback.
WaitTimeout time.Duration
}
// DeploymentStatus represents the status of a deployment.
type DeploymentStatus struct {
// ID is the deployment identifier.
ID string
// Status is the current deployment status.
// Values: queued, building, deploying, finished, failed, cancelled
Status string
// URL is the deployed application URL.
URL string
// Commit is the git commit SHA.
Commit string
// CommitMessage is the git commit message.
CommitMessage string
// Branch is the git branch.
Branch string
// StartedAt is when the deployment started.
StartedAt time.Time
// CompletedAt is when the deployment completed.
CompletedAt time.Time
// Log contains deployment logs.
Log string
}
// Deploy triggers a deployment to Coolify.
func Deploy(ctx context.Context, opts DeployOptions) (*DeploymentStatus, error) {
if opts.Dir == "" {
opts.Dir = "."
}
if opts.Environment == "" {
opts.Environment = EnvProduction
}
if opts.WaitTimeout == 0 {
opts.WaitTimeout = 10 * time.Minute
}
if opts.PollInterval == 0 {
opts.PollInterval = 5 * time.Second
}
// Load config
config, err := LoadCoolifyConfig(opts.Dir)
if err != nil {
return nil, fmt.Errorf("failed to load Coolify config: %w", err)
}
// Get app ID for environment
appID := getAppIDForEnvironment(config, opts.Environment)
if appID == "" {
return nil, fmt.Errorf("no app ID configured for %s environment", opts.Environment)
}
// Create client
client := NewCoolifyClient(config.URL, config.Token)
// Trigger deployment
deployment, err := client.TriggerDeploy(ctx, appID, opts.Force)
if err != nil {
return nil, fmt.Errorf("failed to trigger deployment: %w", err)
}
status := convertDeployment(deployment)
// Wait for completion if requested
if opts.Wait && deployment.ID != "" {
status, err = waitForDeployment(ctx, client, appID, deployment.ID, opts.WaitTimeout, opts.PollInterval)
if err != nil {
return status, err
}
}
// Get app info for URL
app, err := client.GetApp(ctx, appID)
if err == nil && app.FQDN != "" {
status.URL = app.FQDN
}
return status, nil
}
// DeployStatus retrieves the status of a deployment.
func DeployStatus(ctx context.Context, opts StatusOptions) (*DeploymentStatus, error) {
if opts.Dir == "" {
opts.Dir = "."
}
if opts.Environment == "" {
opts.Environment = EnvProduction
}
// Load config
config, err := LoadCoolifyConfig(opts.Dir)
if err != nil {
return nil, fmt.Errorf("failed to load Coolify config: %w", err)
}
// Get app ID for environment
appID := getAppIDForEnvironment(config, opts.Environment)
if appID == "" {
return nil, fmt.Errorf("no app ID configured for %s environment", opts.Environment)
}
// Create client
client := NewCoolifyClient(config.URL, config.Token)
var deployment *CoolifyDeployment
if opts.DeploymentID != "" {
// Get specific deployment
deployment, err = client.GetDeployment(ctx, appID, opts.DeploymentID)
if err != nil {
return nil, fmt.Errorf("failed to get deployment: %w", err)
}
} else {
// Get latest deployment
deployments, err := client.ListDeployments(ctx, appID, 1)
if err != nil {
return nil, fmt.Errorf("failed to list deployments: %w", err)
}
if len(deployments) == 0 {
return nil, fmt.Errorf("no deployments found")
}
deployment = &deployments[0]
}
status := convertDeployment(deployment)
// Get app info for URL
app, err := client.GetApp(ctx, appID)
if err == nil && app.FQDN != "" {
status.URL = app.FQDN
}
return status, nil
}
// Rollback triggers a rollback to a previous deployment.
func Rollback(ctx context.Context, opts RollbackOptions) (*DeploymentStatus, error) {
if opts.Dir == "" {
opts.Dir = "."
}
if opts.Environment == "" {
opts.Environment = EnvProduction
}
if opts.WaitTimeout == 0 {
opts.WaitTimeout = 10 * time.Minute
}
// Load config
config, err := LoadCoolifyConfig(opts.Dir)
if err != nil {
return nil, fmt.Errorf("failed to load Coolify config: %w", err)
}
// Get app ID for environment
appID := getAppIDForEnvironment(config, opts.Environment)
if appID == "" {
return nil, fmt.Errorf("no app ID configured for %s environment", opts.Environment)
}
// Create client
client := NewCoolifyClient(config.URL, config.Token)
// Find deployment to rollback to
deploymentID := opts.DeploymentID
if deploymentID == "" {
// Find previous successful deployment
deployments, err := client.ListDeployments(ctx, appID, 10)
if err != nil {
return nil, fmt.Errorf("failed to list deployments: %w", err)
}
// Skip the first (current) deployment, find the last successful one
for i, d := range deployments {
if i == 0 {
continue // Skip current deployment
}
if d.Status == "finished" || d.Status == "success" {
deploymentID = d.ID
break
}
}
if deploymentID == "" {
return nil, fmt.Errorf("no previous successful deployment found to rollback to")
}
}
// Trigger rollback
deployment, err := client.Rollback(ctx, appID, deploymentID)
if err != nil {
return nil, fmt.Errorf("failed to trigger rollback: %w", err)
}
status := convertDeployment(deployment)
// Wait for completion if requested
if opts.Wait && deployment.ID != "" {
status, err = waitForDeployment(ctx, client, appID, deployment.ID, opts.WaitTimeout, 5*time.Second)
if err != nil {
return status, err
}
}
return status, nil
}
// ListDeployments retrieves recent deployments.
func ListDeployments(ctx context.Context, dir string, env Environment, limit int) ([]DeploymentStatus, error) {
if dir == "" {
dir = "."
}
if env == "" {
env = EnvProduction
}
if limit == 0 {
limit = 10
}
// Load config
config, err := LoadCoolifyConfig(dir)
if err != nil {
return nil, fmt.Errorf("failed to load Coolify config: %w", err)
}
// Get app ID for environment
appID := getAppIDForEnvironment(config, env)
if appID == "" {
return nil, fmt.Errorf("no app ID configured for %s environment", env)
}
// Create client
client := NewCoolifyClient(config.URL, config.Token)
deployments, err := client.ListDeployments(ctx, appID, limit)
if err != nil {
return nil, fmt.Errorf("failed to list deployments: %w", err)
}
result := make([]DeploymentStatus, len(deployments))
for i, d := range deployments {
result[i] = *convertDeployment(&d)
}
return result, nil
}
// getAppIDForEnvironment returns the app ID for the given environment.
func getAppIDForEnvironment(config *CoolifyConfig, env Environment) string {
switch env {
case EnvStaging:
if config.StagingAppID != "" {
return config.StagingAppID
}
return config.AppID // Fallback to production
default:
return config.AppID
}
}
// convertDeployment converts a CoolifyDeployment to DeploymentStatus.
func convertDeployment(d *CoolifyDeployment) *DeploymentStatus {
return &DeploymentStatus{
ID: d.ID,
Status: d.Status,
URL: d.DeployedURL,
Commit: d.CommitSHA,
CommitMessage: d.CommitMsg,
Branch: d.Branch,
StartedAt: d.CreatedAt,
CompletedAt: d.FinishedAt,
Log: d.Log,
}
}
// waitForDeployment polls for deployment completion.
func waitForDeployment(ctx context.Context, client *CoolifyClient, appID, deploymentID string, timeout, interval time.Duration) (*DeploymentStatus, error) {
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
select {
case <-ctx.Done():
return nil, ctx.Err()
default:
}
deployment, err := client.GetDeployment(ctx, appID, deploymentID)
if err != nil {
return nil, fmt.Errorf("failed to get deployment status: %w", err)
}
status := convertDeployment(deployment)
// Check if deployment is complete
switch deployment.Status {
case "finished", "success":
return status, nil
case "failed", "error":
return status, fmt.Errorf("deployment failed: %s", deployment.Status)
case "cancelled":
return status, fmt.Errorf("deployment was cancelled")
}
// Still in progress, wait and retry
select {
case <-ctx.Done():
return status, ctx.Err()
case <-time.After(interval):
}
}
return nil, fmt.Errorf("deployment timed out after %v", timeout)
}
// IsDeploymentComplete returns true if the status indicates completion.
func IsDeploymentComplete(status string) bool {
switch status {
case "finished", "success", "failed", "error", "cancelled":
return true
default:
return false
}
}
// IsDeploymentSuccessful returns true if the status indicates success.
func IsDeploymentSuccessful(status string) bool {
return status == "finished" || status == "success"
}

257
pkg/php/deploy_test.go Normal file
View file

@ -0,0 +1,257 @@
package php
import (
"os"
"path/filepath"
"testing"
)
func TestLoadCoolifyConfig_Good(t *testing.T) {
tests := []struct {
name string
envContent string
wantURL string
wantToken string
wantAppID string
wantStaging string
}{
{
name: "all values set",
envContent: `COOLIFY_URL=https://coolify.example.com
COOLIFY_TOKEN=secret-token
COOLIFY_APP_ID=app-123
COOLIFY_STAGING_APP_ID=staging-456`,
wantURL: "https://coolify.example.com",
wantToken: "secret-token",
wantAppID: "app-123",
wantStaging: "staging-456",
},
{
name: "quoted values",
envContent: `COOLIFY_URL="https://coolify.example.com"
COOLIFY_TOKEN='secret-token'
COOLIFY_APP_ID="app-123"`,
wantURL: "https://coolify.example.com",
wantToken: "secret-token",
wantAppID: "app-123",
},
{
name: "with comments and blank lines",
envContent: `# Coolify configuration
COOLIFY_URL=https://coolify.example.com
# API token
COOLIFY_TOKEN=secret-token
COOLIFY_APP_ID=app-123
# COOLIFY_STAGING_APP_ID=not-this`,
wantURL: "https://coolify.example.com",
wantToken: "secret-token",
wantAppID: "app-123",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Create temp directory
dir := t.TempDir()
envPath := filepath.Join(dir, ".env")
// Write .env file
if err := os.WriteFile(envPath, []byte(tt.envContent), 0644); err != nil {
t.Fatalf("failed to write .env: %v", err)
}
// Load config
config, err := LoadCoolifyConfig(dir)
if err != nil {
t.Fatalf("LoadCoolifyConfig() error = %v", err)
}
if config.URL != tt.wantURL {
t.Errorf("URL = %q, want %q", config.URL, tt.wantURL)
}
if config.Token != tt.wantToken {
t.Errorf("Token = %q, want %q", config.Token, tt.wantToken)
}
if config.AppID != tt.wantAppID {
t.Errorf("AppID = %q, want %q", config.AppID, tt.wantAppID)
}
if tt.wantStaging != "" && config.StagingAppID != tt.wantStaging {
t.Errorf("StagingAppID = %q, want %q", config.StagingAppID, tt.wantStaging)
}
})
}
}
func TestLoadCoolifyConfig_Bad(t *testing.T) {
tests := []struct {
name string
envContent string
wantErr string
}{
{
name: "missing URL",
envContent: "COOLIFY_TOKEN=secret",
wantErr: "COOLIFY_URL is not set",
},
{
name: "missing token",
envContent: "COOLIFY_URL=https://coolify.example.com",
wantErr: "COOLIFY_TOKEN is not set",
},
{
name: "empty values",
envContent: "COOLIFY_URL=\nCOOLIFY_TOKEN=",
wantErr: "COOLIFY_URL is not set",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Create temp directory
dir := t.TempDir()
envPath := filepath.Join(dir, ".env")
// Write .env file
if err := os.WriteFile(envPath, []byte(tt.envContent), 0644); err != nil {
t.Fatalf("failed to write .env: %v", err)
}
// Load config
_, err := LoadCoolifyConfig(dir)
if err == nil {
t.Fatal("LoadCoolifyConfig() expected error, got nil")
}
if err.Error() != tt.wantErr {
t.Errorf("error = %q, want %q", err.Error(), tt.wantErr)
}
})
}
}
func TestGetAppIDForEnvironment_Good(t *testing.T) {
config := &CoolifyConfig{
URL: "https://coolify.example.com",
Token: "token",
AppID: "prod-123",
StagingAppID: "staging-456",
}
tests := []struct {
name string
env Environment
wantID string
}{
{
name: "production environment",
env: EnvProduction,
wantID: "prod-123",
},
{
name: "staging environment",
env: EnvStaging,
wantID: "staging-456",
},
{
name: "empty defaults to production",
env: "",
wantID: "prod-123",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
id := getAppIDForEnvironment(config, tt.env)
if id != tt.wantID {
t.Errorf("getAppIDForEnvironment() = %q, want %q", id, tt.wantID)
}
})
}
}
func TestGetAppIDForEnvironment_FallbackToProduction(t *testing.T) {
config := &CoolifyConfig{
URL: "https://coolify.example.com",
Token: "token",
AppID: "prod-123",
// No staging app ID
}
// Staging should fall back to production
id := getAppIDForEnvironment(config, EnvStaging)
if id != "prod-123" {
t.Errorf("getAppIDForEnvironment(EnvStaging) = %q, want %q (should fallback)", id, "prod-123")
}
}
func TestIsDeploymentComplete_Good(t *testing.T) {
completeStatuses := []string{"finished", "success", "failed", "error", "cancelled"}
for _, status := range completeStatuses {
if !IsDeploymentComplete(status) {
t.Errorf("IsDeploymentComplete(%q) = false, want true", status)
}
}
incompleteStatuses := []string{"queued", "building", "deploying", "pending", "rolling_back"}
for _, status := range incompleteStatuses {
if IsDeploymentComplete(status) {
t.Errorf("IsDeploymentComplete(%q) = true, want false", status)
}
}
}
func TestIsDeploymentSuccessful_Good(t *testing.T) {
successStatuses := []string{"finished", "success"}
for _, status := range successStatuses {
if !IsDeploymentSuccessful(status) {
t.Errorf("IsDeploymentSuccessful(%q) = false, want true", status)
}
}
failedStatuses := []string{"failed", "error", "cancelled", "queued", "building"}
for _, status := range failedStatuses {
if IsDeploymentSuccessful(status) {
t.Errorf("IsDeploymentSuccessful(%q) = true, want false", status)
}
}
}
func TestNewCoolifyClient_Good(t *testing.T) {
tests := []struct {
name string
baseURL string
wantBaseURL string
}{
{
name: "URL without trailing slash",
baseURL: "https://coolify.example.com",
wantBaseURL: "https://coolify.example.com",
},
{
name: "URL with trailing slash",
baseURL: "https://coolify.example.com/",
wantBaseURL: "https://coolify.example.com",
},
{
name: "URL with api path",
baseURL: "https://coolify.example.com/api/",
wantBaseURL: "https://coolify.example.com/api",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
client := NewCoolifyClient(tt.baseURL, "token")
if client.BaseURL != tt.wantBaseURL {
t.Errorf("BaseURL = %q, want %q", client.BaseURL, tt.wantBaseURL)
}
if client.Token != "token" {
t.Errorf("Token = %q, want %q", client.Token, "token")
}
if client.HTTPClient == nil {
t.Error("HTTPClient is nil")
}
})
}
}