1// gordon/internal/cli/cmd/deploy.go
2
3package cmd
4
5import (
6 "encoding/json"
7 "fmt"
8 "io"
9 "net/http"
10 "os"
11 "strings"
12 "time"
13
14 "github.com/bnema/gordon/internal/cli"
15 "github.com/bnema/gordon/internal/cli/handler"
16 "github.com/bnema/gordon/internal/common"
17 "github.com/bnema/gordon/pkg/docker"
18 "github.com/charmbracelet/log"
19 "github.com/spf13/cobra"
20)
21
22func init() {
23 log.SetReportTimestamp(true)
24 log.SetTimeFormat("15:04")
25}
26
27func NewDeployCommand(a *cli.App) *cobra.Command {
28 var port string
29 var targetDomain string
30
31 deployCmd := &cobra.Command{
32 Use: "deploy [image:tag]",
33 Short: "Deploy an image to your remote Gordon instance",
34 Args: cobra.ExactArgs(1),
35 PreRun: func(cmd *cobra.Command, args []string) {
36 if err := handler.FieldCheck(a); err != nil {
37 log.Error("Field check failed", "error", err)
38 os.Exit(1)
39 }
40 },
41 Run: func(cmd *cobra.Command, args []string) {
42 imageName := args[0]
43 log.Info("Pushing image", "image", imageName)
44
45 if err := validateInputs(imageName, port, targetDomain); err != nil {
46 log.Error("Validation failed", "error", err)
47 return
48 }
49
50 reader, actualSize, err := exportDockerImage(imageName)
51 if err != nil {
52 log.Error("Error exporting image", "error", err)
53 return
54 }
55 defer reader.Close()
56
57 sizeInMB := float64(actualSize) / 1024 / 1024
58
59 log.Info("Image exported successfully", "image", imageName, "size", fmt.Sprintf("%.2fMB", sizeInMB))
60
61 if err := deployImage(a, reader, imageName, port, targetDomain); err != nil {
62 if deployErr, ok := err.(*common.DeploymentError); ok {
63 log.Error("Deployment failed",
64 "status_code", deployErr.StatusCode,
65 "message", deployErr.Message,
66 )
67 } else {
68 log.Error("Deployment failed", "error", err)
69 }
70 return
71 }
72
73 log.Info("Deployment successful!")
74 },
75 }
76
77 deployCmd.Flags().StringVarP(&port, "port", "p", "", "Port mapping for the container and Traefik entry point")
78 deployCmd.Flags().StringVarP(&targetDomain, "target", "t", "", "Target domain for Traefik")
79
80 return deployCmd
81}
82
83func validateInputs(imageName, port, targetDomain string) error {
84 if err := handler.ValidateImageName(imageName); err != nil {
85 return err
86 }
87
88 handler.EnsureImageTag(&imageName)
89
90 if port == "" {
91 return fmt.Errorf("port is required")
92 }
93
94 return handler.ValidateTargetDomain(targetDomain)
95}
96
97func exportDockerImage(imageName string) (io.ReadCloser, int64, error) {
98 exists, err := docker.CheckIfImageExists(imageName)
99 if err != nil {
100 return nil, 0, fmt.Errorf("error checking image existence: %w", err)
101 }
102
103 var imageID string
104 if exists {
105 imageID = imageName
106 } else {
107 imageID, err = docker.GetImageIDByName(imageName)
108 if err != nil {
109 return nil, 0, fmt.Errorf("error searching for image by name: %w", err)
110 }
111 }
112
113 actualSize, err := docker.GetImageSizeFromReader(imageID)
114 if err != nil {
115 return nil, 0, fmt.Errorf("error retrieving image size: %w", err)
116 }
117
118 reader, err := docker.ExportImageFromEngine(imageID)
119 if err != nil {
120 return nil, 0, fmt.Errorf("error exporting image: %w", err)
121 }
122
123 return reader, actualSize, nil
124}
125
126func deployImage(a *cli.App, reader io.Reader, imageName, port, targetDomain string) error {
127 reqPayload := common.RequestPayload{
128 Type: "deploy",
129 Payload: common.DeployPayload{
130 Port: port,
131 TargetDomain: targetDomain,
132 ImageName: imageName,
133 Data: io.NopCloser(reader),
134 },
135 }
136
137 resp, err := handler.SendHTTPRequest(a, &reqPayload, "POST", "/deploy")
138 if err != nil {
139 return &common.DeploymentError{
140 StatusCode: 0,
141 Message: fmt.Sprintf("error sending HTTP request: %v", err),
142 }
143 }
144
145 var deployResponse common.DeployResponse
146 if err := json.Unmarshal(resp.Body, &deployResponse); err != nil {
147 return &common.DeploymentError{
148 StatusCode: resp.StatusCode,
149 Message: fmt.Sprintf("error parsing response: %v", err),
150 RawResponse: string(resp.Body),
151 }
152 }
153
154 if !deployResponse.Success {
155 return &common.DeploymentError{
156 StatusCode: resp.StatusCode,
157 Message: deployResponse.Message,
158 RawResponse: string(resp.Body),
159 }
160 }
161
162 log.Info("Application deployed", "domain", deployResponse.Domain)
163 return waitForDeployment(deployResponse.Domain)
164}
165
166func waitForDeployment(domain string) error {
167 client := &http.Client{Timeout: 10 * time.Second}
168 maxRetries := 20
169 retryInterval := time.Second
170
171 log.Info("Waiting for deployment to be reachable")
172 for i := 0; i < maxRetries; i++ {
173 resp, err := client.Get(domain)
174 if err == nil {
175 defer resp.Body.Close()
176 if resp.StatusCode == http.StatusOK {
177 return nil
178 }
179 // Check for error messages in the response body
180 body, _ := io.ReadAll(resp.Body)
181 if strings.Contains(string(body), "failed to create container:") {
182 return fmt.Errorf("deployment failed: %s", string(body))
183 }
184 }
185 log.Warn("Deployment not ready yet, retrying", "attempt", fmt.Sprintf("%d/20", i+1))
186 time.Sleep(retryInterval)
187 }
188
189 return fmt.Errorf("deployment not ready after %d attempts, giving up", maxRetries)
190}