diff --git a/cmd/core/cmd/php.go b/cmd/core/cmd/php.go index 8356421..9e4434c 100644 --- a/cmd/core/cmd/php.go +++ b/cmd/core/cmd/php.go @@ -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) + } +} diff --git a/pkg/php/coolify.go b/pkg/php/coolify.go new file mode 100644 index 0000000..2a83e77 --- /dev/null +++ b/pkg/php/coolify.go @@ -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)) +} diff --git a/pkg/php/deploy.go b/pkg/php/deploy.go new file mode 100644 index 0000000..fc59b6e --- /dev/null +++ b/pkg/php/deploy.go @@ -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" +} diff --git a/pkg/php/deploy_test.go b/pkg/php/deploy_test.go new file mode 100644 index 0000000..436d457 --- /dev/null +++ b/pkg/php/deploy_test.go @@ -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") + } + }) + } +}