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:
parent
312fce343d
commit
605ee023ca
4 changed files with 1407 additions and 0 deletions
|
|
@ -67,6 +67,7 @@ func AddPHPCommands(parent *clir.Cli) {
|
||||||
addPHPFmtCommand(phpCmd)
|
addPHPFmtCommand(phpCmd)
|
||||||
addPHPAnalyseCommand(phpCmd)
|
addPHPAnalyseCommand(phpCmd)
|
||||||
addPHPPackagesCommands(phpCmd)
|
addPHPPackagesCommands(phpCmd)
|
||||||
|
addPHPDeployCommands(phpCmd)
|
||||||
}
|
}
|
||||||
|
|
||||||
func addPHPDevCommand(parent *clir.Command) {
|
func addPHPDevCommand(parent *clir.Command) {
|
||||||
|
|
@ -1151,3 +1152,392 @@ func addPHPPackagesListCommand(parent *clir.Command) {
|
||||||
return nil
|
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
354
pkg/php/coolify.go
Normal 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
406
pkg/php/deploy.go
Normal 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
257
pkg/php/deploy_test.go
Normal 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")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue