1package handler
2
3import (
4 "encoding/json"
5 "errors"
6 "fmt"
7 "net/http"
8 "regexp"
9 "strings"
10
11 "github.com/bnema/gordon/internal/common"
12 "github.com/bnema/gordon/internal/server"
13 "github.com/bnema/gordon/internal/templating/cmdparams"
14 "github.com/bnema/gordon/pkg/docker"
15 "github.com/bnema/gordon/pkg/store"
16 "github.com/charmbracelet/log"
17 "github.com/labstack/echo/v4"
18)
19
20type InfoResponse struct {
21 Uptime string `json:"uptime"`
22 Version string `json:"version"`
23}
24
25type DeployResponse = common.DeployResponse
26type PushResponse = common.PushResponse
27
28// sendJSONResponse is a helper function to send JSON responses
29func sendJSONResponse(c echo.Context, statusCode int, response interface{}) error {
30 // Debug
31 log.Debug("Sending JSON response", "statusCode", statusCode, "response", response)
32 return c.JSON(statusCode, response)
33}
34
35// Populate fills the InfoResponse struct with data from the App
36func (info *InfoResponse) Populate(a *server.App) {
37 info.Uptime = a.GetUptime()
38 info.Version = a.GetVersionstring()
39}
40
41// GetInfos handles GET requests on /api/ping endpoint
42func GetInfos(c echo.Context, a *server.App) error {
43 payload := new(common.RequestPayload)
44 if err := c.Bind(payload); err != nil {
45 return sendJSONResponse(c, http.StatusBadRequest, "Invalid payload: "+err.Error())
46 }
47
48 if payload.Type != "ping" {
49 return sendJSONResponse(c, http.StatusBadRequest, "Invalid payload type")
50 }
51
52 pingPayload, ok := payload.Payload.(common.PingPayload)
53 if !ok {
54 return sendJSONResponse(c, http.StatusBadRequest, "Invalid payload structure")
55 }
56
57 if pingPayload.Message != "ping" {
58 return sendJSONResponse(c, http.StatusBadRequest, "Invalid ping message")
59 }
60
61 info := &InfoResponse{}
62 info.Populate(a)
63
64 return sendJSONResponse(c, http.StatusOK, info)
65}
66
67// PostPush handles the image push request
68func PostPush(c echo.Context, a *server.App) error {
69 payload, err := validateAndPreparePayload(c)
70 if err != nil {
71 return sendJSONResponse(c, http.StatusBadRequest, PushResponse{
72 Success: false,
73 Message: err.Error(),
74 })
75 }
76
77 _, err = saveAndImportImage(c, a, payload)
78 if err != nil {
79 return sendJSONResponse(c, http.StatusInternalServerError, PushResponse{
80 Success: false,
81 Message: err.Error(),
82 })
83 }
84
85 shortID := generateShortID(payload.ImageID)
86 storeIDMapping(shortID, payload.ImageID)
87
88 createContainerURL := generateCreateContainerURL(a, shortID)
89
90 return sendJSONResponse(c, http.StatusOK, common.PushResponse{
91 Success: true,
92 Message: "Image pushed successfully",
93 CreateContainerURL: createContainerURL,
94 })
95}
96
97// validateAndPreparePayload checks the request and prepares the payload
98func validateAndPreparePayload(c echo.Context) (*common.PushPayload, error) {
99 payload := &common.PushPayload{
100 ImageName: c.Request().Header.Get("X-Image-Name"),
101 }
102
103 if payload.ImageName == "" {
104 return nil, errors.New("Invalid image name")
105 }
106
107 return payload, nil
108}
109
110// saveAndImportImage saves the image to storage and imports it to the Docker engine
111func saveAndImportImage(c echo.Context, a *server.App, payload *common.PushPayload) (string, error) {
112 imageReader := c.Request().Body
113 defer imageReader.Close()
114
115 imageFileName := sanitizeImageFileName(payload.ImageName)
116
117 imagePath, err := store.SaveImageToStorage(&a.Config, imageFileName, imageReader)
118 if err != nil {
119 return "", fmt.Errorf("Failed to save image: %v", err)
120 }
121
122 imageID, err := docker.ImportImageToEngine(imagePath)
123 if err != nil {
124 store.RemoveFromStorage(imagePath)
125 return "", fmt.Errorf("Failed to import image: %v", err)
126 }
127
128 if imageID == "" {
129 return "", errors.New("Imported image ID is empty")
130 }
131
132 err = store.RemoveFromStorage(imagePath)
133 if err != nil {
134 return "", fmt.Errorf("Failed to remove temporary image file: %v", err)
135 }
136
137 payload.ImageID = imageID
138 return imagePath, nil
139}
140
141// sanitizeImageFileName removes unwanted parts from the image name
142func sanitizeImageFileName(imageName string) string {
143 imageName = regexp.MustCompile(`^([a-zA-Z0-9\-_.]+\/)?`).ReplaceAllString(imageName, "")
144 imageName = regexp.MustCompile(`(:[a-zA-Z0-9\-_.]+)?$`).ReplaceAllString(imageName, "")
145 return imageName + ".tar"
146}
147
148// generateShortID creates a shortened version of the image ID
149func generateShortID(imageID string) string {
150 shortID := imageID[7:]
151 if len(shortID) > 12 {
152 shortID = shortID[:12]
153 }
154 return shortID
155}
156
157// storeIDMapping saves the mapping between short ID and full ID
158func storeIDMapping(shortID, fullID string) {
159 safelyInteractWithIDMap(Update, shortID, fullID)
160}
161
162// generateCreateContainerURL creates the URL for the container creation page
163func generateCreateContainerURL(a *server.App, shortID string) string {
164 return fmt.Sprintf("%s://%s%s/cc/%s",
165 a.Config.Http.Protocol(),
166 a.Config.Http.FullDomain(),
167 a.Config.Admin.Path,
168 shortID)
169}
170
171// PostDeploy handles the container deployment request
172func PostDeploy(c echo.Context, a *server.App) error {
173 payload, err := validateAndPrepareDeployPayload(c)
174 if err != nil {
175 // Debug
176 log.Error("Failed to validate and prepare payload", "error", err)
177 return sendJSONResponse(c, http.StatusBadRequest, DeployResponse{
178 Success: false,
179 Message: fmt.Sprintf("Invalid payload: %v", err),
180 })
181 }
182
183 _, err = saveAndImportDeployImage(c, a, payload)
184 if err != nil {
185 // Debug
186 log.Error("Failed to save and import image", "error", err)
187 return sendJSONResponse(c, http.StatusInternalServerError, DeployResponse{
188 Success: false,
189 Message: fmt.Sprintf("Failed to save or import image: %v", err),
190 })
191 }
192
193 containerID, err := createAndStartContainer(a, payload)
194 if err != nil {
195 if strings.Contains(err.Error(), "is already in use") {
196 log.Warn("Container already exists", "error", err)
197 extractedID := extractContainerID(err.Error())
198 log.Info("Extracted container ID", "containerID", extractedID)
199
200 if extractedID == "" {
201 log.Error("Failed to extract container ID from error message")
202 return sendJSONResponse(c, http.StatusConflict, DeployResponse{
203 Success: false,
204 Message: "A container with this name already exists, but we couldn't extract its ID.",
205 })
206 }
207
208 return sendJSONResponse(c, http.StatusConflict, DeployResponse{
209 Success: false,
210 Message: fmt.Sprintf("A container for this deploy already exists."),
211 ContainerID: extractedID,
212 })
213 }
214 log.Error("Failed to create or start container", "error", err)
215 return sendJSONResponse(c, http.StatusInternalServerError, DeployResponse{
216 Success: false,
217 Message: fmt.Sprintf("Failed to create or start container: %v", err),
218 })
219 }
220 response := DeployResponse{
221 Success: true,
222 Message: "Deployment successful",
223 Domain: payload.TargetDomain,
224 ContainerID: containerID,
225 }
226
227 // Debug: Verify containerID in response
228 if response.ContainerID != containerID {
229 log.Error("Container ID mismatch", "responseID", response.ContainerID, "actualID", containerID)
230 } else {
231 log.Info("Container ID successfully stored in response", "containerID", containerID)
232 }
233
234 return sendJSONResponse(c, http.StatusOK, response)
235}
236
237// is already in use by 08433f639cb1c01254d43f234360bfa793aa8c33b29e8de8ce42b0706c0090ba. example
238func extractContainerID(errorMessage string) string {
239 re := regexp.MustCompile(`by\s+([0-9a-f]+)\.`)
240 match := re.FindStringSubmatch(errorMessage)
241 if len(match) > 1 {
242 return match[1]
243 }
244 return ""
245}
246
247// validateAndPrepareDeployPayload prepares the deployment payload
248func validateAndPrepareDeployPayload(c echo.Context) (*common.DeployPayload, error) {
249 payload := &common.DeployPayload{
250 Port: c.Request().Header.Get("X-Ports"),
251 ImageName: c.Request().Header.Get("X-Image-Name"),
252 TargetDomain: c.Request().Header.Get("X-Target-Domain"),
253 }
254
255 if payload.ImageName == "" {
256 return nil, errors.New("Invalid image name")
257 }
258
259 payload.Port = normalizePort(payload.Port)
260 payload.TargetDomain = normalizeTargetDomain(payload.TargetDomain)
261
262 return payload, nil
263}
264
265// normalizePort adds "/tcp" to the port if not present
266func normalizePort(port string) string {
267 if !regexp.MustCompile(`\/(tcp|udp)$`).MatchString(port) {
268 return port + "/tcp"
269 }
270 return port
271}
272
273// normalizeTargetDomain adds "https://" to the domain if not present
274func normalizeTargetDomain(domain string) string {
275 if !regexp.MustCompile(`^https?:\/\/`).MatchString(domain) {
276 return "https://" + domain
277 }
278 return domain
279}
280
281// saveAndImportDeployImage saves and imports the deployment image
282func saveAndImportDeployImage(c echo.Context, a *server.App, payload *common.DeployPayload) (string, error) {
283 imageReader := c.Request().Body
284 defer imageReader.Close()
285
286 imageFileName := sanitizeImageFileName(payload.ImageName)
287
288 imagePath, err := store.SaveImageToStorage(&a.Config, imageFileName, imageReader)
289 if err != nil {
290 return "", fmt.Errorf("Failed to save image: %v", err)
291 }
292
293 imageID, err := docker.ImportImageToEngine(imagePath)
294 if err != nil {
295 store.RemoveFromStorage(imagePath)
296 return "", fmt.Errorf("Failed to import image: %v", err)
297 }
298
299 if imageID == "" {
300 return "", errors.New("Imported image ID is empty")
301 }
302
303 err = store.RemoveFromStorage(imagePath)
304 if err != nil {
305 return "", fmt.Errorf("Failed to remove temporary image file: %v", err)
306 }
307
308 payload.ImageID = imageID
309 return imagePath, nil
310}
311
312// createAndStartContainer creates and starts a new container
313func createAndStartContainer(a *server.App, payload *common.DeployPayload) (string, error) {
314 params, err := cmdparams.FromPayloadStructToCmdParams(payload, a, payload.ImageID)
315 if err != nil {
316 return "", fmt.Errorf("Failed to create command parameters: %v", err)
317 }
318
319 containerID, err := docker.CreateContainer(params)
320 if err != nil {
321 return "", fmt.Errorf("Failed to create container: %v", err)
322 }
323
324 err = docker.StartContainer(containerID)
325 if err != nil {
326 docker.RemoveContainer(containerID)
327 return "", fmt.Errorf("Failed to start container: %v", err)
328 }
329
330 return containerID, nil
331}
332
333// logResponse logs the deployment response
334func logResponse(response DeployResponse) {
335 responseJSON, _ := json.Marshal(response)
336 fmt.Printf("Server response: %s\n", string(responseJSON))
337}