408 lines
10 KiB
Go
408 lines
10 KiB
Go
|
|
package php
|
||
|
|
|
||
|
|
import (
|
||
|
|
"context"
|
||
|
|
"time"
|
||
|
|
|
||
|
|
"forge.lthn.ai/core/go/pkg/cli"
|
||
|
|
)
|
||
|
|
|
||
|
|
// 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, cli.WrapVerb(err, "load", "Coolify config")
|
||
|
|
}
|
||
|
|
|
||
|
|
// Get app ID for environment
|
||
|
|
appID := getAppIDForEnvironment(config, opts.Environment)
|
||
|
|
if appID == "" {
|
||
|
|
return nil, cli.Err("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, cli.WrapVerb(err, "trigger", "deployment")
|
||
|
|
}
|
||
|
|
|
||
|
|
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, cli.WrapVerb(err, "load", "Coolify config")
|
||
|
|
}
|
||
|
|
|
||
|
|
// Get app ID for environment
|
||
|
|
appID := getAppIDForEnvironment(config, opts.Environment)
|
||
|
|
if appID == "" {
|
||
|
|
return nil, cli.Err("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, cli.WrapVerb(err, "get", "deployment")
|
||
|
|
}
|
||
|
|
} else {
|
||
|
|
// Get latest deployment
|
||
|
|
deployments, err := client.ListDeployments(ctx, appID, 1)
|
||
|
|
if err != nil {
|
||
|
|
return nil, cli.WrapVerb(err, "list", "deployments")
|
||
|
|
}
|
||
|
|
if len(deployments) == 0 {
|
||
|
|
return nil, cli.Err("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, cli.WrapVerb(err, "load", "Coolify config")
|
||
|
|
}
|
||
|
|
|
||
|
|
// Get app ID for environment
|
||
|
|
appID := getAppIDForEnvironment(config, opts.Environment)
|
||
|
|
if appID == "" {
|
||
|
|
return nil, cli.Err("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, cli.WrapVerb(err, "list", "deployments")
|
||
|
|
}
|
||
|
|
|
||
|
|
// 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, cli.Err("no previous successful deployment found to rollback to")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Trigger rollback
|
||
|
|
deployment, err := client.Rollback(ctx, appID, deploymentID)
|
||
|
|
if err != nil {
|
||
|
|
return nil, cli.WrapVerb(err, "trigger", "rollback")
|
||
|
|
}
|
||
|
|
|
||
|
|
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, cli.WrapVerb(err, "load", "Coolify config")
|
||
|
|
}
|
||
|
|
|
||
|
|
// Get app ID for environment
|
||
|
|
appID := getAppIDForEnvironment(config, env)
|
||
|
|
if appID == "" {
|
||
|
|
return nil, cli.Err("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, cli.WrapVerb(err, "list", "deployments")
|
||
|
|
}
|
||
|
|
|
||
|
|
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, cli.WrapVerb(err, "get", "deployment status")
|
||
|
|
}
|
||
|
|
|
||
|
|
status := convertDeployment(deployment)
|
||
|
|
|
||
|
|
// Check if deployment is complete
|
||
|
|
switch deployment.Status {
|
||
|
|
case "finished", "success":
|
||
|
|
return status, nil
|
||
|
|
case "failed", "error":
|
||
|
|
return status, cli.Err("deployment failed: %s", deployment.Status)
|
||
|
|
case "cancelled":
|
||
|
|
return status, cli.Err("deployment was cancelled")
|
||
|
|
}
|
||
|
|
|
||
|
|
// Still in progress, wait and retry
|
||
|
|
select {
|
||
|
|
case <-ctx.Done():
|
||
|
|
return status, ctx.Err()
|
||
|
|
case <-time.After(interval):
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
return nil, cli.Err("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"
|
||
|
|
}
|