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)
|
||||
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
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