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