refactor: Improve container image transfer and device auth flow
- Improve chunked file upload support for large image - Enhance error handling with detailed logging and context - Implement progress display for upload operations - Improve conflict detection before deployment - Improve cleanup and validation of temporary files - Add retry mechanism for failed chunk uploads - Update Dockerfile to support multi-platform builds - Improve device auth flow security with user in db check
- commit
- 5c644127c342f04ee38275aed1f1dda9c6fa10e7
- author
- Brice · · 2024-11-18 04:08 UTC
- committer
- Brice · · 2024-11-18 04:13 UTC
- parent
- aead545
- signature
- unsigned
24 files · +1769 -677
M Dockerfile +1 -1
| @@ -1,4 +1,4 @@ | |||
| 1 | - | FROM alpine:latest | |
| 1 | + | FROM --platform=$TARGETPLATFORM alpine:latest | |
| 2 | 2 | ||
| 3 | 3 | COPY gordon /gordon | |
| 4 | 4 | ||
| @@ -1,4 +1,4 @@ | |||||
| 1 | - | FROM alpine:latest | 1 | + | FROM --platform=$TARGETPLATFORM alpine:latest |
| 2 | 2 | ||||
| 3 | COPY gordon /gordon | 3 | COPY gordon /gordon | ||
| 4 | 4 | ||||
M go.mod +10 -2
| @@ -5,6 +5,8 @@ go 1.23 | |||
| 5 | 5 | require ( | |
| 6 | 6 | github.com/AlecAivazis/survey/v2 v2.3.7 | |
| 7 | 7 | github.com/PuerkitoBio/goquery v1.9.2 | |
| 8 | + | github.com/charmbracelet/bubbles v0.20.0 | |
| 9 | + | github.com/charmbracelet/lipgloss v0.13.0 | |
| 8 | 10 | github.com/charmbracelet/log v0.4.0 | |
| 9 | 11 | github.com/docker/docker v27.1.2+incompatible | |
| 10 | 12 | github.com/docker/go-connections v0.5.0 | |
| @@ -22,12 +24,14 @@ require ( | |||
| 22 | 24 | ||
| 23 | 25 | require ( | |
| 24 | 26 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect | |
| 25 | - | github.com/charmbracelet/lipgloss v0.13.0 // indirect | |
| 27 | + | github.com/charmbracelet/bubbletea v1.1.0 // direct | |
| 26 | 28 | github.com/charmbracelet/x/ansi v0.2.3 // indirect | |
| 29 | + | github.com/charmbracelet/x/term v0.2.0 // indirect | |
| 27 | 30 | github.com/containerd/log v0.1.0 // indirect | |
| 28 | 31 | github.com/creack/pty v1.1.18 // indirect | |
| 29 | 32 | github.com/distribution/reference v0.6.0 // indirect | |
| 30 | 33 | github.com/dustin/go-humanize v1.0.1 // indirect | |
| 34 | + | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect | |
| 31 | 35 | github.com/felixge/httpsnoop v1.0.4 // indirect | |
| 32 | 36 | github.com/go-logfmt/logfmt v0.6.0 // indirect | |
| 33 | 37 | github.com/go-logr/logr v1.4.2 // indirect | |
| @@ -37,9 +41,12 @@ require ( | |||
| 37 | 41 | github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect | |
| 38 | 42 | github.com/kr/pretty v0.3.1 // indirect | |
| 39 | 43 | github.com/lucasb-eyer/go-colorful v1.2.0 // indirect | |
| 44 | + | github.com/mattn/go-localereader v0.0.1 // indirect | |
| 40 | 45 | github.com/mattn/go-runewidth v0.0.16 // indirect | |
| 41 | 46 | github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d // indirect | |
| 42 | 47 | github.com/moby/docker-image-spec v1.3.1 // indirect | |
| 48 | + | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect | |
| 49 | + | github.com/muesli/cancelreader v0.2.2 // indirect | |
| 43 | 50 | github.com/muesli/termenv v0.15.2 // indirect | |
| 44 | 51 | github.com/ncruces/go-strftime v0.1.9 // indirect | |
| 45 | 52 | github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect | |
| @@ -51,7 +58,8 @@ require ( | |||
| 51 | 58 | go.opentelemetry.io/otel/metric v1.29.0 // indirect | |
| 52 | 59 | go.opentelemetry.io/otel/sdk v1.21.0 // indirect | |
| 53 | 60 | go.opentelemetry.io/otel/trace v1.29.0 // indirect | |
| 54 | - | golang.org/x/exp v0.0.0-20231108232855-2478ac86f678 // indirect | |
| 61 | + | golang.org/x/exp v0.0.0-20231108232855-2478ac86f678 // direct | |
| 62 | + | golang.org/x/sync v0.8.0 // indirect | |
| 55 | 63 | golang.org/x/term v0.23.0 // indirect | |
| 56 | 64 | google.golang.org/genproto/googleapis/api v0.0.0-20240528184218-531527333157 // indirect | |
| 57 | 65 | modernc.org/gc/v3 v3.0.0-20240801135723-a856999a2e4a // indirect | |
| @@ -5,6 +5,8 @@ go 1.23 | |||||
| 5 | require ( | 5 | require ( | ||
| 6 | github.com/AlecAivazis/survey/v2 v2.3.7 | 6 | github.com/AlecAivazis/survey/v2 v2.3.7 | ||
| 7 | github.com/PuerkitoBio/goquery v1.9.2 | 7 | github.com/PuerkitoBio/goquery v1.9.2 | ||
| 8 | + | github.com/charmbracelet/bubbles v0.20.0 | |||
| 9 | + | github.com/charmbracelet/lipgloss v0.13.0 | |||
| 8 | github.com/charmbracelet/log v0.4.0 | 10 | github.com/charmbracelet/log v0.4.0 | ||
| 9 | github.com/docker/docker v27.1.2+incompatible | 11 | github.com/docker/docker v27.1.2+incompatible | ||
| 10 | github.com/docker/go-connections v0.5.0 | 12 | github.com/docker/go-connections v0.5.0 | ||
| @@ -22,12 +24,14 @@ require ( | |||||
| 22 | 24 | ||||
| 23 | require ( | 25 | require ( | ||
| 24 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect | 26 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect | ||
| 25 | - | github.com/charmbracelet/lipgloss v0.13.0 // indirect | 27 | + | github.com/charmbracelet/bubbletea v1.1.0 // direct |
| 26 | github.com/charmbracelet/x/ansi v0.2.3 // indirect | 28 | github.com/charmbracelet/x/ansi v0.2.3 // indirect | ||
| 29 | + | github.com/charmbracelet/x/term v0.2.0 // indirect | |||
| 27 | github.com/containerd/log v0.1.0 // indirect | 30 | github.com/containerd/log v0.1.0 // indirect | ||
| 28 | github.com/creack/pty v1.1.18 // indirect | 31 | github.com/creack/pty v1.1.18 // indirect | ||
| 29 | github.com/distribution/reference v0.6.0 // indirect | 32 | github.com/distribution/reference v0.6.0 // indirect | ||
| 30 | github.com/dustin/go-humanize v1.0.1 // indirect | 33 | github.com/dustin/go-humanize v1.0.1 // indirect | ||
| 34 | + | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect | |||
| 31 | github.com/felixge/httpsnoop v1.0.4 // indirect | 35 | github.com/felixge/httpsnoop v1.0.4 // indirect | ||
| 32 | github.com/go-logfmt/logfmt v0.6.0 // indirect | 36 | github.com/go-logfmt/logfmt v0.6.0 // indirect | ||
| 33 | github.com/go-logr/logr v1.4.2 // indirect | 37 | github.com/go-logr/logr v1.4.2 // indirect | ||
| @@ -37,9 +41,12 @@ require ( | |||||
| 37 | github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect | 41 | github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect | ||
| 38 | github.com/kr/pretty v0.3.1 // indirect | 42 | github.com/kr/pretty v0.3.1 // indirect | ||
| 39 | github.com/lucasb-eyer/go-colorful v1.2.0 // indirect | 43 | github.com/lucasb-eyer/go-colorful v1.2.0 // indirect | ||
| 44 | + | github.com/mattn/go-localereader v0.0.1 // indirect | |||
| 40 | github.com/mattn/go-runewidth v0.0.16 // indirect | 45 | github.com/mattn/go-runewidth v0.0.16 // indirect | ||
| 41 | github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d // indirect | 46 | github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d // indirect | ||
| 42 | github.com/moby/docker-image-spec v1.3.1 // indirect | 47 | github.com/moby/docker-image-spec v1.3.1 // indirect | ||
| 48 | + | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect | |||
| 49 | + | github.com/muesli/cancelreader v0.2.2 // indirect | |||
| 43 | github.com/muesli/termenv v0.15.2 // indirect | 50 | github.com/muesli/termenv v0.15.2 // indirect | ||
| 44 | github.com/ncruces/go-strftime v0.1.9 // indirect | 51 | github.com/ncruces/go-strftime v0.1.9 // indirect | ||
| 45 | github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect | 52 | github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect | ||
| @@ -51,7 +58,8 @@ require ( | |||||
| 51 | go.opentelemetry.io/otel/metric v1.29.0 // indirect | 58 | go.opentelemetry.io/otel/metric v1.29.0 // indirect | ||
| 52 | go.opentelemetry.io/otel/sdk v1.21.0 // indirect | 59 | go.opentelemetry.io/otel/sdk v1.21.0 // indirect | ||
| 53 | go.opentelemetry.io/otel/trace v1.29.0 // indirect | 60 | go.opentelemetry.io/otel/trace v1.29.0 // indirect | ||
| 54 | - | golang.org/x/exp v0.0.0-20231108232855-2478ac86f678 // indirect | 61 | + | golang.org/x/exp v0.0.0-20231108232855-2478ac86f678 // direct |
| 62 | + | golang.org/x/sync v0.8.0 // indirect | |||
| 55 | golang.org/x/term v0.23.0 // indirect | 63 | golang.org/x/term v0.23.0 // indirect | ||
| 56 | google.golang.org/genproto/googleapis/api v0.0.0-20240528184218-531527333157 // indirect | 64 | google.golang.org/genproto/googleapis/api v0.0.0-20240528184218-531527333157 // indirect | ||
| 57 | modernc.org/gc/v3 v3.0.0-20240801135723-a856999a2e4a // indirect | 65 | modernc.org/gc/v3 v3.0.0-20240801135723-a856999a2e4a // indirect | ||
M go.sum +15 -0 generated file: collapsed
M internal/cli/auth/device_flow.go +1 -0
| @@ -1,3 +1,4 @@ | |||
| 1 | + | // internal/cli/auth/device_flow.go | |
| 1 | 2 | package auth | |
| 2 | 3 | ||
| 3 | 4 | import ( | |
| @@ -1,3 +1,4 @@ | |||||
| 1 | + | // internal/cli/auth/device_flow.go | |||
| 1 | package auth | 2 | package auth | ||
| 2 | 3 | ||||
| 3 | import ( | 4 | import ( | ||
M internal/cli/cmd/deploy.go +77 -23
| @@ -73,6 +73,39 @@ func NewDeployCommand(a *cli.App) *cobra.Command { | |||
| 73 | 73 | } | |
| 74 | 74 | ||
| 75 | 75 | func deployImage(a *cli.App, reader io.Reader, imageName, port, targetDomain string) error { | |
| 76 | + | // Check authentication early | |
| 77 | + | if err := handler.CheckAndRefreshAuth(a); err != nil { | |
| 78 | + | log.Error("Authentication check failed", "error", err) | |
| 79 | + | return err | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | log.Info("Deploying image", "image", imageName) | |
| 83 | + | // Check for conflicts first | |
| 84 | + | conflictResp, err := checkDeployConflict(a, targetDomain, port) | |
| 85 | + | if err != nil { | |
| 86 | + | return fmt.Errorf("conflict check failed: %w", err) | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | // Shorten the ContainerID to 12 characters | |
| 90 | + | shortID := conflictResp.ContainerID[:12] | |
| 91 | + | ||
| 92 | + | // If there's a conflict, handle it before proceeding | |
| 93 | + | if !conflictResp.Success && conflictResp.ContainerID != "" { | |
| 94 | + | log.Warn("Container already exists", | |
| 95 | + | "name", conflictResp.ContainerName, | |
| 96 | + | "id", shortID, | |
| 97 | + | "state", conflictResp.State, | |
| 98 | + | "uptime", conflictResp.RunningTime) | |
| 99 | + | ||
| 100 | + | if err := handler.HandleExistingContainer(a, conflictResp); err != nil { | |
| 101 | + | if strings.Contains(err.Error(), "cancelled by user") { | |
| 102 | + | return fmt.Errorf("deployment cancelled by user") | |
| 103 | + | } | |
| 104 | + | return fmt.Errorf("failed to handle existing container: %w", err) | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | } | |
| 108 | + | ||
| 76 | 109 | var buf bytes.Buffer | |
| 77 | 110 | size, err := io.Copy(&buf, reader) | |
| 78 | 111 | if err != nil { | |
| @@ -95,31 +128,21 @@ func deployImage(a *cli.App, reader io.Reader, imageName, port, targetDomain str | |||
| 95 | 128 | "Content-Type": {"application/octet-stream"}, | |
| 96 | 129 | } | |
| 97 | 130 | ||
| 98 | - | // Send a small chunk first to detect any conflicts | |
| 99 | 131 | chunkedClient := handler.NewChunkedClient(a) | |
| 100 | 132 | ctx, cancel := context.WithTimeout(context.Background(), 60*time.Minute) | |
| 101 | 133 | defer cancel() | |
| 102 | 134 | ||
| 103 | - | // Create a small test chunk | |
| 104 | - | testChunk := buf.Bytes()[:1024] // Just send first 1KB to check for conflicts | |
| 105 | - | testReader := bytes.NewReader(testChunk) | |
| 106 | - | ||
| 107 | - | resp, err := chunkedClient.SendFile(ctx, "/deploy", headers, testReader, int64(len(testChunk)), imageName) | |
| 135 | + | resp, err := chunkedClient.SendFileAsChunks(ctx, "/deploy/chunked", headers, imageReader, size, imageName) | |
| 108 | 136 | if err != nil { | |
| 109 | 137 | var deployErr *common.DeploymentError | |
| 110 | 138 | if errors.As(err, &deployErr) && deployErr.StatusCode == http.StatusConflict { | |
| 111 | 139 | var deployResponse common.DeployResponse | |
| 112 | 140 | if jsonErr := json.Unmarshal([]byte(deployErr.RawResponse), &deployResponse); jsonErr == nil { | |
| 113 | 141 | if deployResponse.ContainerID != "" { | |
| 114 | - | if err := handler.HandleExistingContainer(a, &deployResponse); err != nil { | |
| 115 | - | if strings.Contains(err.Error(), "cancelled by user") { | |
| 116 | - | return fmt.Errorf("deployment cancelled by user") | |
| 117 | - | } | |
| 118 | - | return fmt.Errorf("failed to handle existing container: %w", err) | |
| 119 | - | } | |
| 120 | - | // Now proceed with the full upload | |
| 142 | + | log.Warn("Container already exists") | |
| 143 | + | // Retry the deployment after handling the conflict | |
| 121 | 144 | imageReader.Seek(0, 0) // Reset reader to beginning | |
| 122 | - | resp, err = chunkedClient.SendFile(ctx, "/deploy", headers, imageReader, size, imageName) | |
| 145 | + | resp, err = chunkedClient.SendFileAsChunks(ctx, "/deploy", headers, imageReader, size, imageName) | |
| 123 | 146 | if err != nil { | |
| 124 | 147 | return fmt.Errorf("deployment failed after container removal: %w", err) | |
| 125 | 148 | } | |
| @@ -128,13 +151,10 @@ func deployImage(a *cli.App, reader io.Reader, imageName, port, targetDomain str | |||
| 128 | 151 | } else { | |
| 129 | 152 | return fmt.Errorf("deployment failed: %w", err) | |
| 130 | 153 | } | |
| 131 | - | } else { | |
| 132 | - | // No conflict detected, proceed with the rest of the upload | |
| 133 | - | imageReader.Seek(1024, 0) // Skip the first 1KB we already sent | |
| 134 | - | resp, err = chunkedClient.SendFile(ctx, "/deploy", headers, imageReader, size-1024, imageName) | |
| 135 | - | if err != nil { | |
| 136 | - | return fmt.Errorf("deployment failed during main upload: %w", err) | |
| 137 | - | } | |
| 154 | + | } | |
| 155 | + | ||
| 156 | + | if resp == nil { | |
| 157 | + | return fmt.Errorf("received nil response from server") | |
| 138 | 158 | } | |
| 139 | 159 | ||
| 140 | 160 | var deployResp common.DeployResponse | |
| @@ -147,7 +167,7 @@ func deployImage(a *cli.App, reader io.Reader, imageName, port, targetDomain str | |||
| 147 | 167 | ||
| 148 | 168 | func waitForDeployment(domain string, containerID string) error { | |
| 149 | 169 | client := &http.Client{Timeout: 10 * time.Second} | |
| 150 | - | maxRetries := 20 | |
| 170 | + | maxRetries := 10 | |
| 151 | 171 | retryInterval := time.Second | |
| 152 | 172 | ||
| 153 | 173 | var shortContainerID string | |
| @@ -200,9 +220,10 @@ func handleDeployError(a *cli.App, err error, imageName, port, targetDomain stri | |||
| 200 | 220 | var deployErr *common.DeploymentError | |
| 201 | 221 | if errors.As(err, &deployErr) { | |
| 202 | 222 | var deployResponse common.DeployResponse | |
| 223 | + | var conflictResp common.ConflictCheckResponse | |
| 203 | 224 | if jsonErr := json.Unmarshal([]byte(deployErr.RawResponse), &deployResponse); jsonErr == nil { | |
| 204 | 225 | if deployErr.StatusCode == http.StatusConflict && deployResponse.ContainerID != "" { | |
| 205 | - | if err := handler.HandleExistingContainer(a, &deployResponse); err != nil { | |
| 226 | + | if err := handler.HandleExistingContainer(a, &conflictResp); err != nil { | |
| 206 | 227 | if strings.Contains(err.Error(), "cancelled by user") { | |
| 207 | 228 | log.Warn("Deployment cancelled by user") | |
| 208 | 229 | return nil | |
| @@ -217,3 +238,36 @@ func handleDeployError(a *cli.App, err error, imageName, port, targetDomain stri | |||
| 217 | 238 | } | |
| 218 | 239 | return err | |
| 219 | 240 | } | |
| 241 | + | ||
| 242 | + | func checkDeployConflict(a *cli.App, targetDomain string, port string) (*common.ConflictCheckResponse, error) { | |
| 243 | + | ||
| 244 | + | // Create request to check-conflict endpoint with both domain and port parameters | |
| 245 | + | reqUrl := fmt.Sprintf("%s/api/deploy/check-conflict?domain=%s&port=%s", | |
| 246 | + | a.Config.Http.BackendURL, targetDomain, port) | |
| 247 | + | ||
| 248 | + | req, err := http.NewRequest("GET", reqUrl, nil) | |
| 249 | + | if err != nil { | |
| 250 | + | return nil, fmt.Errorf("failed to create conflict check request: %w", err) | |
| 251 | + | } | |
| 252 | + | ||
| 253 | + | // Set auth header | |
| 254 | + | req.Header.Set("Authorization", "Bearer "+a.Config.General.Token) | |
| 255 | + | ||
| 256 | + | // Send request | |
| 257 | + | client := &http.Client{ | |
| 258 | + | Timeout: 10 * time.Second, | |
| 259 | + | } | |
| 260 | + | resp, err := client.Do(req) | |
| 261 | + | if err != nil { | |
| 262 | + | return nil, fmt.Errorf("failed to send conflict check request: %w", err) | |
| 263 | + | } | |
| 264 | + | defer resp.Body.Close() | |
| 265 | + | ||
| 266 | + | // Parse response | |
| 267 | + | var conflictResp common.ConflictCheckResponse | |
| 268 | + | if err := json.NewDecoder(resp.Body).Decode(&conflictResp); err != nil { | |
| 269 | + | return nil, fmt.Errorf("failed to parse conflict check response: %w", err) | |
| 270 | + | } | |
| 271 | + | ||
| 272 | + | return &conflictResp, nil | |
| 273 | + | } | |
| @@ -73,6 +73,39 @@ func NewDeployCommand(a *cli.App) *cobra.Command { | |||||
| 73 | } | 73 | } | ||
| 74 | 74 | ||||
| 75 | func deployImage(a *cli.App, reader io.Reader, imageName, port, targetDomain string) error { | 75 | func deployImage(a *cli.App, reader io.Reader, imageName, port, targetDomain string) error { | ||
| 76 | + | // Check authentication early | |||
| 77 | + | if err := handler.CheckAndRefreshAuth(a); err != nil { | |||
| 78 | + | log.Error("Authentication check failed", "error", err) | |||
| 79 | + | return err | |||
| 80 | + | } | |||
| 81 | + | ||||
| 82 | + | log.Info("Deploying image", "image", imageName) | |||
| 83 | + | // Check for conflicts first | |||
| 84 | + | conflictResp, err := checkDeployConflict(a, targetDomain, port) | |||
| 85 | + | if err != nil { | |||
| 86 | + | return fmt.Errorf("conflict check failed: %w", err) | |||
| 87 | + | } | |||
| 88 | + | ||||
| 89 | + | // Shorten the ContainerID to 12 characters | |||
| 90 | + | shortID := conflictResp.ContainerID[:12] | |||
| 91 | + | ||||
| 92 | + | // If there's a conflict, handle it before proceeding | |||
| 93 | + | if !conflictResp.Success && conflictResp.ContainerID != "" { | |||
| 94 | + | log.Warn("Container already exists", | |||
| 95 | + | "name", conflictResp.ContainerName, | |||
| 96 | + | "id", shortID, | |||
| 97 | + | "state", conflictResp.State, | |||
| 98 | + | "uptime", conflictResp.RunningTime) | |||
| 99 | + | ||||
| 100 | + | if err := handler.HandleExistingContainer(a, conflictResp); err != nil { | |||
| 101 | + | if strings.Contains(err.Error(), "cancelled by user") { | |||
| 102 | + | return fmt.Errorf("deployment cancelled by user") | |||
| 103 | + | } | |||
| 104 | + | return fmt.Errorf("failed to handle existing container: %w", err) | |||
| 105 | + | } | |||
| 106 | + | ||||
| 107 | + | } | |||
| 108 | + | ||||
| 76 | var buf bytes.Buffer | 109 | var buf bytes.Buffer | ||
| 77 | size, err := io.Copy(&buf, reader) | 110 | size, err := io.Copy(&buf, reader) | ||
| 78 | if err != nil { | 111 | if err != nil { | ||
| @@ -95,31 +128,21 @@ func deployImage(a *cli.App, reader io.Reader, imageName, port, targetDomain str | |||||
| 95 | "Content-Type": {"application/octet-stream"}, | 128 | "Content-Type": {"application/octet-stream"}, | ||
| 96 | } | 129 | } | ||
| 97 | 130 | ||||
| 98 | - | // Send a small chunk first to detect any conflicts | |||
| 99 | chunkedClient := handler.NewChunkedClient(a) | 131 | chunkedClient := handler.NewChunkedClient(a) | ||
| 100 | ctx, cancel := context.WithTimeout(context.Background(), 60*time.Minute) | 132 | ctx, cancel := context.WithTimeout(context.Background(), 60*time.Minute) | ||
| 101 | defer cancel() | 133 | defer cancel() | ||
| 102 | 134 | ||||
| 103 | - | // Create a small test chunk | 135 | + | resp, err := chunkedClient.SendFileAsChunks(ctx, "/deploy/chunked", headers, imageReader, size, imageName) |
| 104 | - | testChunk := buf.Bytes()[:1024] // Just send first 1KB to check for conflicts | |||
| 105 | - | testReader := bytes.NewReader(testChunk) | |||
| 106 | - | ||||
| 107 | - | resp, err := chunkedClient.SendFile(ctx, "/deploy", headers, testReader, int64(len(testChunk)), imageName) | |||
| 108 | if err != nil { | 136 | if err != nil { | ||
| 109 | var deployErr *common.DeploymentError | 137 | var deployErr *common.DeploymentError | ||
| 110 | if errors.As(err, &deployErr) && deployErr.StatusCode == http.StatusConflict { | 138 | if errors.As(err, &deployErr) && deployErr.StatusCode == http.StatusConflict { | ||
| 111 | var deployResponse common.DeployResponse | 139 | var deployResponse common.DeployResponse | ||
| 112 | if jsonErr := json.Unmarshal([]byte(deployErr.RawResponse), &deployResponse); jsonErr == nil { | 140 | if jsonErr := json.Unmarshal([]byte(deployErr.RawResponse), &deployResponse); jsonErr == nil { | ||
| 113 | if deployResponse.ContainerID != "" { | 141 | if deployResponse.ContainerID != "" { | ||
| 114 | - | if err := handler.HandleExistingContainer(a, &deployResponse); err != nil { | 142 | + | log.Warn("Container already exists") |
| 115 | - | if strings.Contains(err.Error(), "cancelled by user") { | 143 | + | // Retry the deployment after handling the conflict |
| 116 | - | return fmt.Errorf("deployment cancelled by user") | |||
| 117 | - | } | |||
| 118 | - | return fmt.Errorf("failed to handle existing container: %w", err) | |||
| 119 | - | } | |||
| 120 | - | // Now proceed with the full upload | |||
| 121 | imageReader.Seek(0, 0) // Reset reader to beginning | 144 | imageReader.Seek(0, 0) // Reset reader to beginning | ||
| 122 | - | resp, err = chunkedClient.SendFile(ctx, "/deploy", headers, imageReader, size, imageName) | 145 | + | resp, err = chunkedClient.SendFileAsChunks(ctx, "/deploy", headers, imageReader, size, imageName) |
| 123 | if err != nil { | 146 | if err != nil { | ||
| 124 | return fmt.Errorf("deployment failed after container removal: %w", err) | 147 | return fmt.Errorf("deployment failed after container removal: %w", err) | ||
| 125 | } | 148 | } | ||
| @@ -128,13 +151,10 @@ func deployImage(a *cli.App, reader io.Reader, imageName, port, targetDomain str | |||||
| 128 | } else { | 151 | } else { | ||
| 129 | return fmt.Errorf("deployment failed: %w", err) | 152 | return fmt.Errorf("deployment failed: %w", err) | ||
| 130 | } | 153 | } | ||
| 131 | - | } else { | 154 | + | } |
| 132 | - | // No conflict detected, proceed with the rest of the upload | 155 | + | |
| 133 | - | imageReader.Seek(1024, 0) // Skip the first 1KB we already sent | 156 | + | if resp == nil { |
| 134 | - | resp, err = chunkedClient.SendFile(ctx, "/deploy", headers, imageReader, size-1024, imageName) | 157 | + | return fmt.Errorf("received nil response from server") |
| 135 | - | if err != nil { | |||
| 136 | - | return fmt.Errorf("deployment failed during main upload: %w", err) | |||
| 137 | - | } | |||
| 138 | } | 158 | } | ||
| 139 | 159 | ||||
| 140 | var deployResp common.DeployResponse | 160 | var deployResp common.DeployResponse | ||
| @@ -147,7 +167,7 @@ func deployImage(a *cli.App, reader io.Reader, imageName, port, targetDomain str | |||||
| 147 | 167 | ||||
| 148 | func waitForDeployment(domain string, containerID string) error { | 168 | func waitForDeployment(domain string, containerID string) error { | ||
| 149 | client := &http.Client{Timeout: 10 * time.Second} | 169 | client := &http.Client{Timeout: 10 * time.Second} | ||
| 150 | - | maxRetries := 20 | 170 | + | maxRetries := 10 |
| 151 | retryInterval := time.Second | 171 | retryInterval := time.Second | ||
| 152 | 172 | ||||
| 153 | var shortContainerID string | 173 | var shortContainerID string | ||
| @@ -200,9 +220,10 @@ func handleDeployError(a *cli.App, err error, imageName, port, targetDomain stri | |||||
| 200 | var deployErr *common.DeploymentError | 220 | var deployErr *common.DeploymentError | ||
| 201 | if errors.As(err, &deployErr) { | 221 | if errors.As(err, &deployErr) { | ||
| 202 | var deployResponse common.DeployResponse | 222 | var deployResponse common.DeployResponse | ||
| 223 | + | var conflictResp common.ConflictCheckResponse | |||
| 203 | if jsonErr := json.Unmarshal([]byte(deployErr.RawResponse), &deployResponse); jsonErr == nil { | 224 | if jsonErr := json.Unmarshal([]byte(deployErr.RawResponse), &deployResponse); jsonErr == nil { | ||
| 204 | if deployErr.StatusCode == http.StatusConflict && deployResponse.ContainerID != "" { | 225 | if deployErr.StatusCode == http.StatusConflict && deployResponse.ContainerID != "" { | ||
| 205 | - | if err := handler.HandleExistingContainer(a, &deployResponse); err != nil { | 226 | + | if err := handler.HandleExistingContainer(a, &conflictResp); err != nil { |
| 206 | if strings.Contains(err.Error(), "cancelled by user") { | 227 | if strings.Contains(err.Error(), "cancelled by user") { | ||
| 207 | log.Warn("Deployment cancelled by user") | 228 | log.Warn("Deployment cancelled by user") | ||
| 208 | return nil | 229 | return nil | ||
| @@ -217,3 +238,36 @@ func handleDeployError(a *cli.App, err error, imageName, port, targetDomain stri | |||||
| 217 | } | 238 | } | ||
| 218 | return err | 239 | return err | ||
| 219 | } | 240 | } | ||
| 241 | + | ||||
| 242 | + | func checkDeployConflict(a *cli.App, targetDomain string, port string) (*common.ConflictCheckResponse, error) { | |||
| 243 | + | ||||
| 244 | + | // Create request to check-conflict endpoint with both domain and port parameters | |||
| 245 | + | reqUrl := fmt.Sprintf("%s/api/deploy/check-conflict?domain=%s&port=%s", | |||
| 246 | + | a.Config.Http.BackendURL, targetDomain, port) | |||
| 247 | + | ||||
| 248 | + | req, err := http.NewRequest("GET", reqUrl, nil) | |||
| 249 | + | if err != nil { | |||
| 250 | + | return nil, fmt.Errorf("failed to create conflict check request: %w", err) | |||
| 251 | + | } | |||
| 252 | + | ||||
| 253 | + | // Set auth header | |||
| 254 | + | req.Header.Set("Authorization", "Bearer "+a.Config.General.Token) | |||
| 255 | + | ||||
| 256 | + | // Send request | |||
| 257 | + | client := &http.Client{ | |||
| 258 | + | Timeout: 10 * time.Second, | |||
| 259 | + | } | |||
| 260 | + | resp, err := client.Do(req) | |||
| 261 | + | if err != nil { | |||
| 262 | + | return nil, fmt.Errorf("failed to send conflict check request: %w", err) | |||
| 263 | + | } | |||
| 264 | + | defer resp.Body.Close() | |||
| 265 | + | ||||
| 266 | + | // Parse response | |||
| 267 | + | var conflictResp common.ConflictCheckResponse | |||
| 268 | + | if err := json.NewDecoder(resp.Body).Decode(&conflictResp); err != nil { | |||
| 269 | + | return nil, fmt.Errorf("failed to parse conflict check response: %w", err) | |||
| 270 | + | } | |||
| 271 | + | ||||
| 272 | + | return &conflictResp, nil | |||
| 273 | + | } | |||
M internal/cli/cmd/push.go +66 -15
| @@ -2,15 +2,16 @@ package cmd | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | - | "errors" | |
| 5 | + | "encoding/json" | |
| 6 | 6 | "fmt" | |
| 7 | 7 | "net/http" | |
| 8 | 8 | "os" | |
| 9 | - | "time" | |
| 9 | + | "strings" | |
| 10 | 10 | ||
| 11 | 11 | "github.com/bnema/gordon/internal/cli" | |
| 12 | 12 | "github.com/bnema/gordon/internal/cli/handler" | |
| 13 | 13 | "github.com/bnema/gordon/internal/common" | |
| 14 | + | "github.com/bnema/gordon/pkg/docker" | |
| 14 | 15 | "github.com/charmbracelet/log" | |
| 15 | 16 | "github.com/spf13/cobra" | |
| 16 | 17 | ) | |
| @@ -50,6 +51,12 @@ func NewPushCommand(a *cli.App) *cobra.Command { | |||
| 50 | 51 | } | |
| 51 | 52 | ||
| 52 | 53 | func pushImage(a *cli.App, imageName string) error { | |
| 54 | + | // Check authentication early | |
| 55 | + | if err := handler.CheckAndRefreshAuth(a); err != nil { | |
| 56 | + | log.Error("Authentication check failed", "error", err) | |
| 57 | + | return err | |
| 58 | + | } | |
| 59 | + | ||
| 53 | 60 | if err := handler.ValidateImageName(imageName); err != nil { | |
| 54 | 61 | return fmt.Errorf("invalid image name: %w", err) | |
| 55 | 62 | } | |
| @@ -62,28 +69,72 @@ func pushImage(a *cli.App, imageName string) error { | |||
| 62 | 69 | } | |
| 63 | 70 | defer reader.Close() | |
| 64 | 71 | ||
| 72 | + | _, err = docker.GetImageIDByName(imageName) | |
| 73 | + | if err != nil { | |
| 74 | + | return fmt.Errorf("failed to get image ID: %w", err) | |
| 75 | + | } | |
| 76 | + | ||
| 65 | 77 | sizeInMB := float64(actualSize) / 1024 / 1024 | |
| 66 | 78 | log.Info("Image exported successfully", | |
| 67 | 79 | "image", imageName, | |
| 68 | 80 | "size", fmt.Sprintf("%.2fMB", sizeInMB)) | |
| 69 | 81 | ||
| 70 | - | headers := http.Header{ | |
| 71 | - | "X-Image-Name": {imageName}, | |
| 72 | - | "Content-Type": {"application/octet-stream"}, | |
| 73 | - | } | |
| 82 | + | var resp *handler.Response | |
| 74 | 83 | ||
| 75 | - | chunkedClient := handler.NewChunkedClient(a) | |
| 84 | + | // Use chunked endpoint if image is larger than 10MB | |
| 85 | + | if sizeInMB > 10 { | |
| 86 | + | chunkedClient := handler.NewChunkedClient(a) | |
| 87 | + | if chunkedClient == nil { | |
| 88 | + | return fmt.Errorf("failed to initialize chunked client - authentication issue") | |
| 89 | + | } | |
| 76 | 90 | ||
| 77 | - | ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute) | |
| 78 | - | defer cancel() | |
| 91 | + | ctx := context.Background() | |
| 92 | + | headers := make(http.Header) | |
| 93 | + | // Add any necessary headers here | |
| 94 | + | headers.Set("Content-Type", "application/octet-stream") | |
| 79 | 95 | ||
| 80 | - | _, err = chunkedClient.SendFile(ctx, "/push", headers, reader, actualSize, imageName) | |
| 81 | - | if err != nil { | |
| 82 | - | var pushErr *common.DeploymentError | |
| 83 | - | if errors.As(err, &pushErr) { | |
| 84 | - | return fmt.Errorf(pushErr.Message) | |
| 96 | + | var err error | |
| 97 | + | resp, err = chunkedClient.SendFileAsChunks(ctx, "/push/chunked", headers, reader, actualSize, imageName) | |
| 98 | + | if err != nil { | |
| 99 | + | return fmt.Errorf("chunked transfer failed: %w", err) | |
| 100 | + | } | |
| 101 | + | } else { | |
| 102 | + | // Use regular push endpoint for smaller images | |
| 103 | + | reqPayload := common.RequestPayload{ | |
| 104 | + | Type: "push", | |
| 105 | + | Payload: common.PushPayload{ | |
| 106 | + | ImageName: imageName, | |
| 107 | + | Data: reader, | |
| 108 | + | }, | |
| 109 | + | } | |
| 110 | + | var err error | |
| 111 | + | resp, err = handler.SendHTTPRequest(a, &reqPayload, "POST", "/push") | |
| 112 | + | if err != nil { | |
| 113 | + | return fmt.Errorf("regular push failed: %w", err) | |
| 85 | 114 | } | |
| 86 | - | return fmt.Errorf("chunked transfer failed: %w", err) | |
| 115 | + | } | |
| 116 | + | ||
| 117 | + | if resp == nil { | |
| 118 | + | return fmt.Errorf("received nil response from server") | |
| 119 | + | } | |
| 120 | + | ||
| 121 | + | var pushResponse common.PushResponse | |
| 122 | + | if err := json.Unmarshal(resp.Body, &pushResponse); err != nil { | |
| 123 | + | log.Error("Error parsing response", "error", err) | |
| 124 | + | return fmt.Errorf("failed to parse response: %w", err) | |
| 125 | + | } | |
| 126 | + | ||
| 127 | + | if !pushResponse.Success { | |
| 128 | + | log.Error("Push failed", "resp", pushResponse.Message) | |
| 129 | + | return fmt.Errorf(pushResponse.Message) | |
| 130 | + | } | |
| 131 | + | ||
| 132 | + | // Remove the double quotes from the message | |
| 133 | + | message := strings.Trim(pushResponse.Message, "\"") | |
| 134 | + | log.Info(message) | |
| 135 | + | ||
| 136 | + | if pushResponse.CreateContainerURL != "" { | |
| 137 | + | log.Info("Container creation available", "url", pushResponse.CreateContainerURL) | |
| 87 | 138 | } | |
| 88 | 139 | ||
| 89 | 140 | return nil | |
| @@ -2,15 +2,16 @@ package cmd | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | - | "errors" | 5 | + | "encoding/json" |
| 6 | "fmt" | 6 | "fmt" | ||
| 7 | "net/http" | 7 | "net/http" | ||
| 8 | "os" | 8 | "os" | ||
| 9 | - | "time" | 9 | + | "strings" |
| 10 | 10 | ||||
| 11 | "github.com/bnema/gordon/internal/cli" | 11 | "github.com/bnema/gordon/internal/cli" | ||
| 12 | "github.com/bnema/gordon/internal/cli/handler" | 12 | "github.com/bnema/gordon/internal/cli/handler" | ||
| 13 | "github.com/bnema/gordon/internal/common" | 13 | "github.com/bnema/gordon/internal/common" | ||
| 14 | + | "github.com/bnema/gordon/pkg/docker" | |||
| 14 | "github.com/charmbracelet/log" | 15 | "github.com/charmbracelet/log" | ||
| 15 | "github.com/spf13/cobra" | 16 | "github.com/spf13/cobra" | ||
| 16 | ) | 17 | ) | ||
| @@ -50,6 +51,12 @@ func NewPushCommand(a *cli.App) *cobra.Command { | |||||
| 50 | } | 51 | } | ||
| 51 | 52 | ||||
| 52 | func pushImage(a *cli.App, imageName string) error { | 53 | func pushImage(a *cli.App, imageName string) error { | ||
| 54 | + | // Check authentication early | |||
| 55 | + | if err := handler.CheckAndRefreshAuth(a); err != nil { | |||
| 56 | + | log.Error("Authentication check failed", "error", err) | |||
| 57 | + | return err | |||
| 58 | + | } | |||
| 59 | + | ||||
| 53 | if err := handler.ValidateImageName(imageName); err != nil { | 60 | if err := handler.ValidateImageName(imageName); err != nil { | ||
| 54 | return fmt.Errorf("invalid image name: %w", err) | 61 | return fmt.Errorf("invalid image name: %w", err) | ||
| 55 | } | 62 | } | ||
| @@ -62,28 +69,72 @@ func pushImage(a *cli.App, imageName string) error { | |||||
| 62 | } | 69 | } | ||
| 63 | defer reader.Close() | 70 | defer reader.Close() | ||
| 64 | 71 | ||||
| 72 | + | _, err = docker.GetImageIDByName(imageName) | |||
| 73 | + | if err != nil { | |||
| 74 | + | return fmt.Errorf("failed to get image ID: %w", err) | |||
| 75 | + | } | |||
| 76 | + | ||||
| 65 | sizeInMB := float64(actualSize) / 1024 / 1024 | 77 | sizeInMB := float64(actualSize) / 1024 / 1024 | ||
| 66 | log.Info("Image exported successfully", | 78 | log.Info("Image exported successfully", | ||
| 67 | "image", imageName, | 79 | "image", imageName, | ||
| 68 | "size", fmt.Sprintf("%.2fMB", sizeInMB)) | 80 | "size", fmt.Sprintf("%.2fMB", sizeInMB)) | ||
| 69 | 81 | ||||
| 70 | - | headers := http.Header{ | 82 | + | var resp *handler.Response |
| 71 | - | "X-Image-Name": {imageName}, | |||
| 72 | - | "Content-Type": {"application/octet-stream"}, | |||
| 73 | - | } | |||
| 74 | 83 | ||||
| 75 | - | chunkedClient := handler.NewChunkedClient(a) | 84 | + | // Use chunked endpoint if image is larger than 10MB |
| 85 | + | if sizeInMB > 10 { | |||
| 86 | + | chunkedClient := handler.NewChunkedClient(a) | |||
| 87 | + | if chunkedClient == nil { | |||
| 88 | + | return fmt.Errorf("failed to initialize chunked client - authentication issue") | |||
| 89 | + | } | |||
| 76 | 90 | ||||
| 77 | - | ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute) | 91 | + | ctx := context.Background() |
| 78 | - | defer cancel() | 92 | + | headers := make(http.Header) |
| 93 | + | // Add any necessary headers here | |||
| 94 | + | headers.Set("Content-Type", "application/octet-stream") | |||
| 79 | 95 | ||||
| 80 | - | _, err = chunkedClient.SendFile(ctx, "/push", headers, reader, actualSize, imageName) | 96 | + | var err error |
| 81 | - | if err != nil { | 97 | + | resp, err = chunkedClient.SendFileAsChunks(ctx, "/push/chunked", headers, reader, actualSize, imageName) |
| 82 | - | var pushErr *common.DeploymentError | 98 | + | if err != nil { |
| 83 | - | if errors.As(err, &pushErr) { | 99 | + | return fmt.Errorf("chunked transfer failed: %w", err) |
| 84 | - | return fmt.Errorf(pushErr.Message) | 100 | + | } |
| 101 | + | } else { | |||
| 102 | + | // Use regular push endpoint for smaller images | |||
| 103 | + | reqPayload := common.RequestPayload{ | |||
| 104 | + | Type: "push", | |||
| 105 | + | Payload: common.PushPayload{ | |||
| 106 | + | ImageName: imageName, | |||
| 107 | + | Data: reader, | |||
| 108 | + | }, | |||
| 109 | + | } | |||
| 110 | + | var err error | |||
| 111 | + | resp, err = handler.SendHTTPRequest(a, &reqPayload, "POST", "/push") | |||
| 112 | + | if err != nil { | |||
| 113 | + | return fmt.Errorf("regular push failed: %w", err) | |||
| 85 | } | 114 | } | ||
| 86 | - | return fmt.Errorf("chunked transfer failed: %w", err) | 115 | + | } |
| 116 | + | ||||
| 117 | + | if resp == nil { | |||
| 118 | + | return fmt.Errorf("received nil response from server") | |||
| 119 | + | } | |||
| 120 | + | ||||
| 121 | + | var pushResponse common.PushResponse | |||
| 122 | + | if err := json.Unmarshal(resp.Body, &pushResponse); err != nil { | |||
| 123 | + | log.Error("Error parsing response", "error", err) | |||
| 124 | + | return fmt.Errorf("failed to parse response: %w", err) | |||
| 125 | + | } | |||
| 126 | + | ||||
| 127 | + | if !pushResponse.Success { | |||
| 128 | + | log.Error("Push failed", "resp", pushResponse.Message) | |||
| 129 | + | return fmt.Errorf(pushResponse.Message) | |||
| 130 | + | } | |||
| 131 | + | ||||
| 132 | + | // Remove the double quotes from the message | |||
| 133 | + | message := strings.Trim(pushResponse.Message, "\"") | |||
| 134 | + | log.Info(message) | |||
| 135 | + | ||||
| 136 | + | if pushResponse.CreateContainerURL != "" { | |||
| 137 | + | log.Info("Container creation available", "url", pushResponse.CreateContainerURL) | |||
| 87 | } | 138 | } | ||
| 88 | 139 | ||||
| 89 | return nil | 140 | return nil | ||
A internal/cli/handler/auth.go +77 -0
| @@ -0,0 +1,77 @@ | |||
| 1 | + | package handler | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "fmt" | |
| 5 | + | "time" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/gordon/internal/cli" | |
| 8 | + | "github.com/bnema/gordon/internal/cli/auth" | |
| 9 | + | "github.com/bnema/gordon/internal/common" | |
| 10 | + | "github.com/charmbracelet/log" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | func CheckAndRefreshAuth(a *cli.App) error { | |
| 14 | + | if a.Config.General.Token == "" { | |
| 15 | + | log.Info("No authentication token found. Initiating authentication...") | |
| 16 | + | return ReAuthenticate(a) | |
| 17 | + | } | |
| 18 | + | ||
| 19 | + | resp, err := SendHTTPRequest(a, &common.RequestPayload{ | |
| 20 | + | Type: "ping", | |
| 21 | + | Payload: common.PingPayload{ | |
| 22 | + | Message: "ping", | |
| 23 | + | }, | |
| 24 | + | }, "GET", "/ping") | |
| 25 | + | ||
| 26 | + | if err != nil { | |
| 27 | + | log.Debug("Token validation request failed", "error", err) | |
| 28 | + | return ReAuthenticate(a) | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | // If we get a 200 status code, and the resp contains message = pong, we're good | |
| 32 | + | if resp.StatusCode != 200 { | |
| 33 | + | log.Debug("Token validation failed", | |
| 34 | + | "status_code", resp.StatusCode, | |
| 35 | + | "response", resp.Body) | |
| 36 | + | return ReAuthenticate(a) | |
| 37 | + | ||
| 38 | + | } | |
| 39 | + | ||
| 40 | + | return nil | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | func ReAuthenticate(a *cli.App) error { | |
| 44 | + | fmt.Println("Re-authenticating...") | |
| 45 | + | ||
| 46 | + | // Small delay for visual clarity | |
| 47 | + | time.Sleep(100 * time.Millisecond) | |
| 48 | + | ||
| 49 | + | err := auth.DeviceFlowAuth(a) | |
| 50 | + | if err != nil { | |
| 51 | + | return fmt.Errorf("device flow authentication failed: %w", err) | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | // After successful re-authentication, get the new token | |
| 55 | + | newToken, err := a.Config.GetToken() | |
| 56 | + | if err != nil { | |
| 57 | + | return fmt.Errorf("failed to get new token: %w", err) | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | // Update the token in the app config | |
| 61 | + | a.Config.General.Token = newToken | |
| 62 | + | ||
| 63 | + | // Save the new token to config file | |
| 64 | + | err = a.Config.SaveConfig() | |
| 65 | + | if err != nil { | |
| 66 | + | return fmt.Errorf("failed to save new token to config: %w", err) | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | // Clear any remaining auth UI elements | |
| 70 | + | fmt.Print("\033[2K\r") | |
| 71 | + | fmt.Println("Re-authentication successful.") | |
| 72 | + | ||
| 73 | + | // Add delay before resuming upload display | |
| 74 | + | time.Sleep(500 * time.Millisecond) | |
| 75 | + | ||
| 76 | + | return nil | |
| 77 | + | } | |
| @@ -0,0 +1,77 @@ | |||||
| 1 | + | package handler | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "fmt" | |||
| 5 | + | "time" | |||
| 6 | + | ||||
| 7 | + | "github.com/bnema/gordon/internal/cli" | |||
| 8 | + | "github.com/bnema/gordon/internal/cli/auth" | |||
| 9 | + | "github.com/bnema/gordon/internal/common" | |||
| 10 | + | "github.com/charmbracelet/log" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | func CheckAndRefreshAuth(a *cli.App) error { | |||
| 14 | + | if a.Config.General.Token == "" { | |||
| 15 | + | log.Info("No authentication token found. Initiating authentication...") | |||
| 16 | + | return ReAuthenticate(a) | |||
| 17 | + | } | |||
| 18 | + | ||||
| 19 | + | resp, err := SendHTTPRequest(a, &common.RequestPayload{ | |||
| 20 | + | Type: "ping", | |||
| 21 | + | Payload: common.PingPayload{ | |||
| 22 | + | Message: "ping", | |||
| 23 | + | }, | |||
| 24 | + | }, "GET", "/ping") | |||
| 25 | + | ||||
| 26 | + | if err != nil { | |||
| 27 | + | log.Debug("Token validation request failed", "error", err) | |||
| 28 | + | return ReAuthenticate(a) | |||
| 29 | + | } | |||
| 30 | + | ||||
| 31 | + | // If we get a 200 status code, and the resp contains message = pong, we're good | |||
| 32 | + | if resp.StatusCode != 200 { | |||
| 33 | + | log.Debug("Token validation failed", | |||
| 34 | + | "status_code", resp.StatusCode, | |||
| 35 | + | "response", resp.Body) | |||
| 36 | + | return ReAuthenticate(a) | |||
| 37 | + | ||||
| 38 | + | } | |||
| 39 | + | ||||
| 40 | + | return nil | |||
| 41 | + | } | |||
| 42 | + | ||||
| 43 | + | func ReAuthenticate(a *cli.App) error { | |||
| 44 | + | fmt.Println("Re-authenticating...") | |||
| 45 | + | ||||
| 46 | + | // Small delay for visual clarity | |||
| 47 | + | time.Sleep(100 * time.Millisecond) | |||
| 48 | + | ||||
| 49 | + | err := auth.DeviceFlowAuth(a) | |||
| 50 | + | if err != nil { | |||
| 51 | + | return fmt.Errorf("device flow authentication failed: %w", err) | |||
| 52 | + | } | |||
| 53 | + | ||||
| 54 | + | // After successful re-authentication, get the new token | |||
| 55 | + | newToken, err := a.Config.GetToken() | |||
| 56 | + | if err != nil { | |||
| 57 | + | return fmt.Errorf("failed to get new token: %w", err) | |||
| 58 | + | } | |||
| 59 | + | ||||
| 60 | + | // Update the token in the app config | |||
| 61 | + | a.Config.General.Token = newToken | |||
| 62 | + | ||||
| 63 | + | // Save the new token to config file | |||
| 64 | + | err = a.Config.SaveConfig() | |||
| 65 | + | if err != nil { | |||
| 66 | + | return fmt.Errorf("failed to save new token to config: %w", err) | |||
| 67 | + | } | |||
| 68 | + | ||||
| 69 | + | // Clear any remaining auth UI elements | |||
| 70 | + | fmt.Print("\033[2K\r") | |||
| 71 | + | fmt.Println("Re-authentication successful.") | |||
| 72 | + | ||||
| 73 | + | // Add delay before resuming upload display | |||
| 74 | + | time.Sleep(500 * time.Millisecond) | |||
| 75 | + | ||||
| 76 | + | return nil | |||
| 77 | + | } | |||
M internal/cli/handler/chunked_transfer.go +447 -151
| @@ -1,45 +1,232 @@ | |||
| 1 | - | // internal/cli/handler/chunked_client.go | |
| 1 | + | // internal/cli/handler/progress_display.go | |
| 2 | + | ||
| 2 | 3 | package handler | |
| 3 | 4 | ||
| 4 | 5 | import ( | |
| 5 | 6 | "bytes" | |
| 6 | 7 | "context" | |
| 8 | + | "crypto/sha256" | |
| 7 | 9 | "encoding/json" | |
| 10 | + | "errors" | |
| 8 | 11 | "fmt" | |
| 9 | 12 | "io" | |
| 13 | + | "net" | |
| 10 | 14 | "net/http" | |
| 15 | + | "os" | |
| 16 | + | "os/signal" | |
| 11 | 17 | "strings" | |
| 18 | + | "sync" | |
| 19 | + | "syscall" | |
| 12 | 20 | "time" | |
| 13 | 21 | ||
| 14 | 22 | "github.com/bnema/gordon/internal/cli" | |
| 15 | 23 | "github.com/bnema/gordon/internal/common" | |
| 24 | + | "github.com/charmbracelet/bubbles/spinner" | |
| 25 | + | tea "github.com/charmbracelet/bubbletea" | |
| 26 | + | "github.com/charmbracelet/lipgloss" | |
| 16 | 27 | "github.com/charmbracelet/log" | |
| 17 | 28 | "github.com/google/uuid" | |
| 29 | + | "golang.org/x/exp/rand" | |
| 18 | 30 | ) | |
| 19 | 31 | ||
| 32 | + | // UploadProgress represents the current state of the upload | |
| 33 | + | type UploadProgress struct { | |
| 34 | + | Completed int | |
| 35 | + | Total int | |
| 36 | + | Percentage float64 | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | // ProgressDisplay manages the upload progress display | |
| 40 | + | type ProgressDisplay struct { | |
| 41 | + | mu sync.Mutex | |
| 42 | + | model *uploadModel | |
| 43 | + | program *tea.Program | |
| 44 | + | updateChan chan UploadProgress | |
| 45 | + | done chan struct{} | |
| 46 | + | interrupted bool | |
| 47 | + | paused bool | |
| 48 | + | pauseChan chan bool | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | type uploadModel struct { | |
| 52 | + | spinner spinner.Model | |
| 53 | + | completed int | |
| 54 | + | total int | |
| 55 | + | percentage float64 | |
| 56 | + | done bool | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | // NewProgressDisplay creates a new progress display | |
| 60 | + | func NewProgressDisplay() *ProgressDisplay { | |
| 61 | + | return &ProgressDisplay{ | |
| 62 | + | updateChan: make(chan UploadProgress), | |
| 63 | + | done: make(chan struct{}), | |
| 64 | + | pauseChan: make(chan bool), | |
| 65 | + | } | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | func (pd *ProgressDisplay) Start() { | |
| 69 | + | pd.mu.Lock() | |
| 70 | + | defer pd.mu.Unlock() | |
| 71 | + | ||
| 72 | + | pd.model = &uploadModel{ | |
| 73 | + | spinner: spinner.New( | |
| 74 | + | spinner.WithSpinner(spinner.MiniDot), | |
| 75 | + | spinner.WithStyle(lipgloss.NewStyle().Foreground(lipgloss.Color("69"))), | |
| 76 | + | ), | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | pd.program = tea.NewProgram(pd.model) | |
| 80 | + | ||
| 81 | + | // Run the UI in a goroutine | |
| 82 | + | go func() { | |
| 83 | + | if err := pd.program.Start(); err != nil { | |
| 84 | + | fmt.Printf("Error running progress display: %v\n", err) | |
| 85 | + | } | |
| 86 | + | }() | |
| 87 | + | ||
| 88 | + | // Handle updates in a separate goroutine | |
| 89 | + | go pd.handleUpdates() | |
| 90 | + | } | |
| 91 | + | ||
| 92 | + | func (pd *ProgressDisplay) Pause() { | |
| 93 | + | pd.mu.Lock() | |
| 94 | + | defer pd.mu.Unlock() | |
| 95 | + | pd.paused = true | |
| 96 | + | pd.Clear() // Clear the current display | |
| 97 | + | } | |
| 98 | + | ||
| 99 | + | func (pd *ProgressDisplay) Resume() { | |
| 100 | + | pd.mu.Lock() | |
| 101 | + | defer pd.mu.Unlock() | |
| 102 | + | pd.paused = false | |
| 103 | + | } | |
| 104 | + | ||
| 105 | + | func (pd *ProgressDisplay) handleUpdates() { | |
| 106 | + | for { | |
| 107 | + | select { | |
| 108 | + | case progress := <-pd.updateChan: | |
| 109 | + | pd.mu.Lock() | |
| 110 | + | if !pd.paused { | |
| 111 | + | pd.program.Send(progressMsg{ | |
| 112 | + | completed: progress.Completed, | |
| 113 | + | total: progress.Total, | |
| 114 | + | percentage: progress.Percentage, | |
| 115 | + | }) | |
| 116 | + | } | |
| 117 | + | pd.mu.Unlock() | |
| 118 | + | case <-pd.done: | |
| 119 | + | pd.program.Send(doneMsg{}) | |
| 120 | + | return | |
| 121 | + | } | |
| 122 | + | } | |
| 123 | + | } | |
| 124 | + | ||
| 125 | + | func (pd *ProgressDisplay) Update(completed, total int, percentage float64) { | |
| 126 | + | pd.updateChan <- UploadProgress{ | |
| 127 | + | Completed: completed, | |
| 128 | + | Total: total, | |
| 129 | + | Percentage: percentage, | |
| 130 | + | } | |
| 131 | + | } | |
| 132 | + | ||
| 133 | + | func (pd *ProgressDisplay) Stop() { | |
| 134 | + | close(pd.done) | |
| 135 | + | } | |
| 136 | + | ||
| 137 | + | func (pd *ProgressDisplay) Clear() { | |
| 138 | + | pd.mu.Lock() | |
| 139 | + | defer pd.mu.Unlock() | |
| 140 | + | ||
| 141 | + | // Send a special clear message | |
| 142 | + | pd.program.Send(clearMsg{}) | |
| 143 | + | // Wait a brief moment for the clear to take effect | |
| 144 | + | time.Sleep(50 * time.Millisecond) | |
| 145 | + | } | |
| 146 | + | ||
| 147 | + | type progressMsg struct { | |
| 148 | + | completed int | |
| 149 | + | total int | |
| 150 | + | percentage float64 | |
| 151 | + | } | |
| 152 | + | ||
| 153 | + | type doneMsg struct{} | |
| 154 | + | ||
| 155 | + | type clearMsg struct{} | |
| 156 | + | ||
| 157 | + | func (m *uploadModel) Init() tea.Cmd { | |
| 158 | + | return m.spinner.Tick | |
| 159 | + | } | |
| 160 | + | ||
| 161 | + | func (m *uploadModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { | |
| 162 | + | switch msg := msg.(type) { | |
| 163 | + | case tea.KeyMsg: | |
| 164 | + | if msg.Type == tea.KeyCtrlC { | |
| 165 | + | return m, tea.Quit | |
| 166 | + | } | |
| 167 | + | case spinner.TickMsg: | |
| 168 | + | var cmd tea.Cmd | |
| 169 | + | m.spinner, cmd = m.spinner.Update(msg) | |
| 170 | + | return m, cmd | |
| 171 | + | case progressMsg: | |
| 172 | + | m.completed = msg.completed | |
| 173 | + | m.total = msg.total | |
| 174 | + | m.percentage = msg.percentage | |
| 175 | + | return m, m.spinner.Tick | |
| 176 | + | case doneMsg: | |
| 177 | + | m.done = true | |
| 178 | + | return m, tea.Quit | |
| 179 | + | case clearMsg: | |
| 180 | + | m.done = true | |
| 181 | + | return m, tea.Quit | |
| 182 | + | } | |
| 183 | + | return m, nil | |
| 184 | + | } | |
| 185 | + | ||
| 186 | + | func (m *uploadModel) View() string { | |
| 187 | + | if m.done { | |
| 188 | + | return "" | |
| 189 | + | } | |
| 190 | + | ||
| 191 | + | return fmt.Sprintf("\r%s Uploading image... %d/%d chunks (%.2f%%)", | |
| 192 | + | m.spinner.View(), | |
| 193 | + | m.completed, | |
| 194 | + | m.total, | |
| 195 | + | m.percentage, | |
| 196 | + | ) | |
| 197 | + | } | |
| 198 | + | ||
| 199 | + | type ChunkMetadata = common.ChunkMetadata | |
| 200 | + | ||
| 20 | 201 | type ChunkedClient struct { | |
| 21 | - | client *http.Client | |
| 22 | - | baseURL string | |
| 23 | - | token string | |
| 24 | - | chunkSize int64 | |
| 25 | - | app *cli.App | |
| 202 | + | client *http.Client | |
| 203 | + | baseURL string | |
| 204 | + | token string | |
| 205 | + | chunkSize int64 | |
| 206 | + | app *cli.App | |
| 207 | + | progress *ProgressDisplay | |
| 208 | + | cancelFunc context.CancelFunc | |
| 209 | + | conflictAction chan common.ConflictAction | |
| 210 | + | } | |
| 211 | + | ||
| 212 | + | type transferStore struct { | |
| 213 | + | mu sync.Mutex | |
| 214 | + | chunks map[string]map[int][]byte | |
| 215 | + | metadata map[string]*ChunkMetadata | |
| 216 | + | started map[string]time.Time | |
| 26 | 217 | } | |
| 27 | 218 | ||
| 28 | - | type ChunkMetadata common.ChunkMetadata | |
| 219 | + | var clientChunkStore = &transferStore{ | |
| 220 | + | chunks: make(map[string]map[int][]byte), | |
| 221 | + | metadata: make(map[string]*ChunkMetadata), | |
| 222 | + | started: make(map[string]time.Time), | |
| 223 | + | } | |
| 29 | 224 | ||
| 225 | + | // NewChunkedClient creates a new chunked client | |
| 30 | 226 | func NewChunkedClient(app *cli.App) *ChunkedClient { | |
| 31 | 227 | baseURL := app.Config.Http.BackendURL + "/api" | |
| 32 | 228 | token := app.Config.General.Token | |
| 33 | 229 | ||
| 34 | - | // If token is empty or invalid, try to authenticate | |
| 35 | - | if token == "" { | |
| 36 | - | if err := ReAuthenticate(app); err != nil { | |
| 37 | - | log.Error("Failed to authenticate", "error", err) | |
| 38 | - | return nil | |
| 39 | - | } | |
| 40 | - | token = app.Config.General.Token | |
| 41 | - | } | |
| 42 | - | ||
| 43 | 230 | return &ChunkedClient{ | |
| 44 | 231 | client: &http.Client{ | |
| 45 | 232 | Timeout: 30 * time.Minute, | |
| @@ -48,18 +235,48 @@ func NewChunkedClient(app *cli.App) *ChunkedClient { | |||
| 48 | 235 | token: token, | |
| 49 | 236 | chunkSize: 5 * 1024 * 1024, | |
| 50 | 237 | app: app, | |
| 238 | + | progress: NewProgressDisplay(), | |
| 51 | 239 | } | |
| 52 | 240 | } | |
| 53 | 241 | ||
| 54 | - | func (c *ChunkedClient) SendFile(ctx context.Context, endpoint string, headers http.Header, reader io.Reader, totalSize int64, imageName string) (resp *Response, error error) { | |
| 242 | + | // SendFileAsChunks sends a file as chunks to the specified endpoint | |
| 243 | + | func (c *ChunkedClient) SendFileAsChunks(ctx context.Context, endpoint string, headers http.Header, reader io.Reader, totalSize int64, imageName string) (*Response, error) { | |
| 244 | + | ctx, cancel := context.WithCancel(ctx) | |
| 245 | + | c.cancelFunc = cancel | |
| 246 | + | defer cancel() | |
| 247 | + | ||
| 248 | + | // Set up signal handling | |
| 249 | + | sigChan := make(chan os.Signal, 1) | |
| 250 | + | signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM) | |
| 251 | + | defer signal.Stop(sigChan) | |
| 252 | + | ||
| 253 | + | // Start progress display | |
| 254 | + | c.progress.Start() | |
| 255 | + | defer c.progress.Stop() | |
| 256 | + | ||
| 257 | + | // Handle signals in a goroutine | |
| 258 | + | go func() { | |
| 259 | + | select { | |
| 260 | + | case <-sigChan: | |
| 261 | + | log.Info("Received interrupt signal, cancelling upload...") | |
| 262 | + | cancel() | |
| 263 | + | case <-ctx.Done(): | |
| 264 | + | return | |
| 265 | + | } | |
| 266 | + | }() | |
| 267 | + | ||
| 55 | 268 | transferID := uuid.New().String() | |
| 56 | 269 | totalChunks := (int(totalSize) + int(c.chunkSize) - 1) / int(c.chunkSize) | |
| 57 | 270 | buf := make([]byte, c.chunkSize) | |
| 58 | - | var lastResponse *Response | |
| 59 | 271 | ||
| 60 | - | // Determine response type based on endpoint | |
| 61 | - | responseType := determineResponseType(endpoint) | |
| 272 | + | // Initialize transfer tracking | |
| 273 | + | clientChunkStore.mu.Lock() | |
| 274 | + | clientChunkStore.started[transferID] = time.Now() | |
| 275 | + | clientChunkStore.mu.Unlock() | |
| 62 | 276 | ||
| 277 | + | defer cleanupClientTransfer(transferID) | |
| 278 | + | ||
| 279 | + | var lastResponse *Response | |
| 63 | 280 | for chunkNum := 0; chunkNum < totalChunks; chunkNum++ { | |
| 64 | 281 | select { | |
| 65 | 282 | case <-ctx.Done(): | |
| @@ -70,11 +287,11 @@ func (c *ChunkedClient) SendFile(ctx context.Context, endpoint string, headers h | |||
| 70 | 287 | return nil, fmt.Errorf("failed to read chunk %d: %w", chunkNum, err) | |
| 71 | 288 | } | |
| 72 | 289 | ||
| 73 | - | chunk := buf[:n] | |
| 74 | 290 | if n == 0 { | |
| 75 | 291 | continue | |
| 76 | 292 | } | |
| 77 | 293 | ||
| 294 | + | chunk := buf[:n] | |
| 78 | 295 | metadata := common.ChunkMetadata{ | |
| 79 | 296 | ChunkNumber: chunkNum, | |
| 80 | 297 | TotalChunks: totalChunks, | |
| @@ -84,28 +301,20 @@ func (c *ChunkedClient) SendFile(ctx context.Context, endpoint string, headers h | |||
| 84 | 301 | TransferID: transferID, | |
| 85 | 302 | } | |
| 86 | 303 | ||
| 87 | - | // Send chunk and get response | |
| 88 | 304 | resp, err := c.sendChunkWithRetry(ctx, endpoint, headers, chunk, metadata) | |
| 89 | 305 | if err != nil { | |
| 90 | 306 | return nil, fmt.Errorf("failed to send chunk %d: %w", chunkNum, err) | |
| 91 | 307 | } | |
| 92 | 308 | lastResponse = resp | |
| 93 | 309 | ||
| 94 | - | // Handle chunk response | |
| 95 | - | if err := handleChunkResponse(resp, responseType); err != nil { | |
| 96 | - | log.Error("Failed to handle chunk response", "error", err) | |
| 97 | - | } | |
| 98 | - | ||
| 99 | - | percentage := float64(chunkNum+1) / float64(totalChunks) * 100 | |
| 100 | - | log.Info("Upload progress", | |
| 101 | - | "completed", chunkNum+1, | |
| 102 | - | "total", totalChunks, | |
| 103 | - | "progress", fmt.Sprintf("%.2f%%", percentage)) | |
| 310 | + | // Update progress | |
| 311 | + | c.progress.Update(chunkNum+1, totalChunks, float64(chunkNum+1)/float64(totalChunks)*100) | |
| 104 | 312 | } | |
| 105 | 313 | } | |
| 106 | 314 | ||
| 107 | - | // Handle final response | |
| 108 | 315 | if lastResponse != nil { | |
| 316 | + | c.progress.Clear() | |
| 317 | + | responseType := determineResponseType(endpoint) | |
| 109 | 318 | if err := handleFinalResponse(lastResponse, responseType); err != nil { | |
| 110 | 319 | log.Error("Failed to handle final response", "error", err) | |
| 111 | 320 | } | |
| @@ -114,6 +323,81 @@ func (c *ChunkedClient) SendFile(ctx context.Context, endpoint string, headers h | |||
| 114 | 323 | return lastResponse, nil | |
| 115 | 324 | } | |
| 116 | 325 | ||
| 326 | + | // sendChunkWithRetry sends a single chunk to the server and retries on failure | |
| 327 | + | func (c *ChunkedClient) sendChunkWithRetry(ctx context.Context, endpoint string, headers http.Header, chunk []byte, metadata common.ChunkMetadata) (*Response, error) { | |
| 328 | + | maxRetries := 3 | |
| 329 | + | backoff := time.Second | |
| 330 | + | ||
| 331 | + | isRetryableError := func(err error) bool { | |
| 332 | + | if err == nil { | |
| 333 | + | return false | |
| 334 | + | } | |
| 335 | + | ||
| 336 | + | if netErr, ok := err.(net.Error); ok { | |
| 337 | + | return netErr.Timeout() | |
| 338 | + | } | |
| 339 | + | ||
| 340 | + | if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { | |
| 341 | + | return false | |
| 342 | + | } | |
| 343 | + | ||
| 344 | + | errorStr := err.Error() | |
| 345 | + | retryableErrors := []string{ | |
| 346 | + | "connection reset by peer", | |
| 347 | + | "broken pipe", | |
| 348 | + | "connection refused", | |
| 349 | + | "no such host", | |
| 350 | + | "i/o timeout", | |
| 351 | + | "unexpected EOF", | |
| 352 | + | "connection timed out", | |
| 353 | + | "network is unreachable", | |
| 354 | + | } | |
| 355 | + | ||
| 356 | + | for _, retryableErr := range retryableErrors { | |
| 357 | + | if strings.Contains(strings.ToLower(errorStr), retryableErr) { | |
| 358 | + | return true | |
| 359 | + | } | |
| 360 | + | } | |
| 361 | + | ||
| 362 | + | return false | |
| 363 | + | } | |
| 364 | + | ||
| 365 | + | var lastErr error | |
| 366 | + | for attempt := 0; attempt < maxRetries; attempt++ { | |
| 367 | + | resp, err := c.sendChunk(ctx, endpoint, headers, chunk, metadata) | |
| 368 | + | if err == nil { | |
| 369 | + | return resp, nil | |
| 370 | + | } | |
| 371 | + | ||
| 372 | + | lastErr = err | |
| 373 | + | ||
| 374 | + | if !isRetryableError(err) { | |
| 375 | + | return nil, err | |
| 376 | + | } | |
| 377 | + | ||
| 378 | + | if attempt < maxRetries-1 { | |
| 379 | + | retryDelay := backoff * time.Duration(attempt+1) | |
| 380 | + | jitter := time.Duration(rand.Int63n(int64(retryDelay) / 2)) | |
| 381 | + | ||
| 382 | + | select { | |
| 383 | + | case <-ctx.Done(): | |
| 384 | + | return nil, ctx.Err() | |
| 385 | + | case <-time.After(retryDelay + jitter): | |
| 386 | + | continue | |
| 387 | + | } | |
| 388 | + | } | |
| 389 | + | } | |
| 390 | + | ||
| 391 | + | return nil, fmt.Errorf("max retries exceeded after %d attempts: %w", maxRetries, lastErr) | |
| 392 | + | } | |
| 393 | + | ||
| 394 | + | func (c *ChunkedClient) Cancel() { | |
| 395 | + | if c.cancelFunc != nil { | |
| 396 | + | c.cancelFunc() | |
| 397 | + | } | |
| 398 | + | } | |
| 399 | + | ||
| 400 | + | // Response handling functions | |
| 117 | 401 | func determineResponseType(endpoint string) string { | |
| 118 | 402 | switch { | |
| 119 | 403 | case strings.Contains(endpoint, "/deploy"): | |
| @@ -125,158 +409,170 @@ func determineResponseType(endpoint string) string { | |||
| 125 | 409 | } | |
| 126 | 410 | } | |
| 127 | 411 | ||
| 128 | - | func handleChunkResponse(resp *Response, responseType string) error { | |
| 412 | + | // handleFinalResponse determines the response type and calls the appropriate handler | |
| 413 | + | func handleFinalResponse(resp *Response, responseType string) error { | |
| 414 | + | if resp == nil || len(resp.Body) == 0 { | |
| 415 | + | return fmt.Errorf("empty response received") | |
| 416 | + | } | |
| 417 | + | ||
| 129 | 418 | switch responseType { | |
| 130 | 419 | case "deploy": | |
| 131 | - | var deployResp common.DeployResponse | |
| 132 | - | if err := json.Unmarshal(resp.Body, &deployResp); err != nil { | |
| 133 | - | return fmt.Errorf("failed to unmarshal deploy response: %w", err) | |
| 134 | - | } | |
| 135 | - | return nil | |
| 136 | - | ||
| 420 | + | return handleDeployResponse(resp) | |
| 137 | 421 | case "push": | |
| 138 | - | var pushResp common.PushResponse | |
| 139 | - | if err := json.Unmarshal(resp.Body, &pushResp); err != nil { | |
| 140 | - | return fmt.Errorf("failed to unmarshal push response: %w", err) | |
| 141 | - | } | |
| 142 | - | ||
| 143 | - | return nil | |
| 144 | - | ||
| 422 | + | return handlePushResponse(resp) | |
| 145 | 423 | default: | |
| 146 | 424 | return fmt.Errorf("unknown response type: %s", responseType) | |
| 147 | 425 | } | |
| 148 | 426 | } | |
| 149 | 427 | ||
| 150 | - | func handleFinalResponse(resp *Response, responseType string) error { | |
| 151 | - | switch responseType { | |
| 152 | - | case "deploy": | |
| 153 | - | var finalResp common.DeployResponse | |
| 154 | - | if err := json.Unmarshal(resp.Body, &finalResp); err != nil { | |
| 155 | - | return fmt.Errorf("failed to unmarshal final deploy response: %w", err) | |
| 156 | - | } | |
| 157 | - | // only keep the 8 first characters of the container ID | |
| 158 | - | finalResp.ContainerID = finalResp.ContainerID[:8] | |
| 428 | + | // handleDeployResponse handles the response from a deploy operation | |
| 429 | + | func handleDeployResponse(resp *Response) error { | |
| 430 | + | // If not a conflict, process as normal deploy response | |
| 431 | + | var finalResp common.DeployResponse | |
| 432 | + | if err := json.Unmarshal(resp.Body, &finalResp); err != nil { | |
| 433 | + | return fmt.Errorf("failed to unmarshal deploy response: %w", err) | |
| 434 | + | } | |
| 159 | 435 | ||
| 160 | - | log.Info( | |
| 161 | - | finalResp.Message, | |
| 162 | - | "domain", finalResp.Domain, | |
| 163 | - | "containerID", finalResp.ContainerID, | |
| 164 | - | "containerName", finalResp.ContainerName) | |
| 165 | - | return nil | |
| 436 | + | // Always log the success message with available information | |
| 437 | + | logFields := []interface{}{} | |
| 166 | 438 | ||
| 167 | - | case "push": | |
| 168 | - | var finalResp common.PushResponse | |
| 169 | - | if err := json.Unmarshal(resp.Body, &finalResp); err != nil { | |
| 170 | - | return fmt.Errorf("failed to unmarshal final push response: %w", err) | |
| 439 | + | if finalResp.Domain != "" { | |
| 440 | + | logFields = append(logFields, "domain", finalResp.Domain) | |
| 441 | + | } | |
| 442 | + | if finalResp.ContainerID != "" { | |
| 443 | + | shortContainerID := finalResp.ContainerID | |
| 444 | + | if len(shortContainerID) > 8 { | |
| 445 | + | shortContainerID = shortContainerID[:8] | |
| 171 | 446 | } | |
| 172 | - | log.Info( | |
| 173 | - | finalResp.Message, | |
| 174 | - | "createContainerURL", finalResp.CreateContainerURL, | |
| 175 | - | "imageID", finalResp.ImageID) | |
| 176 | - | return nil | |
| 447 | + | logFields = append(logFields, "containerID", shortContainerID) | |
| 448 | + | } | |
| 449 | + | if finalResp.ContainerName != "" { | |
| 450 | + | logFields = append(logFields, "containerName", finalResp.ContainerName) | |
| 451 | + | } | |
| 177 | 452 | ||
| 178 | - | default: | |
| 179 | - | return fmt.Errorf("unknown response type: %s", responseType) | |
| 453 | + | // If there's a message, use it; otherwise, use a default success message | |
| 454 | + | message := finalResp.Message | |
| 455 | + | if message == "" { | |
| 456 | + | message = "Deployment completed successfully" | |
| 180 | 457 | } | |
| 181 | - | } | |
| 182 | 458 | ||
| 183 | - | func (c *ChunkedClient) sendChunkWithRetry(ctx context.Context, endpoint string, headers http.Header, chunk []byte, metadata common.ChunkMetadata) (*Response, error) { | |
| 184 | - | // For the first chunk (conflict check), only try once | |
| 185 | - | maxRetries := 3 | |
| 186 | - | if metadata.ChunkNumber == 0 && len(chunk) == 1024 { | |
| 187 | - | maxRetries = 1 | |
| 459 | + | // Ensure we log the result even if there are no additional fields | |
| 460 | + | if len(logFields) > 0 { | |
| 461 | + | log.Info(message, logFields...) | |
| 462 | + | } else { | |
| 463 | + | log.Info(message) | |
| 188 | 464 | } | |
| 189 | 465 | ||
| 190 | - | var lastErr error | |
| 191 | - | for attempt := 0; attempt < maxRetries; attempt++ { | |
| 192 | - | resp, err := c.sendChunk(ctx, endpoint, headers, chunk, metadata) | |
| 193 | - | if err == nil { | |
| 194 | - | return resp, nil | |
| 195 | - | } | |
| 196 | - | lastErr = err | |
| 466 | + | return nil | |
| 467 | + | } | |
| 197 | 468 | ||
| 198 | - | // Don't log retry messages for the conflict check | |
| 199 | - | if maxRetries > 1 { | |
| 200 | - | log.Warn("Chunk upload failed, retrying", | |
| 201 | - | "chunk", metadata.ChunkNumber, | |
| 202 | - | "attempt", attempt+1, | |
| 203 | - | "error", err) | |
| 204 | - | time.Sleep(time.Second * time.Duration(attempt+1)) | |
| 205 | - | } | |
| 469 | + | // handlePushResponse handles the response from a push operation | |
| 470 | + | func handlePushResponse(resp *Response) error { | |
| 471 | + | var finalResp common.PushResponse | |
| 472 | + | if err := json.Unmarshal(resp.Body, &finalResp); err != nil { | |
| 473 | + | return fmt.Errorf("failed to unmarshal push response: %w", err) | |
| 206 | 474 | } | |
| 207 | - | return nil, fmt.Errorf("max retries exceeded: %w", lastErr) | |
| 475 | + | return nil | |
| 208 | 476 | } | |
| 209 | 477 | ||
| 478 | + | // sendChunk sends a single chunk to the server | |
| 210 | 479 | func (c *ChunkedClient) sendChunk(ctx context.Context, endpoint string, headers http.Header, chunk []byte, metadata common.ChunkMetadata) (*Response, error) { | |
| 211 | - | reauthenticated := false | |
| 212 | - | ||
| 480 | + | if err := verifyChunkIntegrity(chunk, metadata); err != nil { | |
| 481 | + | return nil, fmt.Errorf("chunk integrity check failed: %w", err) | |
| 482 | + | } | |
| 213 | 483 | for { | |
| 214 | - | ctx, cancel := context.WithTimeout(ctx, 10*time.Minute) | |
| 215 | - | defer cancel() | |
| 216 | - | ||
| 217 | - | metadataJSON, err := json.Marshal(metadata) | |
| 218 | - | if err != nil { | |
| 219 | - | return nil, fmt.Errorf("failed to marshal metadata: %w", err) | |
| 220 | - | } | |
| 221 | - | ||
| 222 | - | // Create the request | |
| 223 | - | url := c.baseURL + endpoint | |
| 224 | - | req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(chunk)) | |
| 225 | - | if err != nil { | |
| 226 | - | return nil, fmt.Errorf("failed to create request: %w", err) | |
| 484 | + | select { | |
| 485 | + | case <-ctx.Done(): | |
| 486 | + | return nil, ctx.Err() | |
| 487 | + | default: | |
| 488 | + | resp, err := c.attemptChunkSend(ctx, endpoint, headers, chunk, metadata) | |
| 489 | + | if err != nil { | |
| 490 | + | return nil, err | |
| 491 | + | } | |
| 492 | + | return resp, nil | |
| 227 | 493 | } | |
| 494 | + | } | |
| 495 | + | } | |
| 228 | 496 | ||
| 229 | - | // Set headers | |
| 230 | - | req.Header.Set("Authorization", "Bearer "+c.token) | |
| 231 | - | req.Header.Set("Content-Type", "application/octet-stream") | |
| 232 | - | req.Header.Set("X-Chunk-Metadata", string(metadataJSON)) | |
| 497 | + | func (c *ChunkedClient) attemptChunkSend(ctx context.Context, endpoint string, headers http.Header, chunk []byte, metadata common.ChunkMetadata) (*Response, error) { | |
| 498 | + | metadataJSON, err := json.Marshal(metadata) | |
| 499 | + | if err != nil { | |
| 500 | + | return nil, fmt.Errorf("failed to marshal metadata: %w", err) | |
| 501 | + | } | |
| 233 | 502 | ||
| 234 | - | for k, v := range headers { | |
| 235 | - | req.Header[k] = v | |
| 236 | - | } | |
| 503 | + | url := c.baseURL + endpoint | |
| 504 | + | req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(chunk)) | |
| 505 | + | if err != nil { | |
| 506 | + | return nil, fmt.Errorf("failed to create request: %w", err) | |
| 507 | + | } | |
| 237 | 508 | ||
| 238 | - | // Send request | |
| 239 | - | resp, err := c.client.Do(req) | |
| 240 | - | if err != nil { | |
| 241 | - | return nil, fmt.Errorf("request failed: %w", err) | |
| 242 | - | } | |
| 243 | - | defer resp.Body.Close() | |
| 509 | + | // Set headers | |
| 510 | + | req.Header.Set("Authorization", "Bearer "+c.token) | |
| 511 | + | req.Header.Set("Content-Type", "application/octet-stream") | |
| 512 | + | req.Header.Set("X-Chunk-Metadata", string(metadataJSON)) | |
| 513 | + | req.Header.Set("X-Chunk-Hash", fmt.Sprintf("%x", sha256.Sum256(chunk))) | |
| 244 | 514 | ||
| 245 | - | // Handle 401 Unauthorized | |
| 246 | - | if resp.StatusCode == http.StatusUnauthorized && !reauthenticated { | |
| 247 | - | log.Warn("Token expired, initiating re-authentication") | |
| 515 | + | for k, v := range headers { | |
| 516 | + | req.Header[k] = v | |
| 517 | + | } | |
| 248 | 518 | ||
| 249 | - | err := ReAuthenticate(c.app) | |
| 250 | - | if err != nil { | |
| 251 | - | return nil, fmt.Errorf("re-authentication failed: %w", err) | |
| 252 | - | } | |
| 519 | + | resp, err := c.client.Do(req) | |
| 520 | + | if err != nil { | |
| 521 | + | return nil, err | |
| 522 | + | } | |
| 523 | + | defer resp.Body.Close() | |
| 253 | 524 | ||
| 254 | - | // Update the client's token with the new one | |
| 255 | - | c.token = c.app.Config.General.Token | |
| 256 | - | reauthenticated = true | |
| 257 | - | continue | |
| 258 | - | } | |
| 525 | + | body, err := io.ReadAll(resp.Body) | |
| 526 | + | if err != nil { | |
| 527 | + | return nil, fmt.Errorf("failed to read response: %w", err) | |
| 528 | + | } | |
| 259 | 529 | ||
| 260 | - | // Handle non-200 status codes | |
| 261 | - | if resp.StatusCode != http.StatusOK { | |
| 262 | - | body, _ := io.ReadAll(resp.Body) | |
| 263 | - | return nil, &common.DeploymentError{ | |
| 530 | + | if resp.StatusCode != http.StatusOK { | |
| 531 | + | return &Response{ | |
| 532 | + | Http: resp, | |
| 533 | + | Body: body, | |
| 534 | + | StatusCode: resp.StatusCode, | |
| 535 | + | Header: resp.Header, | |
| 536 | + | }, &common.DeploymentError{ | |
| 264 | 537 | StatusCode: resp.StatusCode, | |
| 538 | + | Message: "Request failed", | |
| 265 | 539 | RawResponse: string(body), | |
| 266 | 540 | } | |
| 267 | - | } | |
| 541 | + | } | |
| 268 | 542 | ||
| 269 | - | // Read response body | |
| 270 | - | body, err := io.ReadAll(resp.Body) | |
| 271 | - | if err != nil { | |
| 272 | - | return nil, fmt.Errorf("failed to read response: %w", err) | |
| 273 | - | } | |
| 543 | + | return &Response{ | |
| 544 | + | Http: resp, | |
| 545 | + | Body: body, | |
| 546 | + | StatusCode: resp.StatusCode, | |
| 547 | + | Header: resp.Header, | |
| 548 | + | }, nil | |
| 549 | + | } | |
| 274 | 550 | ||
| 275 | - | return &Response{ | |
| 276 | - | Http: resp, | |
| 277 | - | Body: body, | |
| 278 | - | StatusCode: resp.StatusCode, | |
| 279 | - | Header: resp.Header, | |
| 280 | - | }, nil | |
| 551 | + | // verifyChunkIntegrity verifies the integrity of a chunk | |
| 552 | + | func verifyChunkIntegrity(chunk []byte, metadata common.ChunkMetadata) error { | |
| 553 | + | if len(chunk) == 0 { | |
| 554 | + | return fmt.Errorf("empty chunk received") | |
| 281 | 555 | } | |
| 556 | + | ||
| 557 | + | if int64(len(chunk)) != metadata.ChunkSize { | |
| 558 | + | return fmt.Errorf("chunk size mismatch: expected %d, got %d", | |
| 559 | + | metadata.ChunkSize, len(chunk)) | |
| 560 | + | } | |
| 561 | + | ||
| 562 | + | return nil | |
| 563 | + | } | |
| 564 | + | ||
| 565 | + | // isAuthError checks if a response is an authentication error | |
| 566 | + | func isAuthError(resp *Response) bool { | |
| 567 | + | return resp != nil && resp.StatusCode == http.StatusUnauthorized | |
| 568 | + | } | |
| 569 | + | ||
| 570 | + | // cleanupClientTransfer cleans up a client transfer | |
| 571 | + | func cleanupClientTransfer(transferID string) { | |
| 572 | + | clientChunkStore.mu.Lock() | |
| 573 | + | defer clientChunkStore.mu.Unlock() | |
| 574 | + | ||
| 575 | + | delete(clientChunkStore.chunks, transferID) | |
| 576 | + | delete(clientChunkStore.metadata, transferID) | |
| 577 | + | delete(clientChunkStore.started, transferID) | |
| 282 | 578 | } | |
| @@ -1,45 +1,232 @@ | |||||
| 1 | - | // internal/cli/handler/chunked_client.go | 1 | + | // internal/cli/handler/progress_display.go |
| 2 | + | ||||
| 2 | package handler | 3 | package handler | ||
| 3 | 4 | ||||
| 4 | import ( | 5 | import ( | ||
| 5 | "bytes" | 6 | "bytes" | ||
| 6 | "context" | 7 | "context" | ||
| 8 | + | "crypto/sha256" | |||
| 7 | "encoding/json" | 9 | "encoding/json" | ||
| 10 | + | "errors" | |||
| 8 | "fmt" | 11 | "fmt" | ||
| 9 | "io" | 12 | "io" | ||
| 13 | + | "net" | |||
| 10 | "net/http" | 14 | "net/http" | ||
| 15 | + | "os" | |||
| 16 | + | "os/signal" | |||
| 11 | "strings" | 17 | "strings" | ||
| 18 | + | "sync" | |||
| 19 | + | "syscall" | |||
| 12 | "time" | 20 | "time" | ||
| 13 | 21 | ||||
| 14 | "github.com/bnema/gordon/internal/cli" | 22 | "github.com/bnema/gordon/internal/cli" | ||
| 15 | "github.com/bnema/gordon/internal/common" | 23 | "github.com/bnema/gordon/internal/common" | ||
| 24 | + | "github.com/charmbracelet/bubbles/spinner" | |||
| 25 | + | tea "github.com/charmbracelet/bubbletea" | |||
| 26 | + | "github.com/charmbracelet/lipgloss" | |||
| 16 | "github.com/charmbracelet/log" | 27 | "github.com/charmbracelet/log" | ||
| 17 | "github.com/google/uuid" | 28 | "github.com/google/uuid" | ||
| 29 | + | "golang.org/x/exp/rand" | |||
| 18 | ) | 30 | ) | ||
| 19 | 31 | ||||
| 32 | + | // UploadProgress represents the current state of the upload | |||
| 33 | + | type UploadProgress struct { | |||
| 34 | + | Completed int | |||
| 35 | + | Total int | |||
| 36 | + | Percentage float64 | |||
| 37 | + | } | |||
| 38 | + | ||||
| 39 | + | // ProgressDisplay manages the upload progress display | |||
| 40 | + | type ProgressDisplay struct { | |||
| 41 | + | mu sync.Mutex | |||
| 42 | + | model *uploadModel | |||
| 43 | + | program *tea.Program | |||
| 44 | + | updateChan chan UploadProgress | |||
| 45 | + | done chan struct{} | |||
| 46 | + | interrupted bool | |||
| 47 | + | paused bool | |||
| 48 | + | pauseChan chan bool | |||
| 49 | + | } | |||
| 50 | + | ||||
| 51 | + | type uploadModel struct { | |||
| 52 | + | spinner spinner.Model | |||
| 53 | + | completed int | |||
| 54 | + | total int | |||
| 55 | + | percentage float64 | |||
| 56 | + | done bool | |||
| 57 | + | } | |||
| 58 | + | ||||
| 59 | + | // NewProgressDisplay creates a new progress display | |||
| 60 | + | func NewProgressDisplay() *ProgressDisplay { | |||
| 61 | + | return &ProgressDisplay{ | |||
| 62 | + | updateChan: make(chan UploadProgress), | |||
| 63 | + | done: make(chan struct{}), | |||
| 64 | + | pauseChan: make(chan bool), | |||
| 65 | + | } | |||
| 66 | + | } | |||
| 67 | + | ||||
| 68 | + | func (pd *ProgressDisplay) Start() { | |||
| 69 | + | pd.mu.Lock() | |||
| 70 | + | defer pd.mu.Unlock() | |||
| 71 | + | ||||
| 72 | + | pd.model = &uploadModel{ | |||
| 73 | + | spinner: spinner.New( | |||
| 74 | + | spinner.WithSpinner(spinner.MiniDot), | |||
| 75 | + | spinner.WithStyle(lipgloss.NewStyle().Foreground(lipgloss.Color("69"))), | |||
| 76 | + | ), | |||
| 77 | + | } | |||
| 78 | + | ||||
| 79 | + | pd.program = tea.NewProgram(pd.model) | |||
| 80 | + | ||||
| 81 | + | // Run the UI in a goroutine | |||
| 82 | + | go func() { | |||
| 83 | + | if err := pd.program.Start(); err != nil { | |||
| 84 | + | fmt.Printf("Error running progress display: %v\n", err) | |||
| 85 | + | } | |||
| 86 | + | }() | |||
| 87 | + | ||||
| 88 | + | // Handle updates in a separate goroutine | |||
| 89 | + | go pd.handleUpdates() | |||
| 90 | + | } | |||
| 91 | + | ||||
| 92 | + | func (pd *ProgressDisplay) Pause() { | |||
| 93 | + | pd.mu.Lock() | |||
| 94 | + | defer pd.mu.Unlock() | |||
| 95 | + | pd.paused = true | |||
| 96 | + | pd.Clear() // Clear the current display | |||
| 97 | + | } | |||
| 98 | + | ||||
| 99 | + | func (pd *ProgressDisplay) Resume() { | |||
| 100 | + | pd.mu.Lock() | |||
| 101 | + | defer pd.mu.Unlock() | |||
| 102 | + | pd.paused = false | |||
| 103 | + | } | |||
| 104 | + | ||||
| 105 | + | func (pd *ProgressDisplay) handleUpdates() { | |||
| 106 | + | for { | |||
| 107 | + | select { | |||
| 108 | + | case progress := <-pd.updateChan: | |||
| 109 | + | pd.mu.Lock() | |||
| 110 | + | if !pd.paused { | |||
| 111 | + | pd.program.Send(progressMsg{ | |||
| 112 | + | completed: progress.Completed, | |||
| 113 | + | total: progress.Total, | |||
| 114 | + | percentage: progress.Percentage, | |||
| 115 | + | }) | |||
| 116 | + | } | |||
| 117 | + | pd.mu.Unlock() | |||
| 118 | + | case <-pd.done: | |||
| 119 | + | pd.program.Send(doneMsg{}) | |||
| 120 | + | return | |||
| 121 | + | } | |||
| 122 | + | } | |||
| 123 | + | } | |||
| 124 | + | ||||
| 125 | + | func (pd *ProgressDisplay) Update(completed, total int, percentage float64) { | |||
| 126 | + | pd.updateChan <- UploadProgress{ | |||
| 127 | + | Completed: completed, | |||
| 128 | + | Total: total, | |||
| 129 | + | Percentage: percentage, | |||
| 130 | + | } | |||
| 131 | + | } | |||
| 132 | + | ||||
| 133 | + | func (pd *ProgressDisplay) Stop() { | |||
| 134 | + | close(pd.done) | |||
| 135 | + | } | |||
| 136 | + | ||||
| 137 | + | func (pd *ProgressDisplay) Clear() { | |||
| 138 | + | pd.mu.Lock() | |||
| 139 | + | defer pd.mu.Unlock() | |||
| 140 | + | ||||
| 141 | + | // Send a special clear message | |||
| 142 | + | pd.program.Send(clearMsg{}) | |||
| 143 | + | // Wait a brief moment for the clear to take effect | |||
| 144 | + | time.Sleep(50 * time.Millisecond) | |||
| 145 | + | } | |||
| 146 | + | ||||
| 147 | + | type progressMsg struct { | |||
| 148 | + | completed int | |||
| 149 | + | total int | |||
| 150 | + | percentage float64 | |||
| 151 | + | } | |||
| 152 | + | ||||
| 153 | + | type doneMsg struct{} | |||
| 154 | + | ||||
| 155 | + | type clearMsg struct{} | |||
| 156 | + | ||||
| 157 | + | func (m *uploadModel) Init() tea.Cmd { | |||
| 158 | + | return m.spinner.Tick | |||
| 159 | + | } | |||
| 160 | + | ||||
| 161 | + | func (m *uploadModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { | |||
| 162 | + | switch msg := msg.(type) { | |||
| 163 | + | case tea.KeyMsg: | |||
| 164 | + | if msg.Type == tea.KeyCtrlC { | |||
| 165 | + | return m, tea.Quit | |||
| 166 | + | } | |||
| 167 | + | case spinner.TickMsg: | |||
| 168 | + | var cmd tea.Cmd | |||
| 169 | + | m.spinner, cmd = m.spinner.Update(msg) | |||
| 170 | + | return m, cmd | |||
| 171 | + | case progressMsg: | |||
| 172 | + | m.completed = msg.completed | |||
| 173 | + | m.total = msg.total | |||
| 174 | + | m.percentage = msg.percentage | |||
| 175 | + | return m, m.spinner.Tick | |||
| 176 | + | case doneMsg: | |||
| 177 | + | m.done = true | |||
| 178 | + | return m, tea.Quit | |||
| 179 | + | case clearMsg: | |||
| 180 | + | m.done = true | |||
| 181 | + | return m, tea.Quit | |||
| 182 | + | } | |||
| 183 | + | return m, nil | |||
| 184 | + | } | |||
| 185 | + | ||||
| 186 | + | func (m *uploadModel) View() string { | |||
| 187 | + | if m.done { | |||
| 188 | + | return "" | |||
| 189 | + | } | |||
| 190 | + | ||||
| 191 | + | return fmt.Sprintf("\r%s Uploading image... %d/%d chunks (%.2f%%)", | |||
| 192 | + | m.spinner.View(), | |||
| 193 | + | m.completed, | |||
| 194 | + | m.total, | |||
| 195 | + | m.percentage, | |||
| 196 | + | ) | |||
| 197 | + | } | |||
| 198 | + | ||||
| 199 | + | type ChunkMetadata = common.ChunkMetadata | |||
| 200 | + | ||||
| 20 | type ChunkedClient struct { | 201 | type ChunkedClient struct { | ||
| 21 | - | client *http.Client | 202 | + | client *http.Client |
| 22 | - | baseURL string | 203 | + | baseURL string |
| 23 | - | token string | 204 | + | token string |
| 24 | - | chunkSize int64 | 205 | + | chunkSize int64 |
| 25 | - | app *cli.App | 206 | + | app *cli.App |
| 207 | + | progress *ProgressDisplay | |||
| 208 | + | cancelFunc context.CancelFunc | |||
| 209 | + | conflictAction chan common.ConflictAction | |||
| 210 | + | } | |||
| 211 | + | ||||
| 212 | + | type transferStore struct { | |||
| 213 | + | mu sync.Mutex | |||
| 214 | + | chunks map[string]map[int][]byte | |||
| 215 | + | metadata map[string]*ChunkMetadata | |||
| 216 | + | started map[string]time.Time | |||
| 26 | } | 217 | } | ||
| 27 | 218 | ||||
| 28 | - | type ChunkMetadata common.ChunkMetadata | 219 | + | var clientChunkStore = &transferStore{ |
| 220 | + | chunks: make(map[string]map[int][]byte), | |||
| 221 | + | metadata: make(map[string]*ChunkMetadata), | |||
| 222 | + | started: make(map[string]time.Time), | |||
| 223 | + | } | |||
| 29 | 224 | ||||
| 225 | + | // NewChunkedClient creates a new chunked client | |||
| 30 | func NewChunkedClient(app *cli.App) *ChunkedClient { | 226 | func NewChunkedClient(app *cli.App) *ChunkedClient { | ||
| 31 | baseURL := app.Config.Http.BackendURL + "/api" | 227 | baseURL := app.Config.Http.BackendURL + "/api" | ||
| 32 | token := app.Config.General.Token | 228 | token := app.Config.General.Token | ||
| 33 | 229 | ||||
| 34 | - | // If token is empty or invalid, try to authenticate | |||
| 35 | - | if token == "" { | |||
| 36 | - | if err := ReAuthenticate(app); err != nil { | |||
| 37 | - | log.Error("Failed to authenticate", "error", err) | |||
| 38 | - | return nil | |||
| 39 | - | } | |||
| 40 | - | token = app.Config.General.Token | |||
| 41 | - | } | |||
| 42 | - | ||||
| 43 | return &ChunkedClient{ | 230 | return &ChunkedClient{ | ||
| 44 | client: &http.Client{ | 231 | client: &http.Client{ | ||
| 45 | Timeout: 30 * time.Minute, | 232 | Timeout: 30 * time.Minute, | ||
| @@ -48,18 +235,48 @@ func NewChunkedClient(app *cli.App) *ChunkedClient { | |||||
| 48 | token: token, | 235 | token: token, | ||
| 49 | chunkSize: 5 * 1024 * 1024, | 236 | chunkSize: 5 * 1024 * 1024, | ||
| 50 | app: app, | 237 | app: app, | ||
| 238 | + | progress: NewProgressDisplay(), | |||
| 51 | } | 239 | } | ||
| 52 | } | 240 | } | ||
| 53 | 241 | ||||
| 54 | - | func (c *ChunkedClient) SendFile(ctx context.Context, endpoint string, headers http.Header, reader io.Reader, totalSize int64, imageName string) (resp *Response, error error) { | 242 | + | // SendFileAsChunks sends a file as chunks to the specified endpoint |
| 243 | + | func (c *ChunkedClient) SendFileAsChunks(ctx context.Context, endpoint string, headers http.Header, reader io.Reader, totalSize int64, imageName string) (*Response, error) { | |||
| 244 | + | ctx, cancel := context.WithCancel(ctx) | |||
| 245 | + | c.cancelFunc = cancel | |||
| 246 | + | defer cancel() | |||
| 247 | + | ||||
| 248 | + | // Set up signal handling | |||
| 249 | + | sigChan := make(chan os.Signal, 1) | |||
| 250 | + | signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM) | |||
| 251 | + | defer signal.Stop(sigChan) | |||
| 252 | + | ||||
| 253 | + | // Start progress display | |||
| 254 | + | c.progress.Start() | |||
| 255 | + | defer c.progress.Stop() | |||
| 256 | + | ||||
| 257 | + | // Handle signals in a goroutine | |||
| 258 | + | go func() { | |||
| 259 | + | select { | |||
| 260 | + | case <-sigChan: | |||
| 261 | + | log.Info("Received interrupt signal, cancelling upload...") | |||
| 262 | + | cancel() | |||
| 263 | + | case <-ctx.Done(): | |||
| 264 | + | return | |||
| 265 | + | } | |||
| 266 | + | }() | |||
| 267 | + | ||||
| 55 | transferID := uuid.New().String() | 268 | transferID := uuid.New().String() | ||
| 56 | totalChunks := (int(totalSize) + int(c.chunkSize) - 1) / int(c.chunkSize) | 269 | totalChunks := (int(totalSize) + int(c.chunkSize) - 1) / int(c.chunkSize) | ||
| 57 | buf := make([]byte, c.chunkSize) | 270 | buf := make([]byte, c.chunkSize) | ||
| 58 | - | var lastResponse *Response | |||
| 59 | 271 | ||||
| 60 | - | // Determine response type based on endpoint | 272 | + | // Initialize transfer tracking |
| 61 | - | responseType := determineResponseType(endpoint) | 273 | + | clientChunkStore.mu.Lock() |
| 274 | + | clientChunkStore.started[transferID] = time.Now() | |||
| 275 | + | clientChunkStore.mu.Unlock() | |||
| 62 | 276 | ||||
| 277 | + | defer cleanupClientTransfer(transferID) | |||
| 278 | + | ||||
| 279 | + | var lastResponse *Response | |||
| 63 | for chunkNum := 0; chunkNum < totalChunks; chunkNum++ { | 280 | for chunkNum := 0; chunkNum < totalChunks; chunkNum++ { | ||
| 64 | select { | 281 | select { | ||
| 65 | case <-ctx.Done(): | 282 | case <-ctx.Done(): | ||
| @@ -70,11 +287,11 @@ func (c *ChunkedClient) SendFile(ctx context.Context, endpoint string, headers h | |||||
| 70 | return nil, fmt.Errorf("failed to read chunk %d: %w", chunkNum, err) | 287 | return nil, fmt.Errorf("failed to read chunk %d: %w", chunkNum, err) | ||
| 71 | } | 288 | } | ||
| 72 | 289 | ||||
| 73 | - | chunk := buf[:n] | |||
| 74 | if n == 0 { | 290 | if n == 0 { | ||
| 75 | continue | 291 | continue | ||
| 76 | } | 292 | } | ||
| 77 | 293 | ||||
| 294 | + | chunk := buf[:n] | |||
| 78 | metadata := common.ChunkMetadata{ | 295 | metadata := common.ChunkMetadata{ | ||
| 79 | ChunkNumber: chunkNum, | 296 | ChunkNumber: chunkNum, | ||
| 80 | TotalChunks: totalChunks, | 297 | TotalChunks: totalChunks, | ||
| @@ -84,28 +301,20 @@ func (c *ChunkedClient) SendFile(ctx context.Context, endpoint string, headers h | |||||
| 84 | TransferID: transferID, | 301 | TransferID: transferID, | ||
| 85 | } | 302 | } | ||
| 86 | 303 | ||||
| 87 | - | // Send chunk and get response | |||
| 88 | resp, err := c.sendChunkWithRetry(ctx, endpoint, headers, chunk, metadata) | 304 | resp, err := c.sendChunkWithRetry(ctx, endpoint, headers, chunk, metadata) | ||
| 89 | if err != nil { | 305 | if err != nil { | ||
| 90 | return nil, fmt.Errorf("failed to send chunk %d: %w", chunkNum, err) | 306 | return nil, fmt.Errorf("failed to send chunk %d: %w", chunkNum, err) | ||
| 91 | } | 307 | } | ||
| 92 | lastResponse = resp | 308 | lastResponse = resp | ||
| 93 | 309 | ||||
| 94 | - | // Handle chunk response | 310 | + | // Update progress |
| 95 | - | if err := handleChunkResponse(resp, responseType); err != nil { | 311 | + | c.progress.Update(chunkNum+1, totalChunks, float64(chunkNum+1)/float64(totalChunks)*100) |
| 96 | - | log.Error("Failed to handle chunk response", "error", err) | |||
| 97 | - | } | |||
| 98 | - | ||||
| 99 | - | percentage := float64(chunkNum+1) / float64(totalChunks) * 100 | |||
| 100 | - | log.Info("Upload progress", | |||
| 101 | - | "completed", chunkNum+1, | |||
| 102 | - | "total", totalChunks, | |||
| 103 | - | "progress", fmt.Sprintf("%.2f%%", percentage)) | |||
| 104 | } | 312 | } | ||
| 105 | } | 313 | } | ||
| 106 | 314 | ||||
| 107 | - | // Handle final response | |||
| 108 | if lastResponse != nil { | 315 | if lastResponse != nil { | ||
| 316 | + | c.progress.Clear() | |||
| 317 | + | responseType := determineResponseType(endpoint) | |||
| 109 | if err := handleFinalResponse(lastResponse, responseType); err != nil { | 318 | if err := handleFinalResponse(lastResponse, responseType); err != nil { | ||
| 110 | log.Error("Failed to handle final response", "error", err) | 319 | log.Error("Failed to handle final response", "error", err) | ||
| 111 | } | 320 | } | ||
| @@ -114,6 +323,81 @@ func (c *ChunkedClient) SendFile(ctx context.Context, endpoint string, headers h | |||||
| 114 | return lastResponse, nil | 323 | return lastResponse, nil | ||
| 115 | } | 324 | } | ||
| 116 | 325 | ||||
| 326 | + | // sendChunkWithRetry sends a single chunk to the server and retries on failure | |||
| 327 | + | func (c *ChunkedClient) sendChunkWithRetry(ctx context.Context, endpoint string, headers http.Header, chunk []byte, metadata common.ChunkMetadata) (*Response, error) { | |||
| 328 | + | maxRetries := 3 | |||
| 329 | + | backoff := time.Second | |||
| 330 | + | ||||
| 331 | + | isRetryableError := func(err error) bool { | |||
| 332 | + | if err == nil { | |||
| 333 | + | return false | |||
| 334 | + | } | |||
| 335 | + | ||||
| 336 | + | if netErr, ok := err.(net.Error); ok { | |||
| 337 | + | return netErr.Timeout() | |||
| 338 | + | } | |||
| 339 | + | ||||
| 340 | + | if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { | |||
| 341 | + | return false | |||
| 342 | + | } | |||
| 343 | + | ||||
| 344 | + | errorStr := err.Error() | |||
| 345 | + | retryableErrors := []string{ | |||
| 346 | + | "connection reset by peer", | |||
| 347 | + | "broken pipe", | |||
| 348 | + | "connection refused", | |||
| 349 | + | "no such host", | |||
| 350 | + | "i/o timeout", | |||
| 351 | + | "unexpected EOF", | |||
| 352 | + | "connection timed out", | |||
| 353 | + | "network is unreachable", | |||
| 354 | + | } | |||
| 355 | + | ||||
| 356 | + | for _, retryableErr := range retryableErrors { | |||
| 357 | + | if strings.Contains(strings.ToLower(errorStr), retryableErr) { | |||
| 358 | + | return true | |||
| 359 | + | } | |||
| 360 | + | } | |||
| 361 | + | ||||
| 362 | + | return false | |||
| 363 | + | } | |||
| 364 | + | ||||
| 365 | + | var lastErr error | |||
| 366 | + | for attempt := 0; attempt < maxRetries; attempt++ { | |||
| 367 | + | resp, err := c.sendChunk(ctx, endpoint, headers, chunk, metadata) | |||
| 368 | + | if err == nil { | |||
| 369 | + | return resp, nil | |||
| 370 | + | } | |||
| 371 | + | ||||
| 372 | + | lastErr = err | |||
| 373 | + | ||||
| 374 | + | if !isRetryableError(err) { | |||
| 375 | + | return nil, err | |||
| 376 | + | } | |||
| 377 | + | ||||
| 378 | + | if attempt < maxRetries-1 { | |||
| 379 | + | retryDelay := backoff * time.Duration(attempt+1) | |||
| 380 | + | jitter := time.Duration(rand.Int63n(int64(retryDelay) / 2)) | |||
| 381 | + | ||||
| 382 | + | select { | |||
| 383 | + | case <-ctx.Done(): | |||
| 384 | + | return nil, ctx.Err() | |||
| 385 | + | case <-time.After(retryDelay + jitter): | |||
| 386 | + | continue | |||
| 387 | + | } | |||
| 388 | + | } | |||
| 389 | + | } | |||
| 390 | + | ||||
| 391 | + | return nil, fmt.Errorf("max retries exceeded after %d attempts: %w", maxRetries, lastErr) | |||
| 392 | + | } | |||
| 393 | + | ||||
| 394 | + | func (c *ChunkedClient) Cancel() { | |||
| 395 | + | if c.cancelFunc != nil { | |||
| 396 | + | c.cancelFunc() | |||
| 397 | + | } | |||
| 398 | + | } | |||
| 399 | + | ||||
| 400 | + | // Response handling functions | |||
| 117 | func determineResponseType(endpoint string) string { | 401 | func determineResponseType(endpoint string) string { | ||
| 118 | switch { | 402 | switch { | ||
| 119 | case strings.Contains(endpoint, "/deploy"): | 403 | case strings.Contains(endpoint, "/deploy"): | ||
| @@ -125,158 +409,170 @@ func determineResponseType(endpoint string) string { | |||||
| 125 | } | 409 | } | ||
| 126 | } | 410 | } | ||
| 127 | 411 | ||||
| 128 | - | func handleChunkResponse(resp *Response, responseType string) error { | 412 | + | // handleFinalResponse determines the response type and calls the appropriate handler |
| 413 | + | func handleFinalResponse(resp *Response, responseType string) error { | |||
| 414 | + | if resp == nil || len(resp.Body) == 0 { | |||
| 415 | + | return fmt.Errorf("empty response received") | |||
| 416 | + | } | |||
| 417 | + | ||||
| 129 | switch responseType { | 418 | switch responseType { | ||
| 130 | case "deploy": | 419 | case "deploy": | ||
| 131 | - | var deployResp common.DeployResponse | 420 | + | return handleDeployResponse(resp) |
| 132 | - | if err := json.Unmarshal(resp.Body, &deployResp); err != nil { | |||
| 133 | - | return fmt.Errorf("failed to unmarshal deploy response: %w", err) | |||
| 134 | - | } | |||
| 135 | - | return nil | |||
| 136 | - | ||||
| 137 | case "push": | 421 | case "push": | ||
| 138 | - | var pushResp common.PushResponse | 422 | + | return handlePushResponse(resp) |
| 139 | - | if err := json.Unmarshal(resp.Body, &pushResp); err != nil { | |||
| 140 | - | return fmt.Errorf("failed to unmarshal push response: %w", err) | |||
| 141 | - | } | |||
| 142 | - | ||||
| 143 | - | return nil | |||
| 144 | - | ||||
| 145 | default: | 423 | default: | ||
| 146 | return fmt.Errorf("unknown response type: %s", responseType) | 424 | return fmt.Errorf("unknown response type: %s", responseType) | ||
| 147 | } | 425 | } | ||
| 148 | } | 426 | } | ||
| 149 | 427 | ||||
| 150 | - | func handleFinalResponse(resp *Response, responseType string) error { | 428 | + | // handleDeployResponse handles the response from a deploy operation |
| 151 | - | switch responseType { | 429 | + | func handleDeployResponse(resp *Response) error { |
| 152 | - | case "deploy": | 430 | + | // If not a conflict, process as normal deploy response |
| 153 | - | var finalResp common.DeployResponse | 431 | + | var finalResp common.DeployResponse |
| 154 | - | if err := json.Unmarshal(resp.Body, &finalResp); err != nil { | 432 | + | if err := json.Unmarshal(resp.Body, &finalResp); err != nil { |
| 155 | - | return fmt.Errorf("failed to unmarshal final deploy response: %w", err) | 433 | + | return fmt.Errorf("failed to unmarshal deploy response: %w", err) |
| 156 | - | } | 434 | + | } |
| 157 | - | // only keep the 8 first characters of the container ID | |||
| 158 | - | finalResp.ContainerID = finalResp.ContainerID[:8] | |||
| 159 | 435 | ||||
| 160 | - | log.Info( | 436 | + | // Always log the success message with available information |
| 161 | - | finalResp.Message, | 437 | + | logFields := []interface{}{} |
| 162 | - | "domain", finalResp.Domain, | |||
| 163 | - | "containerID", finalResp.ContainerID, | |||
| 164 | - | "containerName", finalResp.ContainerName) | |||
| 165 | - | return nil | |||
| 166 | 438 | ||||
| 167 | - | case "push": | 439 | + | if finalResp.Domain != "" { |
| 168 | - | var finalResp common.PushResponse | 440 | + | logFields = append(logFields, "domain", finalResp.Domain) |
| 169 | - | if err := json.Unmarshal(resp.Body, &finalResp); err != nil { | 441 | + | } |
| 170 | - | return fmt.Errorf("failed to unmarshal final push response: %w", err) | 442 | + | if finalResp.ContainerID != "" { |
| 443 | + | shortContainerID := finalResp.ContainerID | |||
| 444 | + | if len(shortContainerID) > 8 { | |||
| 445 | + | shortContainerID = shortContainerID[:8] | |||
| 171 | } | 446 | } | ||
| 172 | - | log.Info( | 447 | + | logFields = append(logFields, "containerID", shortContainerID) |
| 173 | - | finalResp.Message, | 448 | + | } |
| 174 | - | "createContainerURL", finalResp.CreateContainerURL, | 449 | + | if finalResp.ContainerName != "" { |
| 175 | - | "imageID", finalResp.ImageID) | 450 | + | logFields = append(logFields, "containerName", finalResp.ContainerName) |
| 176 | - | return nil | 451 | + | } |
| 177 | 452 | ||||
| 178 | - | default: | 453 | + | // If there's a message, use it; otherwise, use a default success message |
| 179 | - | return fmt.Errorf("unknown response type: %s", responseType) | 454 | + | message := finalResp.Message |
| 455 | + | if message == "" { | |||
| 456 | + | message = "Deployment completed successfully" | |||
| 180 | } | 457 | } | ||
| 181 | - | } | |||
| 182 | 458 | ||||
| 183 | - | func (c *ChunkedClient) sendChunkWithRetry(ctx context.Context, endpoint string, headers http.Header, chunk []byte, metadata common.ChunkMetadata) (*Response, error) { | 459 | + | // Ensure we log the result even if there are no additional fields |
| 184 | - | // For the first chunk (conflict check), only try once | 460 | + | if len(logFields) > 0 { |
| 185 | - | maxRetries := 3 | 461 | + | log.Info(message, logFields...) |
| 186 | - | if metadata.ChunkNumber == 0 && len(chunk) == 1024 { | 462 | + | } else { |
| 187 | - | maxRetries = 1 | 463 | + | log.Info(message) |
| 188 | } | 464 | } | ||
| 189 | 465 | ||||
| 190 | - | var lastErr error | 466 | + | return nil |
| 191 | - | for attempt := 0; attempt < maxRetries; attempt++ { | 467 | + | } |
| 192 | - | resp, err := c.sendChunk(ctx, endpoint, headers, chunk, metadata) | |||
| 193 | - | if err == nil { | |||
| 194 | - | return resp, nil | |||
| 195 | - | } | |||
| 196 | - | lastErr = err | |||
| 197 | 468 | ||||
| 198 | - | // Don't log retry messages for the conflict check | 469 | + | // handlePushResponse handles the response from a push operation |
| 199 | - | if maxRetries > 1 { | 470 | + | func handlePushResponse(resp *Response) error { |
| 200 | - | log.Warn("Chunk upload failed, retrying", | 471 | + | var finalResp common.PushResponse |
| 201 | - | "chunk", metadata.ChunkNumber, | 472 | + | if err := json.Unmarshal(resp.Body, &finalResp); err != nil { |
| 202 | - | "attempt", attempt+1, | 473 | + | return fmt.Errorf("failed to unmarshal push response: %w", err) |
| 203 | - | "error", err) | |||
| 204 | - | time.Sleep(time.Second * time.Duration(attempt+1)) | |||
| 205 | - | } | |||
| 206 | } | 474 | } | ||
| 207 | - | return nil, fmt.Errorf("max retries exceeded: %w", lastErr) | 475 | + | return nil |
| 208 | } | 476 | } | ||
| 209 | 477 | ||||
| 478 | + | // sendChunk sends a single chunk to the server | |||
| 210 | func (c *ChunkedClient) sendChunk(ctx context.Context, endpoint string, headers http.Header, chunk []byte, metadata common.ChunkMetadata) (*Response, error) { | 479 | func (c *ChunkedClient) sendChunk(ctx context.Context, endpoint string, headers http.Header, chunk []byte, metadata common.ChunkMetadata) (*Response, error) { | ||
| 211 | - | reauthenticated := false | 480 | + | if err := verifyChunkIntegrity(chunk, metadata); err != nil { |
| 212 | - | 481 | + | return nil, fmt.Errorf("chunk integrity check failed: %w", err) | |
| 482 | + | } | |||
| 213 | for { | 483 | for { | ||
| 214 | - | ctx, cancel := context.WithTimeout(ctx, 10*time.Minute) | 484 | + | select { |
| 215 | - | defer cancel() | 485 | + | case <-ctx.Done(): |
| 216 | - | 486 | + | return nil, ctx.Err() | |
| 217 | - | metadataJSON, err := json.Marshal(metadata) | 487 | + | default: |
| 218 | - | if err != nil { | 488 | + | resp, err := c.attemptChunkSend(ctx, endpoint, headers, chunk, metadata) |
| 219 | - | return nil, fmt.Errorf("failed to marshal metadata: %w", err) | 489 | + | if err != nil { |
| 220 | - | } | 490 | + | return nil, err |
| 221 | - | 491 | + | } | |
| 222 | - | // Create the request | 492 | + | return resp, nil |
| 223 | - | url := c.baseURL + endpoint | |||
| 224 | - | req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(chunk)) | |||
| 225 | - | if err != nil { | |||
| 226 | - | return nil, fmt.Errorf("failed to create request: %w", err) | |||
| 227 | } | 493 | } | ||
| 494 | + | } | |||
| 495 | + | } | |||
| 228 | 496 | ||||
| 229 | - | // Set headers | 497 | + | func (c *ChunkedClient) attemptChunkSend(ctx context.Context, endpoint string, headers http.Header, chunk []byte, metadata common.ChunkMetadata) (*Response, error) { |
| 230 | - | req.Header.Set("Authorization", "Bearer "+c.token) | 498 | + | metadataJSON, err := json.Marshal(metadata) |
| 231 | - | req.Header.Set("Content-Type", "application/octet-stream") | 499 | + | if err != nil { |
| 232 | - | req.Header.Set("X-Chunk-Metadata", string(metadataJSON)) | 500 | + | return nil, fmt.Errorf("failed to marshal metadata: %w", err) |
| 501 | + | } | |||
| 233 | 502 | ||||
| 234 | - | for k, v := range headers { | 503 | + | url := c.baseURL + endpoint |
| 235 | - | req.Header[k] = v | 504 | + | req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(chunk)) |
| 236 | - | } | 505 | + | if err != nil { |
| 506 | + | return nil, fmt.Errorf("failed to create request: %w", err) | |||
| 507 | + | } | |||
| 237 | 508 | ||||
| 238 | - | // Send request | 509 | + | // Set headers |
| 239 | - | resp, err := c.client.Do(req) | 510 | + | req.Header.Set("Authorization", "Bearer "+c.token) |
| 240 | - | if err != nil { | 511 | + | req.Header.Set("Content-Type", "application/octet-stream") |
| 241 | - | return nil, fmt.Errorf("request failed: %w", err) | 512 | + | req.Header.Set("X-Chunk-Metadata", string(metadataJSON)) |
| 242 | - | } | 513 | + | req.Header.Set("X-Chunk-Hash", fmt.Sprintf("%x", sha256.Sum256(chunk))) |
| 243 | - | defer resp.Body.Close() | |||
| 244 | 514 | ||||
| 245 | - | // Handle 401 Unauthorized | 515 | + | for k, v := range headers { |
| 246 | - | if resp.StatusCode == http.StatusUnauthorized && !reauthenticated { | 516 | + | req.Header[k] = v |
| 247 | - | log.Warn("Token expired, initiating re-authentication") | 517 | + | } |
| 248 | 518 | ||||
| 249 | - | err := ReAuthenticate(c.app) | 519 | + | resp, err := c.client.Do(req) |
| 250 | - | if err != nil { | 520 | + | if err != nil { |
| 251 | - | return nil, fmt.Errorf("re-authentication failed: %w", err) | 521 | + | return nil, err |
| 252 | - | } | 522 | + | } |
| 523 | + | defer resp.Body.Close() | |||
| 253 | 524 | ||||
| 254 | - | // Update the client's token with the new one | 525 | + | body, err := io.ReadAll(resp.Body) |
| 255 | - | c.token = c.app.Config.General.Token | 526 | + | if err != nil { |
| 256 | - | reauthenticated = true | 527 | + | return nil, fmt.Errorf("failed to read response: %w", err) |
| 257 | - | continue | 528 | + | } |
| 258 | - | } | |||
| 259 | 529 | ||||
| 260 | - | // Handle non-200 status codes | 530 | + | if resp.StatusCode != http.StatusOK { |
| 261 | - | if resp.StatusCode != http.StatusOK { | 531 | + | return &Response{ |
| 262 | - | body, _ := io.ReadAll(resp.Body) | 532 | + | Http: resp, |
| 263 | - | return nil, &common.DeploymentError{ | 533 | + | Body: body, |
| 534 | + | StatusCode: resp.StatusCode, | |||
| 535 | + | Header: resp.Header, | |||
| 536 | + | }, &common.DeploymentError{ | |||
| 264 | StatusCode: resp.StatusCode, | 537 | StatusCode: resp.StatusCode, | ||
| 538 | + | Message: "Request failed", | |||
| 265 | RawResponse: string(body), | 539 | RawResponse: string(body), | ||
| 266 | } | 540 | } | ||
| 267 | - | } | 541 | + | } |
| 268 | 542 | ||||
| 269 | - | // Read response body | 543 | + | return &Response{ |
| 270 | - | body, err := io.ReadAll(resp.Body) | 544 | + | Http: resp, |
| 271 | - | if err != nil { | 545 | + | Body: body, |
| 272 | - | return nil, fmt.Errorf("failed to read response: %w", err) | 546 | + | StatusCode: resp.StatusCode, |
| 273 | - | } | 547 | + | Header: resp.Header, |
| 548 | + | }, nil | |||
| 549 | + | } | |||
| 274 | 550 | ||||
| 275 | - | return &Response{ | 551 | + | // verifyChunkIntegrity verifies the integrity of a chunk |
| 276 | - | Http: resp, | 552 | + | func verifyChunkIntegrity(chunk []byte, metadata common.ChunkMetadata) error { |
| 277 | - | Body: body, | 553 | + | if len(chunk) == 0 { |
| 278 | - | StatusCode: resp.StatusCode, | 554 | + | return fmt.Errorf("empty chunk received") |
| 279 | - | Header: resp.Header, | |||
| 280 | - | }, nil | |||
| 281 | } | 555 | } | ||
| 556 | + | ||||
| 557 | + | if int64(len(chunk)) != metadata.ChunkSize { | |||
| 558 | + | return fmt.Errorf("chunk size mismatch: expected %d, got %d", | |||
| 559 | + | metadata.ChunkSize, len(chunk)) | |||
| 560 | + | } | |||
| 561 | + | ||||
| 562 | + | return nil | |||
| 563 | + | } | |||
| 564 | + | ||||
| 565 | + | // isAuthError checks if a response is an authentication error | |||
| 566 | + | func isAuthError(resp *Response) bool { | |||
| 567 | + | return resp != nil && resp.StatusCode == http.StatusUnauthorized | |||
| 568 | + | } | |||
| 569 | + | ||||
| 570 | + | // cleanupClientTransfer cleans up a client transfer | |||
| 571 | + | func cleanupClientTransfer(transferID string) { | |||
| 572 | + | clientChunkStore.mu.Lock() | |||
| 573 | + | defer clientChunkStore.mu.Unlock() | |||
| 574 | + | ||||
| 575 | + | delete(clientChunkStore.chunks, transferID) | |||
| 576 | + | delete(clientChunkStore.metadata, transferID) | |||
| 577 | + | delete(clientChunkStore.started, transferID) | |||
| 282 | } | 578 | } | ||
M internal/cli/handler/deploy_survey.go +20 -6
| @@ -5,6 +5,7 @@ package handler | |||
| 5 | 5 | import ( | |
| 6 | 6 | "encoding/json" | |
| 7 | 7 | "fmt" | |
| 8 | + | "time" | |
| 8 | 9 | ||
| 9 | 10 | "github.com/AlecAivazis/survey/v2" | |
| 10 | 11 | "github.com/bnema/gordon/internal/cli" | |
| @@ -12,13 +13,26 @@ import ( | |||
| 12 | 13 | "github.com/charmbracelet/log" | |
| 13 | 14 | ) | |
| 14 | 15 | ||
| 15 | - | func HandleExistingContainer(a *cli.App, deployResponse *common.DeployResponse) error { | |
| 16 | - | containerName := deployResponse.ContainerName | |
| 17 | - | shortID := deployResponse.ContainerID[:12] | |
| 16 | + | func HandleExistingContainer(app *cli.App, conflictResp *common.ConflictCheckResponse) error { | |
| 17 | + | // Add a small delay to ensure terminal is clear | |
| 18 | + | time.Sleep(100 * time.Millisecond) | |
| 19 | + | ||
| 20 | + | // Clear the current line | |
| 21 | + | fmt.Print("\033[2K\r") | |
| 22 | + | ||
| 23 | + | containerName := conflictResp.ContainerName | |
| 24 | + | shortID := conflictResp.ContainerID | |
| 25 | + | if len(shortID) > 12 { | |
| 26 | + | shortID = shortID[:12] | |
| 27 | + | } | |
| 18 | 28 | ||
| 19 | 29 | var proceed bool | |
| 20 | 30 | prompt := &survey.Confirm{ | |
| 21 | - | Message: fmt.Sprintf("Container '%s' (ID: %s) is already running. Stop and remove it?", containerName, shortID), | |
| 31 | + | Message: fmt.Sprintf("Container '%s' (ID: %s) is already running for %s. Stop and remove it?", | |
| 32 | + | containerName, | |
| 33 | + | shortID, | |
| 34 | + | conflictResp.RunningTime, | |
| 35 | + | ), | |
| 22 | 36 | Default: true, | |
| 23 | 37 | } | |
| 24 | 38 | ||
| @@ -33,14 +47,14 @@ func HandleExistingContainer(a *cli.App, deployResponse *common.DeployResponse) | |||
| 33 | 47 | log.Info("Stopping container...", "name", containerName, "id", shortID) | |
| 34 | 48 | ||
| 35 | 49 | // Stop the container | |
| 36 | - | if err := stopContainer(a, deployResponse.ContainerID); err != nil { | |
| 50 | + | if err := stopContainer(app, conflictResp.ContainerID); err != nil { | |
| 37 | 51 | return fmt.Errorf("failed to stop container '%s': %w", containerName, err) | |
| 38 | 52 | } | |
| 39 | 53 | ||
| 40 | 54 | log.Info("Removing container...", "name", containerName, "id", shortID) | |
| 41 | 55 | ||
| 42 | 56 | // Remove the container | |
| 43 | - | if err := removeContainer(a, deployResponse.ContainerID); err != nil { | |
| 57 | + | if err := removeContainer(app, conflictResp.ContainerID); err != nil { | |
| 44 | 58 | return fmt.Errorf("failed to remove container '%s': %w", containerName, err) | |
| 45 | 59 | } | |
| 46 | 60 | ||
| @@ -5,6 +5,7 @@ package handler | |||||
| 5 | import ( | 5 | import ( | ||
| 6 | "encoding/json" | 6 | "encoding/json" | ||
| 7 | "fmt" | 7 | "fmt" | ||
| 8 | + | "time" | |||
| 8 | 9 | ||||
| 9 | "github.com/AlecAivazis/survey/v2" | 10 | "github.com/AlecAivazis/survey/v2" | ||
| 10 | "github.com/bnema/gordon/internal/cli" | 11 | "github.com/bnema/gordon/internal/cli" | ||
| @@ -12,13 +13,26 @@ import ( | |||||
| 12 | "github.com/charmbracelet/log" | 13 | "github.com/charmbracelet/log" | ||
| 13 | ) | 14 | ) | ||
| 14 | 15 | ||||
| 15 | - | func HandleExistingContainer(a *cli.App, deployResponse *common.DeployResponse) error { | 16 | + | func HandleExistingContainer(app *cli.App, conflictResp *common.ConflictCheckResponse) error { |
| 16 | - | containerName := deployResponse.ContainerName | 17 | + | // Add a small delay to ensure terminal is clear |
| 17 | - | shortID := deployResponse.ContainerID[:12] | 18 | + | time.Sleep(100 * time.Millisecond) |
| 19 | + | ||||
| 20 | + | // Clear the current line | |||
| 21 | + | fmt.Print("\033[2K\r") | |||
| 22 | + | ||||
| 23 | + | containerName := conflictResp.ContainerName | |||
| 24 | + | shortID := conflictResp.ContainerID | |||
| 25 | + | if len(shortID) > 12 { | |||
| 26 | + | shortID = shortID[:12] | |||
| 27 | + | } | |||
| 18 | 28 | ||||
| 19 | var proceed bool | 29 | var proceed bool | ||
| 20 | prompt := &survey.Confirm{ | 30 | prompt := &survey.Confirm{ | ||
| 21 | - | Message: fmt.Sprintf("Container '%s' (ID: %s) is already running. Stop and remove it?", containerName, shortID), | 31 | + | Message: fmt.Sprintf("Container '%s' (ID: %s) is already running for %s. Stop and remove it?", |
| 32 | + | containerName, | |||
| 33 | + | shortID, | |||
| 34 | + | conflictResp.RunningTime, | |||
| 35 | + | ), | |||
| 22 | Default: true, | 36 | Default: true, | ||
| 23 | } | 37 | } | ||
| 24 | 38 | ||||
| @@ -33,14 +47,14 @@ func HandleExistingContainer(a *cli.App, deployResponse *common.DeployResponse) | |||||
| 33 | log.Info("Stopping container...", "name", containerName, "id", shortID) | 47 | log.Info("Stopping container...", "name", containerName, "id", shortID) | ||
| 34 | 48 | ||||
| 35 | // Stop the container | 49 | // Stop the container | ||
| 36 | - | if err := stopContainer(a, deployResponse.ContainerID); err != nil { | 50 | + | if err := stopContainer(app, conflictResp.ContainerID); err != nil { |
| 37 | return fmt.Errorf("failed to stop container '%s': %w", containerName, err) | 51 | return fmt.Errorf("failed to stop container '%s': %w", containerName, err) | ||
| 38 | } | 52 | } | ||
| 39 | 53 | ||||
| 40 | log.Info("Removing container...", "name", containerName, "id", shortID) | 54 | log.Info("Removing container...", "name", containerName, "id", shortID) | ||
| 41 | 55 | ||||
| 42 | // Remove the container | 56 | // Remove the container | ||
| 43 | - | if err := removeContainer(a, deployResponse.ContainerID); err != nil { | 57 | + | if err := removeContainer(app, conflictResp.ContainerID); err != nil { |
| 44 | return fmt.Errorf("failed to remove container '%s': %w", containerName, err) | 58 | return fmt.Errorf("failed to remove container '%s': %w", containerName, err) | ||
| 45 | } | 59 | } | ||
| 46 | 60 | ||||
M internal/cli/handler/docker.go +30 -16
| @@ -8,30 +8,44 @@ import ( | |||
| 8 | 8 | ) | |
| 9 | 9 | ||
| 10 | 10 | func ExportDockerImage(imageName string) (io.ReadCloser, int64, error) { | |
| 11 | - | exists, err := docker.CheckIfImageExists(imageName) | |
| 11 | + | // Initialize Docker client | |
| 12 | + | if err := docker.CheckIfInitialized(); err != nil { | |
| 13 | + | return nil, 0, fmt.Errorf("failed to initialize Docker client: %w", err) | |
| 14 | + | } | |
| 15 | + | ||
| 16 | + | // Get the image ID first | |
| 17 | + | imageID, err := docker.GetImageIDByName(imageName) | |
| 12 | 18 | if err != nil { | |
| 13 | - | return nil, 0, fmt.Errorf("error checking image existence: %w", err) | |
| 19 | + | return nil, 0, fmt.Errorf("failed to get image ID: %w", err) | |
| 14 | 20 | } | |
| 15 | 21 | ||
| 16 | - | var imageID string | |
| 17 | - | if exists { | |
| 18 | - | imageID = imageName | |
| 19 | - | } else { | |
| 20 | - | imageID, err = docker.GetImageIDByName(imageName) | |
| 22 | + | // Create a pipe for streaming | |
| 23 | + | pr, pw := io.Pipe() | |
| 24 | + | ||
| 25 | + | go func() { | |
| 26 | + | defer pw.Close() | |
| 27 | + | ||
| 28 | + | // Export the image using save | |
| 29 | + | reader, err := docker.ExportImageFromEngine(imageID) | |
| 21 | 30 | if err != nil { | |
| 22 | - | return nil, 0, fmt.Errorf("error searching for image by name: %w", err) | |
| 31 | + | pw.CloseWithError(fmt.Errorf("failed to export image: %w", err)) | |
| 32 | + | return | |
| 23 | 33 | } | |
| 24 | - | } | |
| 34 | + | defer reader.Close() | |
| 25 | 35 | ||
| 26 | - | actualSize, err := docker.GetImageSizeFromReader(imageID) | |
| 27 | - | if err != nil { | |
| 28 | - | return nil, 0, fmt.Errorf("error retrieving image size: %w", err) | |
| 29 | - | } | |
| 36 | + | // Copy the data to the pipe | |
| 37 | + | _, err = io.Copy(pw, reader) | |
| 38 | + | if err != nil { | |
| 39 | + | pw.CloseWithError(fmt.Errorf("failed to copy image data: %w", err)) | |
| 40 | + | return | |
| 41 | + | } | |
| 42 | + | }() | |
| 30 | 43 | ||
| 31 | - | reader, err := docker.ExportImageFromEngine(imageID) | |
| 44 | + | // Get the actual size | |
| 45 | + | size, err := docker.GetImageSize(imageID) | |
| 32 | 46 | if err != nil { | |
| 33 | - | return nil, 0, fmt.Errorf("error exporting image: %w", err) | |
| 47 | + | return nil, 0, fmt.Errorf("failed to get image size: %w", err) | |
| 34 | 48 | } | |
| 35 | 49 | ||
| 36 | - | return reader, actualSize, nil | |
| 50 | + | return pr, size, nil | |
| 37 | 51 | } | |
| @@ -8,30 +8,44 @@ import ( | |||||
| 8 | ) | 8 | ) | ||
| 9 | 9 | ||||
| 10 | func ExportDockerImage(imageName string) (io.ReadCloser, int64, error) { | 10 | func ExportDockerImage(imageName string) (io.ReadCloser, int64, error) { | ||
| 11 | - | exists, err := docker.CheckIfImageExists(imageName) | 11 | + | // Initialize Docker client |
| 12 | + | if err := docker.CheckIfInitialized(); err != nil { | |||
| 13 | + | return nil, 0, fmt.Errorf("failed to initialize Docker client: %w", err) | |||
| 14 | + | } | |||
| 15 | + | ||||
| 16 | + | // Get the image ID first | |||
| 17 | + | imageID, err := docker.GetImageIDByName(imageName) | |||
| 12 | if err != nil { | 18 | if err != nil { | ||
| 13 | - | return nil, 0, fmt.Errorf("error checking image existence: %w", err) | 19 | + | return nil, 0, fmt.Errorf("failed to get image ID: %w", err) |
| 14 | } | 20 | } | ||
| 15 | 21 | ||||
| 16 | - | var imageID string | 22 | + | // Create a pipe for streaming |
| 17 | - | if exists { | 23 | + | pr, pw := io.Pipe() |
| 18 | - | imageID = imageName | 24 | + | |
| 19 | - | } else { | 25 | + | go func() { |
| 20 | - | imageID, err = docker.GetImageIDByName(imageName) | 26 | + | defer pw.Close() |
| 27 | + | ||||
| 28 | + | // Export the image using save | |||
| 29 | + | reader, err := docker.ExportImageFromEngine(imageID) | |||
| 21 | if err != nil { | 30 | if err != nil { | ||
| 22 | - | return nil, 0, fmt.Errorf("error searching for image by name: %w", err) | 31 | + | pw.CloseWithError(fmt.Errorf("failed to export image: %w", err)) |
| 32 | + | return | |||
| 23 | } | 33 | } | ||
| 24 | - | } | 34 | + | defer reader.Close() |
| 25 | 35 | ||||
| 26 | - | actualSize, err := docker.GetImageSizeFromReader(imageID) | 36 | + | // Copy the data to the pipe |
| 27 | - | if err != nil { | 37 | + | _, err = io.Copy(pw, reader) |
| 28 | - | return nil, 0, fmt.Errorf("error retrieving image size: %w", err) | 38 | + | if err != nil { |
| 29 | - | } | 39 | + | pw.CloseWithError(fmt.Errorf("failed to copy image data: %w", err)) |
| 40 | + | return | |||
| 41 | + | } | |||
| 42 | + | }() | |||
| 30 | 43 | ||||
| 31 | - | reader, err := docker.ExportImageFromEngine(imageID) | 44 | + | // Get the actual size |
| 45 | + | size, err := docker.GetImageSize(imageID) | |||
| 32 | if err != nil { | 46 | if err != nil { | ||
| 33 | - | return nil, 0, fmt.Errorf("error exporting image: %w", err) | 47 | + | return nil, 0, fmt.Errorf("failed to get image size: %w", err) |
| 34 | } | 48 | } | ||
| 35 | 49 | ||||
| 36 | - | return reader, actualSize, nil | 50 | + | return pr, size, nil |
| 37 | } | 51 | } | ||
M internal/cli/handler/http.go +0 -26
| @@ -8,7 +8,6 @@ import ( | |||
| 8 | 8 | "net/http" | |
| 9 | 9 | ||
| 10 | 10 | "github.com/bnema/gordon/internal/cli" | |
| 11 | - | "github.com/bnema/gordon/internal/cli/auth" | |
| 12 | 11 | "github.com/bnema/gordon/internal/common" | |
| 13 | 12 | ) | |
| 14 | 13 | ||
| @@ -152,28 +151,3 @@ func createRequest(apiUrl, endpoint, method string, rp *common.RequestPayload, t | |||
| 152 | 151 | ||
| 153 | 152 | return req, nil | |
| 154 | 153 | } | |
| 155 | - | ||
| 156 | - | func ReAuthenticate(a *cli.App) error { | |
| 157 | - | fmt.Println("Re-authenticating...") | |
| 158 | - | err := auth.DeviceFlowAuth(a) | |
| 159 | - | if err != nil { | |
| 160 | - | return fmt.Errorf("device flow authentication failed: %w", err) | |
| 161 | - | } | |
| 162 | - | ||
| 163 | - | // After successful re-authentication, get the new token | |
| 164 | - | newToken, err := a.Config.GetToken() | |
| 165 | - | if err != nil { | |
| 166 | - | return fmt.Errorf("failed to get new token: %w", err) | |
| 167 | - | } | |
| 168 | - | // Update the token in the app config | |
| 169 | - | a.Config.General.Token = newToken | |
| 170 | - | ||
| 171 | - | // Save the new token to config file | |
| 172 | - | err = a.Config.SaveConfig() | |
| 173 | - | if err != nil { | |
| 174 | - | return fmt.Errorf("failed to save new token to config: %w", err) | |
| 175 | - | } | |
| 176 | - | ||
| 177 | - | fmt.Println("Re-authentication successful. Your session has been renewed.") | |
| 178 | - | return nil | |
| 179 | - | } | |
| @@ -8,7 +8,6 @@ import ( | |||||
| 8 | "net/http" | 8 | "net/http" | ||
| 9 | 9 | ||||
| 10 | "github.com/bnema/gordon/internal/cli" | 10 | "github.com/bnema/gordon/internal/cli" | ||
| 11 | - | "github.com/bnema/gordon/internal/cli/auth" | |||
| 12 | "github.com/bnema/gordon/internal/common" | 11 | "github.com/bnema/gordon/internal/common" | ||
| 13 | ) | 12 | ) | ||
| 14 | 13 | ||||
| @@ -152,28 +151,3 @@ func createRequest(apiUrl, endpoint, method string, rp *common.RequestPayload, t | |||||
| 152 | 151 | ||||
| 153 | return req, nil | 152 | return req, nil | ||
| 154 | } | 153 | } | ||
| 155 | - | ||||
| 156 | - | func ReAuthenticate(a *cli.App) error { | |||
| 157 | - | fmt.Println("Re-authenticating...") | |||
| 158 | - | err := auth.DeviceFlowAuth(a) | |||
| 159 | - | if err != nil { | |||
| 160 | - | return fmt.Errorf("device flow authentication failed: %w", err) | |||
| 161 | - | } | |||
| 162 | - | ||||
| 163 | - | // After successful re-authentication, get the new token | |||
| 164 | - | newToken, err := a.Config.GetToken() | |||
| 165 | - | if err != nil { | |||
| 166 | - | return fmt.Errorf("failed to get new token: %w", err) | |||
| 167 | - | } | |||
| 168 | - | // Update the token in the app config | |||
| 169 | - | a.Config.General.Token = newToken | |||
| 170 | - | ||||
| 171 | - | // Save the new token to config file | |||
| 172 | - | err = a.Config.SaveConfig() | |||
| 173 | - | if err != nil { | |||
| 174 | - | return fmt.Errorf("failed to save new token to config: %w", err) | |||
| 175 | - | } | |||
| 176 | - | ||||
| 177 | - | fmt.Println("Re-authentication successful. Your session has been renewed.") | |||
| 178 | - | return nil | |||
| 179 | - | } | |||
M internal/cli/handler/regex_check.go +5 -2
| @@ -14,10 +14,13 @@ func ValidateImageName(imageName string) error { | |||
| 14 | 14 | return nil | |
| 15 | 15 | } | |
| 16 | 16 | ||
| 17 | - | func EnsureImageTag(imageName *string) { | |
| 17 | + | func EnsureImageTag(imageName *string) error { | |
| 18 | + | // if not tags returns an error | |
| 18 | 19 | if !strings.Contains(*imageName, ":") { | |
| 19 | - | *imageName += ":latest" | |
| 20 | + | return fmt.Errorf("You must specify a tag for the image") | |
| 20 | 21 | } | |
| 22 | + | ||
| 23 | + | return nil | |
| 21 | 24 | } | |
| 22 | 25 | ||
| 23 | 26 | func ValidatePortMapping(port string) error { | |
| @@ -14,10 +14,13 @@ func ValidateImageName(imageName string) error { | |||||
| 14 | return nil | 14 | return nil | ||
| 15 | } | 15 | } | ||
| 16 | 16 | ||||
| 17 | - | func EnsureImageTag(imageName *string) { | 17 | + | func EnsureImageTag(imageName *string) error { |
| 18 | + | // if not tags returns an error | |||
| 18 | if !strings.Contains(*imageName, ":") { | 19 | if !strings.Contains(*imageName, ":") { | ||
| 19 | - | *imageName += ":latest" | 20 | + | return fmt.Errorf("You must specify a tag for the image") |
| 20 | } | 21 | } | ||
| 22 | + | ||||
| 23 | + | return nil | |||
| 21 | } | 24 | } | ||
| 22 | 25 | ||||
| 23 | func ValidatePortMapping(port string) error { | 26 | func ValidatePortMapping(port string) error { | ||
M internal/common/payload.go +21 -0
| @@ -86,6 +86,27 @@ type ChunkMetadata struct { | |||
| 86 | 86 | TransferID string `json:"transfer_id"` | |
| 87 | 87 | } | |
| 88 | 88 | ||
| 89 | + | type ConflictCheckResponse struct { | |
| 90 | + | Success bool `json:"success"` | |
| 91 | + | Message string `json:"message"` | |
| 92 | + | ContainerID string `json:"container_id"` | |
| 93 | + | ContainerName string `json:"container_name"` | |
| 94 | + | Domain string `json:"domain"` | |
| 95 | + | State string `json:"state"` | |
| 96 | + | RunningTime string `json:"running_time"` | |
| 97 | + | Ports string `json:"ports"` | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | type ConflictAction struct { | |
| 101 | + | Action string `json:"action"` // "stop", "remove", or "cancel" | |
| 102 | + | Force bool `json:"force"` | |
| 103 | + | } | |
| 104 | + | ||
| 105 | + | type DeviceFlowResponse struct { | |
| 106 | + | Code string `json:"code"` | |
| 107 | + | Message string `json:"message"` | |
| 108 | + | } | |
| 109 | + | ||
| 89 | 110 | // UnmarshalJSON custom unmarshaler for RequestPayload | |
| 90 | 111 | func (p *RequestPayload) UnmarshalJSON(data []byte) error { | |
| 91 | 112 | var raw map[string]json.RawMessage | |
| @@ -86,6 +86,27 @@ type ChunkMetadata struct { | |||||
| 86 | TransferID string `json:"transfer_id"` | 86 | TransferID string `json:"transfer_id"` | ||
| 87 | } | 87 | } | ||
| 88 | 88 | ||||
| 89 | + | type ConflictCheckResponse struct { | |||
| 90 | + | Success bool `json:"success"` | |||
| 91 | + | Message string `json:"message"` | |||
| 92 | + | ContainerID string `json:"container_id"` | |||
| 93 | + | ContainerName string `json:"container_name"` | |||
| 94 | + | Domain string `json:"domain"` | |||
| 95 | + | State string `json:"state"` | |||
| 96 | + | RunningTime string `json:"running_time"` | |||
| 97 | + | Ports string `json:"ports"` | |||
| 98 | + | } | |||
| 99 | + | ||||
| 100 | + | type ConflictAction struct { | |||
| 101 | + | Action string `json:"action"` // "stop", "remove", or "cancel" | |||
| 102 | + | Force bool `json:"force"` | |||
| 103 | + | } | |||
| 104 | + | ||||
| 105 | + | type DeviceFlowResponse struct { | |||
| 106 | + | Code string `json:"code"` | |||
| 107 | + | Message string `json:"message"` | |||
| 108 | + | } | |||
| 109 | + | ||||
| 89 | // UnmarshalJSON custom unmarshaler for RequestPayload | 110 | // UnmarshalJSON custom unmarshaler for RequestPayload | ||
| 90 | func (p *RequestPayload) UnmarshalJSON(data []byte) error { | 111 | func (p *RequestPayload) UnmarshalJSON(data []byte) error { | ||
| 91 | var raw map[string]json.RawMessage | 112 | var raw map[string]json.RawMessage | ||
M internal/httpserve/handlers/endpoint_deploy.go +315 -247
| @@ -2,274 +2,342 @@ | |||
| 2 | 2 | package handlers | |
| 3 | 3 | ||
| 4 | 4 | import ( | |
| 5 | - | "bytes" | |
| 6 | 5 | "encoding/json" | |
| 7 | 6 | "fmt" | |
| 8 | 7 | "io" | |
| 9 | 8 | "net/http" | |
| 9 | + | "os" | |
| 10 | + | "path/filepath" | |
| 10 | 11 | "strings" | |
| 11 | - | "time" | |
| 12 | 12 | ||
| 13 | 13 | authToken "github.com/bnema/gordon/internal/cli/auth" | |
| 14 | 14 | "github.com/bnema/gordon/internal/common" | |
| 15 | 15 | "github.com/bnema/gordon/internal/server" | |
| 16 | 16 | "github.com/bnema/gordon/pkg/docker" | |
| 17 | - | "github.com/bnema/gordon/pkg/store" | |
| 18 | 17 | "github.com/charmbracelet/log" | |
| 19 | 18 | "github.com/labstack/echo/v4" | |
| 20 | 19 | ) | |
| 21 | 20 | ||
| 22 | 21 | // PostDeploy handles the container deployment request | |
| 23 | 22 | func PostDeploy(c echo.Context, a *server.App) error { | |
| 24 | - | chunkMetadataStr := c.Request().Header.Get("X-Chunk-Metadata") | |
| 23 | + | payload, err := validateAndPrepareDeployPayload(c) | |
| 24 | + | if err != nil { | |
| 25 | + | // Debug | |
| 26 | + | log.Error("Failed to validate and prepare payload", "error", err) | |
| 27 | + | return sendJSONResponse(c, http.StatusBadRequest, DeployResponse{ | |
| 28 | + | Success: false, | |
| 29 | + | Message: fmt.Sprintf("Invalid payload: %v", err), | |
| 30 | + | }) | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | _, err = saveAndImportDeployImage(c, a, payload) | |
| 34 | + | if err != nil { | |
| 35 | + | // Debug | |
| 36 | + | log.Error("Failed to save and import image", "error", err) | |
| 37 | + | return sendJSONResponse(c, http.StatusInternalServerError, DeployResponse{ | |
| 38 | + | Success: false, | |
| 39 | + | Message: fmt.Sprintf("Failed to save or import image: %v", err), | |
| 40 | + | }) | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | containerID, containerName, err := createAndStartContainer(a, payload) | |
| 44 | + | if err != nil { | |
| 45 | + | if strings.Contains(err.Error(), "is already in use") { | |
| 46 | + | log.Warn("Container already exists", "error", err) | |
| 47 | + | existingContainerID := extractContainerID(err.Error()) | |
| 48 | + | existingContainerName, _ := docker.GetContainerName(existingContainerID) | |
| 49 | + | // Remove the / from the container name | |
| 50 | + | existingContainerName = strings.TrimLeft(existingContainerName, "/") | |
| 51 | + | ||
| 52 | + | if existingContainerID == "" || existingContainerName == "" { | |
| 53 | + | log.Error("Failed to extract container ID or Name from error message") | |
| 54 | + | return sendJSONResponse(c, http.StatusConflict, DeployResponse{ | |
| 55 | + | Success: false, | |
| 56 | + | Message: "A container for this deploy already exists but could not be identified.", | |
| 57 | + | }) | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | return sendJSONResponse(c, http.StatusConflict, DeployResponse{ | |
| 61 | + | Success: false, | |
| 62 | + | Message: "A container for this deploy already exists.", | |
| 63 | + | ContainerID: existingContainerID, | |
| 64 | + | ContainerName: existingContainerName, | |
| 65 | + | }) | |
| 66 | + | } | |
| 67 | + | log.Error("Failed to create or start container", "error", err) | |
| 68 | + | return sendJSONResponse(c, http.StatusInternalServerError, DeployResponse{ | |
| 69 | + | Success: false, | |
| 70 | + | Message: fmt.Sprintf("Failed to create or start container: %v", err), | |
| 71 | + | }) | |
| 72 | + | } | |
| 73 | + | response := DeployResponse{ | |
| 74 | + | Success: true, | |
| 75 | + | Message: "Deployment successful", | |
| 76 | + | Domain: payload.TargetDomain, | |
| 77 | + | ContainerID: containerID, | |
| 78 | + | ContainerName: containerName, | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | // Debug: Verify containerID in response | |
| 82 | + | if response.ContainerID != containerID { | |
| 83 | + | log.Error("Container ID mismatch", "responseID", response.ContainerID, "actualID", containerID) | |
| 84 | + | } else { | |
| 85 | + | log.Info("Container ID successfully stored in response", "containerID", containerID) | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | return sendJSONResponse(c, http.StatusOK, response) | |
| 89 | + | } | |
| 25 | 90 | ||
| 26 | - | return handleChunkedDeploy(c, a, chunkMetadataStr) | |
| 91 | + | // PostDeployChunked handles the chunked container deployment request | |
| 92 | + | func PostDeployChunked(c echo.Context, a *server.App) error { | |
| 93 | + | return handleChunkedDeploy(c, a, c.Request().Header.Get("X-Chunk-Metadata")) | |
| 27 | 94 | } | |
| 28 | 95 | ||
| 29 | 96 | func handleChunkedDeploy(c echo.Context, a *server.App, metadataStr string) error { | |
| 30 | - | // First, get the target domain from headers | |
| 31 | - | targetDomain := c.Request().Header.Get("X-Target-Domain") | |
| 32 | - | if targetDomain == "" { | |
| 33 | - | return sendJSONResponse(c, http.StatusBadRequest, DeployResponse{ | |
| 34 | - | Success: false, | |
| 35 | - | Message: "Target domain is required", | |
| 36 | - | }) | |
| 37 | - | } | |
| 38 | - | ||
| 39 | - | // Extract container name from the target domain | |
| 40 | - | cleanDomain := strings.TrimPrefix(strings.TrimPrefix(targetDomain, "https://"), "http://") | |
| 41 | - | containerName := strings.Split(cleanDomain, ".")[0] | |
| 42 | - | ||
| 43 | - | log.Info("Checking for existing container", "name", containerName) | |
| 44 | - | ||
| 45 | - | // Check if container already exists | |
| 46 | - | containerID := docker.GetContainerIDByName(containerName) | |
| 47 | - | if containerID != "" { | |
| 48 | - | log.Warn("Container already exists", | |
| 49 | - | "name", containerName, | |
| 50 | - | "id", containerID) | |
| 51 | - | return sendJSONResponse(c, http.StatusConflict, DeployResponse{ | |
| 52 | - | Success: false, | |
| 53 | - | Message: fmt.Sprintf("Container with name '%s' already exists", containerName), | |
| 54 | - | ContainerID: containerID, | |
| 55 | - | ContainerName: containerName, | |
| 56 | - | }) | |
| 57 | - | } | |
| 58 | - | ||
| 59 | - | payload, err := validateAndPrepareDeployPayload(c) | |
| 60 | - | if err != nil { | |
| 61 | - | log.Error("Failed to validate and prepare payload", "error", err) | |
| 62 | - | return sendJSONResponse(c, http.StatusBadRequest, DeployResponse{ | |
| 63 | - | Success: false, | |
| 64 | - | Message: fmt.Sprintf("Invalid payload: %v", err), | |
| 65 | - | }) | |
| 66 | - | } | |
| 67 | - | ||
| 68 | - | // Authenticate request | |
| 69 | - | auth := c.Request().Header.Get("Authorization") | |
| 70 | - | if auth == "" { | |
| 71 | - | return sendJSONResponse(c, http.StatusUnauthorized, DeployResponse{ | |
| 72 | - | Success: false, | |
| 73 | - | Message: "Authentication required", | |
| 74 | - | }) | |
| 75 | - | } | |
| 76 | - | ||
| 77 | - | token := strings.TrimPrefix(auth, "Bearer ") | |
| 78 | - | if !authToken.IsValidGitHubToken(token) { | |
| 79 | - | return sendJSONResponse(c, http.StatusUnauthorized, DeployResponse{ | |
| 80 | - | Success: false, | |
| 81 | - | Message: "Invalid or expired token", | |
| 82 | - | }) | |
| 83 | - | } | |
| 84 | - | ||
| 85 | - | // Parse chunk metadata | |
| 86 | - | var metadata ChunkMetadata | |
| 87 | - | if err := json.Unmarshal([]byte(metadataStr), &metadata); err != nil { | |
| 88 | - | return sendJSONResponse(c, http.StatusBadRequest, DeployResponse{ | |
| 89 | - | Success: false, | |
| 90 | - | Message: "Invalid chunk metadata", | |
| 91 | - | }) | |
| 92 | - | } | |
| 93 | - | ||
| 94 | - | // Store chunk data | |
| 95 | - | chunkStore.mu.Lock() | |
| 96 | - | if chunkStore.chunks[metadata.TransferID] == nil { | |
| 97 | - | chunkStore.chunks[metadata.TransferID] = make(map[int][]byte) | |
| 98 | - | chunkStore.metadata[metadata.TransferID] = &metadata | |
| 99 | - | } | |
| 100 | - | ||
| 101 | - | chunkData, err := io.ReadAll(c.Request().Body) | |
| 102 | - | if err != nil { | |
| 103 | - | chunkStore.mu.Unlock() | |
| 104 | - | return sendJSONResponse(c, http.StatusInternalServerError, DeployResponse{ | |
| 105 | - | Success: false, | |
| 106 | - | Message: "Failed to read chunk data", | |
| 107 | - | }) | |
| 108 | - | } | |
| 109 | - | ||
| 110 | - | chunkStore.chunks[metadata.TransferID][metadata.ChunkNumber] = chunkData | |
| 111 | - | chunkStore.mu.Unlock() | |
| 112 | - | ||
| 113 | - | // If this is the last chunk, process the complete transfer | |
| 114 | - | if isTransferComplete(metadata.TransferID) { | |
| 115 | - | return processCompleteDeployTransfer(c, a, metadata.TransferID) | |
| 116 | - | } | |
| 117 | - | ||
| 118 | - | // Include domain information in intermediate chunk responses | |
| 119 | - | return sendJSONResponse(c, http.StatusOK, DeployResponse{ | |
| 120 | - | Success: true, | |
| 121 | - | Message: fmt.Sprintf("Chunk %d received successfully", metadata.ChunkNumber), | |
| 122 | - | Domain: payload.TargetDomain, | |
| 123 | - | }) | |
| 97 | + | var metadata ChunkMetadata | |
| 98 | + | if err := json.Unmarshal([]byte(metadataStr), &metadata); err != nil { | |
| 99 | + | return sendJSONResponse(c, http.StatusBadRequest, DeployResponse{ | |
| 100 | + | Success: false, | |
| 101 | + | Message: "Invalid chunk metadata", | |
| 102 | + | }) | |
| 103 | + | } | |
| 104 | + | ||
| 105 | + | payload, err := validateAndPrepareDeployPayload(c) | |
| 106 | + | if err != nil { | |
| 107 | + | log.Error("Failed to validate and prepare payload", "error", err) | |
| 108 | + | return sendJSONResponse(c, http.StatusBadRequest, DeployResponse{ | |
| 109 | + | Success: false, | |
| 110 | + | Message: fmt.Sprintf("Invalid payload: %v", err), | |
| 111 | + | }) | |
| 112 | + | } | |
| 113 | + | ||
| 114 | + | // Authenticate request | |
| 115 | + | auth := c.Request().Header.Get("Authorization") | |
| 116 | + | if auth == "" { | |
| 117 | + | return sendJSONResponse(c, http.StatusUnauthorized, DeployResponse{ | |
| 118 | + | Success: false, | |
| 119 | + | Message: "Authentication required", | |
| 120 | + | }) | |
| 121 | + | } | |
| 122 | + | ||
| 123 | + | token := strings.TrimPrefix(auth, "Bearer ") | |
| 124 | + | if !authToken.IsValidGitHubToken(token) { | |
| 125 | + | return sendJSONResponse(c, http.StatusUnauthorized, DeployResponse{ | |
| 126 | + | Success: false, | |
| 127 | + | Message: "Invalid or expired token", | |
| 128 | + | }) | |
| 129 | + | } | |
| 130 | + | ||
| 131 | + | if metadata.TotalChunks == 1 { | |
| 132 | + | chunkData, err := io.ReadAll(c.Request().Body) | |
| 133 | + | if err != nil { | |
| 134 | + | return sendJSONResponse(c, http.StatusInternalServerError, DeployResponse{ | |
| 135 | + | Success: false, | |
| 136 | + | Message: "Failed to read chunk data", | |
| 137 | + | }) | |
| 138 | + | } | |
| 139 | + | ||
| 140 | + | // Store the data directly | |
| 141 | + | chunkStore.mu.Lock() | |
| 142 | + | chunkStore.chunks[metadata.TransferID] = make(map[int][]byte) | |
| 143 | + | chunkStore.chunks[metadata.TransferID][0] = chunkData | |
| 144 | + | chunkStore.metadata[metadata.TransferID] = &metadata | |
| 145 | + | chunkStore.mu.Unlock() | |
| 146 | + | ||
| 147 | + | // Process the complete transfer immediately | |
| 148 | + | return processCompleteChunkedDeployTansfert(c, a, metadata.TransferID) | |
| 149 | + | } | |
| 150 | + | ||
| 151 | + | // Store chunk data | |
| 152 | + | chunkStore.mu.Lock() | |
| 153 | + | if chunkStore.chunks[metadata.TransferID] == nil { | |
| 154 | + | chunkStore.chunks[metadata.TransferID] = make(map[int][]byte) | |
| 155 | + | chunkStore.metadata[metadata.TransferID] = &metadata | |
| 156 | + | } | |
| 157 | + | ||
| 158 | + | chunkData, err := io.ReadAll(c.Request().Body) | |
| 159 | + | if err != nil { | |
| 160 | + | chunkStore.mu.Unlock() | |
| 161 | + | return sendJSONResponse(c, http.StatusInternalServerError, DeployResponse{ | |
| 162 | + | Success: false, | |
| 163 | + | Message: "Failed to read chunk data", | |
| 164 | + | }) | |
| 165 | + | } | |
| 166 | + | ||
| 167 | + | // Store the complete chunk | |
| 168 | + | chunkStore.chunks[metadata.TransferID][metadata.ChunkNumber] = chunkData | |
| 169 | + | chunkStore.mu.Unlock() | |
| 170 | + | ||
| 171 | + | // If this is the last chunk, process the complete transfer | |
| 172 | + | if isTransferComplete(metadata.TransferID) { | |
| 173 | + | return processCompleteChunkedDeployTansfert(c, a, metadata.TransferID) | |
| 174 | + | } | |
| 175 | + | ||
| 176 | + | // Include domain information in intermediate chunk responses | |
| 177 | + | return sendJSONResponse(c, http.StatusOK, DeployResponse{ | |
| 178 | + | Success: true, | |
| 179 | + | Message: fmt.Sprintf("Chunk %d/%d received", metadata.ChunkNumber+1, metadata.TotalChunks), | |
| 180 | + | Domain: payload.TargetDomain, | |
| 181 | + | }) | |
| 124 | 182 | } | |
| 125 | 183 | ||
| 126 | - | func processCompleteDeployTransfer(c echo.Context, a *server.App, transferID string) error { | |
| 127 | - | log.Info("Starting complete deploy transfer processing", "transferID", transferID) | |
| 128 | - | ||
| 129 | - | // Get deployment-specific headers | |
| 130 | - | port := normalizePort(c.Request().Header.Get("X-Ports")) | |
| 131 | - | targetDomain := normalizeTargetDomain(c.Request().Header.Get("X-Target-Domain")) | |
| 132 | - | imageName := c.Request().Header.Get("X-Image-Name") | |
| 133 | - | ||
| 134 | - | // Extract container name and check again (safety check) | |
| 135 | - | cleanDomain := strings.TrimPrefix(strings.TrimPrefix(targetDomain, "https://"), "http://") | |
| 136 | - | containerName := strings.Split(cleanDomain, ".")[0] | |
| 137 | - | ||
| 138 | - | containerID := docker.GetContainerIDByName(containerName) | |
| 139 | - | if containerID != "" { | |
| 140 | - | log.Warn("Container already exists during final processing", | |
| 141 | - | "name", containerName, | |
| 142 | - | "id", containerID) | |
| 143 | - | return sendJSONResponse(c, http.StatusConflict, DeployResponse{ | |
| 144 | - | Success: false, | |
| 145 | - | Message: fmt.Sprintf("Container with name '%s' already exists", containerName), | |
| 146 | - | ContainerID: containerID, | |
| 147 | - | ContainerName: containerName, | |
| 148 | - | }) | |
| 149 | - | } | |
| 150 | - | ||
| 151 | - | // Combine chunks | |
| 152 | - | chunkStore.mu.Lock() | |
| 153 | - | metadata := chunkStore.metadata[transferID] | |
| 154 | - | chunks := chunkStore.chunks[transferID] | |
| 155 | - | chunkStore.mu.Unlock() | |
| 156 | - | ||
| 157 | - | if metadata == nil { | |
| 158 | - | return sendJSONResponse(c, http.StatusInternalServerError, DeployResponse{ | |
| 159 | - | Success: false, | |
| 160 | - | Message: "Transfer metadata not found", | |
| 161 | - | }) | |
| 162 | - | } | |
| 163 | - | ||
| 164 | - | combinedData := bytes.NewBuffer(nil) | |
| 165 | - | for i := 0; i < metadata.TotalChunks; i++ { | |
| 166 | - | chunk, exists := chunks[i] | |
| 167 | - | if !exists { | |
| 168 | - | return sendJSONResponse(c, http.StatusInternalServerError, DeployResponse{ | |
| 169 | - | Success: false, | |
| 170 | - | Message: fmt.Sprintf("Missing chunk %d", i), | |
| 171 | - | }) | |
| 172 | - | } | |
| 173 | - | combinedData.Write(chunk) | |
| 174 | - | } | |
| 175 | - | ||
| 176 | - | // Clean up the transfer data | |
| 177 | - | defer cleanupTransfer(transferID) | |
| 178 | - | ||
| 179 | - | // Save and import the image | |
| 180 | - | imageFileName := sanitizeImageFileName(imageName) | |
| 181 | - | imagePath, err := store.SaveImageToStorage(&a.Config, imageFileName, combinedData) | |
| 182 | - | if err != nil { | |
| 183 | - | return sendJSONResponse(c, http.StatusInternalServerError, DeployResponse{ | |
| 184 | - | Success: false, | |
| 185 | - | Message: fmt.Sprintf("Failed to save image: %v", err), | |
| 186 | - | }) | |
| 187 | - | } | |
| 188 | - | ||
| 189 | - | imageID, err := docker.ImportImageToEngine(imagePath) | |
| 190 | - | if err != nil { | |
| 191 | - | store.RemoveFromStorage(imagePath) | |
| 192 | - | return sendJSONResponse(c, http.StatusInternalServerError, DeployResponse{ | |
| 193 | - | Success: false, | |
| 194 | - | Message: fmt.Sprintf("Failed to import image: %v", err), | |
| 195 | - | }) | |
| 196 | - | } | |
| 197 | - | ||
| 198 | - | if err := store.RemoveFromStorage(imagePath); err != nil { | |
| 199 | - | log.Warn("Failed to remove temporary image file", "error", err) | |
| 200 | - | } | |
| 201 | - | ||
| 202 | - | // Create and start the container | |
| 203 | - | containerID, containerName, err = createAndStartContainer(a, &common.DeployPayload{ | |
| 204 | - | Port: port, | |
| 205 | - | TargetDomain: targetDomain, | |
| 206 | - | ImageName: imageName, | |
| 207 | - | ImageID: imageID, | |
| 208 | - | }) | |
| 209 | - | ||
| 210 | - | if err != nil { | |
| 211 | - | if strings.Contains(err.Error(), "is already in use") { | |
| 212 | - | existingContainerID := extractContainerID(err.Error()) | |
| 213 | - | existingContainerName, _ := docker.GetContainerName(existingContainerID) | |
| 214 | - | existingContainerName = strings.TrimPrefix(existingContainerName, "/") | |
| 215 | - | ||
| 216 | - | return sendJSONResponse(c, http.StatusConflict, DeployResponse{ | |
| 217 | - | Success: false, | |
| 218 | - | Message: "A container for this deploy already exists.", | |
| 219 | - | ContainerID: existingContainerID, | |
| 220 | - | ContainerName: existingContainerName, | |
| 221 | - | }) | |
| 222 | - | } | |
| 223 | - | return sendJSONResponse(c, http.StatusInternalServerError, DeployResponse{ | |
| 224 | - | Success: false, | |
| 225 | - | Message: fmt.Sprintf("Failed to create or start container: %v", err), | |
| 226 | - | }) | |
| 227 | - | } | |
| 228 | - | ||
| 229 | - | // Send final success response with all information | |
| 230 | - | return sendJSONResponse(c, http.StatusOK, DeployResponse{ | |
| 231 | - | Success: true, | |
| 232 | - | Message: "Deployment successful", | |
| 233 | - | Domain: targetDomain, | |
| 234 | - | ContainerID: containerID, | |
| 235 | - | ContainerName: strings.TrimPrefix(containerName, "/"), | |
| 236 | - | }) | |
| 184 | + | func processCompleteChunkedDeployTansfert(c echo.Context, a *server.App, transferID string) error { | |
| 185 | + | log.Info("Starting complete deploy transfer processing", | |
| 186 | + | "transferID", transferID) | |
| 187 | + | ||
| 188 | + | // Get transfer data | |
| 189 | + | metadata, chunks, err := getTransferData(transferID) | |
| 190 | + | if err != nil { | |
| 191 | + | return sendErrorResponse(c, "Failed to get transfer data", err) | |
| 192 | + | } | |
| 193 | + | ||
| 194 | + | // Create temporary directory for processing | |
| 195 | + | tmpDir, err := os.MkdirTemp("", "docker-deploy-*") | |
| 196 | + | if err != nil { | |
| 197 | + | return sendErrorResponse(c, "Failed to create temp directory", err) | |
| 198 | + | } | |
| 199 | + | defer os.RemoveAll(tmpDir) | |
| 200 | + | ||
| 201 | + | tmpFile, err := os.Create(filepath.Join(tmpDir, "image.tar")) | |
| 202 | + | if err != nil { | |
| 203 | + | return sendErrorResponse(c, "Failed to create temp file", err) | |
| 204 | + | } | |
| 205 | + | defer tmpFile.Close() | |
| 206 | + | ||
| 207 | + | // Write chunks to file | |
| 208 | + | if err := writeChunksToFile(tmpFile, chunks, metadata.TotalChunks); err != nil { | |
| 209 | + | return sendErrorResponse(c, "Failed to write chunks", err) | |
| 210 | + | } | |
| 211 | + | ||
| 212 | + | // Get deployment-specific headers | |
| 213 | + | port := normalizePort(c.Request().Header.Get("X-Ports")) | |
| 214 | + | targetDomain := normalizeTargetDomain(c.Request().Header.Get("X-Target-Domain")) | |
| 215 | + | imageName := c.Request().Header.Get("X-Image-Name") | |
| 216 | + | ||
| 217 | + | if port == "" || targetDomain == "" || imageName == "" { | |
| 218 | + | return sendErrorResponse(c, "Missing required deployment parameters", | |
| 219 | + | fmt.Errorf("port: %s, domain: %s, image: %s", port, targetDomain, imageName)) | |
| 220 | + | } | |
| 221 | + | ||
| 222 | + | // Extract container name from domain and perform safety checks | |
| 223 | + | cleanDomain := strings.TrimPrefix(strings.TrimPrefix(targetDomain, "https://"), "http://") | |
| 224 | + | containerName := strings.Split(cleanDomain, ".")[0] | |
| 225 | + | ||
| 226 | + | if containerName == "" { | |
| 227 | + | return sendErrorResponse(c, "Invalid target domain", | |
| 228 | + | fmt.Errorf("couldn't extract container name from domain: %s", targetDomain)) | |
| 229 | + | } | |
| 230 | + | ||
| 231 | + | // Check for existing container | |
| 232 | + | existingContainerID := docker.GetContainerIDByName(containerName) | |
| 233 | + | if existingContainerID != "" { | |
| 234 | + | log.Warn("Container already exists", | |
| 235 | + | "name", containerName, | |
| 236 | + | "id", existingContainerID) | |
| 237 | + | return sendJSONResponse(c, http.StatusConflict, DeployResponse{ | |
| 238 | + | Success: false, | |
| 239 | + | Message: fmt.Sprintf("Container with name '%s' already exists", containerName), | |
| 240 | + | ContainerID: existingContainerID, | |
| 241 | + | ContainerName: containerName, | |
| 242 | + | }) | |
| 243 | + | } | |
| 244 | + | ||
| 245 | + | // Import the image | |
| 246 | + | log.Info("Importing image from file", | |
| 247 | + | "path", tmpFile.Name(), | |
| 248 | + | "imageName", imageName) | |
| 249 | + | ||
| 250 | + | imageID, err := docker.ImportImageToEngine(tmpFile.Name()) | |
| 251 | + | if err != nil { | |
| 252 | + | if strings.Contains(err.Error(), "platform compatibility check failed") { | |
| 253 | + | log.Error("Platform compatibility check failed", | |
| 254 | + | "error", err, | |
| 255 | + | "imageName", imageName) | |
| 256 | + | return c.JSON(http.StatusBadRequest, &common.DeploymentError{ | |
| 257 | + | StatusCode: http.StatusBadRequest, | |
| 258 | + | Message: "Platform compatibility error", | |
| 259 | + | RawResponse: err.Error(), | |
| 260 | + | }) | |
| 261 | + | } | |
| 262 | + | return sendErrorResponse(c, "Failed to import image", err) | |
| 263 | + | } | |
| 264 | + | ||
| 265 | + | // Prepare deployment payload | |
| 266 | + | deployPayload := &common.DeployPayload{ | |
| 267 | + | Port: port, | |
| 268 | + | TargetDomain: targetDomain, | |
| 269 | + | ImageName: imageName, | |
| 270 | + | ImageID: imageID, | |
| 271 | + | } | |
| 272 | + | ||
| 273 | + | // Create and start the container | |
| 274 | + | containerID, containerName, err := createAndStartContainer(a, deployPayload) | |
| 275 | + | if err != nil { | |
| 276 | + | if strings.Contains(err.Error(), "is already in use") { | |
| 277 | + | existingContainerID := extractContainerID(err.Error()) | |
| 278 | + | existingContainerName, _ := docker.GetContainerName(existingContainerID) | |
| 279 | + | existingContainerName = strings.TrimPrefix(existingContainerName, "/") | |
| 280 | + | ||
| 281 | + | return c.JSON(http.StatusBadRequest, &common.DeploymentError{ | |
| 282 | + | StatusCode: http.StatusBadRequest, | |
| 283 | + | Message: fmt.Sprintf("Container with name '%s' already exists", existingContainerName), | |
| 284 | + | RawResponse: err.Error(), | |
| 285 | + | }) | |
| 286 | + | } | |
| 287 | + | ||
| 288 | + | return sendErrorResponse(c, "Failed to create or start container", err) | |
| 289 | + | } | |
| 290 | + | ||
| 291 | + | // Cleanup transfer data | |
| 292 | + | cleanupTransfer(transferID) | |
| 293 | + | ||
| 294 | + | log.Info("Deployment completed successfully", | |
| 295 | + | "containerID", containerID, | |
| 296 | + | "containerName", containerName, | |
| 297 | + | "domain", targetDomain) | |
| 298 | + | ||
| 299 | + | return sendJSONResponse(c, http.StatusOK, DeployResponse{ | |
| 300 | + | Success: true, | |
| 301 | + | Message: "Deployment successful", | |
| 302 | + | Domain: targetDomain, | |
| 303 | + | ContainerID: containerID, | |
| 304 | + | ContainerName: strings.TrimPrefix(containerName, "/"), | |
| 305 | + | }) | |
| 237 | 306 | } | |
| 238 | 307 | ||
| 239 | - | ||
| 240 | - | func waitForDeployment(domain string, containerID string) error { | |
| 241 | - | client := &http.Client{Timeout: 10 * time.Second} | |
| 242 | - | maxRetries := 20 | |
| 243 | - | retryInterval := time.Second | |
| 244 | - | ||
| 245 | - | var shortContainerID string | |
| 246 | - | if containerID != "" { | |
| 247 | - | shortContainerID = containerID[:12] | |
| 248 | - | } | |
| 249 | - | ||
| 250 | - | log.Info("Waiting for deployment to be reachable", | |
| 251 | - | "domain", domain, | |
| 252 | - | "container_id", shortContainerID) | |
| 253 | - | ||
| 254 | - | for i := 0; i < maxRetries; i++ { | |
| 255 | - | resp, err := client.Get(domain) | |
| 256 | - | if err == nil { | |
| 257 | - | defer resp.Body.Close() | |
| 258 | - | if resp.StatusCode == http.StatusOK { | |
| 259 | - | log.Info("Deployment successful", | |
| 260 | - | "domain", domain, | |
| 261 | - | "container_id", shortContainerID) | |
| 262 | - | return nil | |
| 263 | - | } | |
| 264 | - | body, _ := io.ReadAll(resp.Body) | |
| 265 | - | if strings.Contains(string(body), "failed to create container:") { | |
| 266 | - | return fmt.Errorf("deployment failed: %s", string(body)) | |
| 267 | - | } | |
| 268 | - | } | |
| 269 | - | log.Warn("Deployment not ready yet, retrying", | |
| 270 | - | "attempt", fmt.Sprintf("%d/%d", i+1, maxRetries)) | |
| 271 | - | time.Sleep(retryInterval) | |
| 272 | - | } | |
| 273 | - | ||
| 274 | - | return fmt.Errorf("deployment not ready after %d attempts", maxRetries) | |
| 275 | - | } | |
| 308 | + | // func waitForDeployment(domain string, containerID string) error { | |
| 309 | + | // client := &http.Client{Timeout: 10 * time.Second} | |
| 310 | + | // maxRetries := 10 | |
| 311 | + | // retryInterval := time.Second | |
| 312 | + | ||
| 313 | + | // var shortContainerID string | |
| 314 | + | // if containerID != "" { | |
| 315 | + | // shortContainerID = containerID[:12] | |
| 316 | + | // } | |
| 317 | + | ||
| 318 | + | // log.Info("Waiting for deployment to be reachable", | |
| 319 | + | // "domain", domain, | |
| 320 | + | // "container_id", shortContainerID) | |
| 321 | + | ||
| 322 | + | // for i := 0; i < maxRetries; i++ { | |
| 323 | + | // resp, err := client.Get(domain) | |
| 324 | + | // if err == nil { | |
| 325 | + | // defer resp.Body.Close() | |
| 326 | + | // if resp.StatusCode == http.StatusOK { | |
| 327 | + | // log.Info("Deployment successful", | |
| 328 | + | // "domain", domain, | |
| 329 | + | // "container_id", shortContainerID) | |
| 330 | + | // return nil | |
| 331 | + | // } | |
| 332 | + | // body, _ := io.ReadAll(resp.Body) | |
| 333 | + | // if strings.Contains(string(body), "failed to create container:") { | |
| 334 | + | // return fmt.Errorf("deployment failed: %s", string(body)) | |
| 335 | + | // } | |
| 336 | + | // } | |
| 337 | + | // log.Warn("Deployment not ready yet, retrying", | |
| 338 | + | // "attempt", fmt.Sprintf("%d/%d", i+1, maxRetries)) | |
| 339 | + | // time.Sleep(retryInterval) | |
| 340 | + | // } | |
| 341 | + | ||
| 342 | + | // return fmt.Errorf("deployment not ready after %d attempts", maxRetries) | |
| 343 | + | // } | |
| @@ -2,274 +2,342 @@ | |||||
| 2 | package handlers | 2 | package handlers | ||
| 3 | 3 | ||||
| 4 | import ( | 4 | import ( | ||
| 5 | - | "bytes" | |||
| 6 | "encoding/json" | 5 | "encoding/json" | ||
| 7 | "fmt" | 6 | "fmt" | ||
| 8 | "io" | 7 | "io" | ||
| 9 | "net/http" | 8 | "net/http" | ||
| 9 | + | "os" | |||
| 10 | + | "path/filepath" | |||
| 10 | "strings" | 11 | "strings" | ||
| 11 | - | "time" | |||
| 12 | 12 | ||||
| 13 | authToken "github.com/bnema/gordon/internal/cli/auth" | 13 | authToken "github.com/bnema/gordon/internal/cli/auth" | ||
| 14 | "github.com/bnema/gordon/internal/common" | 14 | "github.com/bnema/gordon/internal/common" | ||
| 15 | "github.com/bnema/gordon/internal/server" | 15 | "github.com/bnema/gordon/internal/server" | ||
| 16 | "github.com/bnema/gordon/pkg/docker" | 16 | "github.com/bnema/gordon/pkg/docker" | ||
| 17 | - | "github.com/bnema/gordon/pkg/store" | |||
| 18 | "github.com/charmbracelet/log" | 17 | "github.com/charmbracelet/log" | ||
| 19 | "github.com/labstack/echo/v4" | 18 | "github.com/labstack/echo/v4" | ||
| 20 | ) | 19 | ) | ||
| 21 | 20 | ||||
| 22 | // PostDeploy handles the container deployment request | 21 | // PostDeploy handles the container deployment request | ||
| 23 | func PostDeploy(c echo.Context, a *server.App) error { | 22 | func PostDeploy(c echo.Context, a *server.App) error { | ||
| 24 | - | chunkMetadataStr := c.Request().Header.Get("X-Chunk-Metadata") | 23 | + | payload, err := validateAndPrepareDeployPayload(c) |
| 24 | + | if err != nil { | |||
| 25 | + | // Debug | |||
| 26 | + | log.Error("Failed to validate and prepare payload", "error", err) | |||
| 27 | + | return sendJSONResponse(c, http.StatusBadRequest, DeployResponse{ | |||
| 28 | + | Success: false, | |||
| 29 | + | Message: fmt.Sprintf("Invalid payload: %v", err), | |||
| 30 | + | }) | |||
| 31 | + | } | |||
| 32 | + | ||||
| 33 | + | _, err = saveAndImportDeployImage(c, a, payload) | |||
| 34 | + | if err != nil { | |||
| 35 | + | // Debug | |||
| 36 | + | log.Error("Failed to save and import image", "error", err) | |||
| 37 | + | return sendJSONResponse(c, http.StatusInternalServerError, DeployResponse{ | |||
| 38 | + | Success: false, | |||
| 39 | + | Message: fmt.Sprintf("Failed to save or import image: %v", err), | |||
| 40 | + | }) | |||
| 41 | + | } | |||
| 42 | + | ||||
| 43 | + | containerID, containerName, err := createAndStartContainer(a, payload) | |||
| 44 | + | if err != nil { | |||
| 45 | + | if strings.Contains(err.Error(), "is already in use") { | |||
| 46 | + | log.Warn("Container already exists", "error", err) | |||
| 47 | + | existingContainerID := extractContainerID(err.Error()) | |||
| 48 | + | existingContainerName, _ := docker.GetContainerName(existingContainerID) | |||
| 49 | + | // Remove the / from the container name | |||
| 50 | + | existingContainerName = strings.TrimLeft(existingContainerName, "/") | |||
| 51 | + | ||||
| 52 | + | if existingContainerID == "" || existingContainerName == "" { | |||
| 53 | + | log.Error("Failed to extract container ID or Name from error message") | |||
| 54 | + | return sendJSONResponse(c, http.StatusConflict, DeployResponse{ | |||
| 55 | + | Success: false, | |||
| 56 | + | Message: "A container for this deploy already exists but could not be identified.", | |||
| 57 | + | }) | |||
| 58 | + | } | |||
| 59 | + | ||||
| 60 | + | return sendJSONResponse(c, http.StatusConflict, DeployResponse{ | |||
| 61 | + | Success: false, | |||
| 62 | + | Message: "A container for this deploy already exists.", | |||
| 63 | + | ContainerID: existingContainerID, | |||
| 64 | + | ContainerName: existingContainerName, | |||
| 65 | + | }) | |||
| 66 | + | } | |||
| 67 | + | log.Error("Failed to create or start container", "error", err) | |||
| 68 | + | return sendJSONResponse(c, http.StatusInternalServerError, DeployResponse{ | |||
| 69 | + | Success: false, | |||
| 70 | + | Message: fmt.Sprintf("Failed to create or start container: %v", err), | |||
| 71 | + | }) | |||
| 72 | + | } | |||
| 73 | + | response := DeployResponse{ | |||
| 74 | + | Success: true, | |||
| 75 | + | Message: "Deployment successful", | |||
| 76 | + | Domain: payload.TargetDomain, | |||
| 77 | + | ContainerID: containerID, | |||
| 78 | + | ContainerName: containerName, | |||
| 79 | + | } | |||
| 80 | + | ||||
| 81 | + | // Debug: Verify containerID in response | |||
| 82 | + | if response.ContainerID != containerID { | |||
| 83 | + | log.Error("Container ID mismatch", "responseID", response.ContainerID, "actualID", containerID) | |||
| 84 | + | } else { | |||
| 85 | + | log.Info("Container ID successfully stored in response", "containerID", containerID) | |||
| 86 | + | } | |||
| 87 | + | ||||
| 88 | + | return sendJSONResponse(c, http.StatusOK, response) | |||
| 89 | + | } | |||
| 25 | 90 | ||||
| 26 | - | return handleChunkedDeploy(c, a, chunkMetadataStr) | 91 | + | // PostDeployChunked handles the chunked container deployment request |
| 92 | + | func PostDeployChunked(c echo.Context, a *server.App) error { | |||
| 93 | + | return handleChunkedDeploy(c, a, c.Request().Header.Get("X-Chunk-Metadata")) | |||
| 27 | } | 94 | } | ||
| 28 | 95 | ||||
| 29 | func handleChunkedDeploy(c echo.Context, a *server.App, metadataStr string) error { | 96 | func handleChunkedDeploy(c echo.Context, a *server.App, metadataStr string) error { | ||
| 30 | - | // First, get the target domain from headers | 97 | + | var metadata ChunkMetadata |
| 31 | - | targetDomain := c.Request().Header.Get("X-Target-Domain") | 98 | + | if err := json.Unmarshal([]byte(metadataStr), &metadata); err != nil { |
| 32 | - | if targetDomain == "" { | 99 | + | return sendJSONResponse(c, http.StatusBadRequest, DeployResponse{ |
| 33 | - | return sendJSONResponse(c, http.StatusBadRequest, DeployResponse{ | 100 | + | Success: false, |
| 34 | - | Success: false, | 101 | + | Message: "Invalid chunk metadata", |
| 35 | - | Message: "Target domain is required", | 102 | + | }) |
| 36 | - | }) | 103 | + | } |
| 37 | - | } | 104 | + | |
| 38 | - | 105 | + | payload, err := validateAndPrepareDeployPayload(c) | |
| 39 | - | // Extract container name from the target domain | 106 | + | if err != nil { |
| 40 | - | cleanDomain := strings.TrimPrefix(strings.TrimPrefix(targetDomain, "https://"), "http://") | 107 | + | log.Error("Failed to validate and prepare payload", "error", err) |
| 41 | - | containerName := strings.Split(cleanDomain, ".")[0] | 108 | + | return sendJSONResponse(c, http.StatusBadRequest, DeployResponse{ |
| 42 | - | 109 | + | Success: false, | |
| 43 | - | log.Info("Checking for existing container", "name", containerName) | 110 | + | Message: fmt.Sprintf("Invalid payload: %v", err), |
| 44 | - | 111 | + | }) | |
| 45 | - | // Check if container already exists | 112 | + | } |
| 46 | - | containerID := docker.GetContainerIDByName(containerName) | 113 | + | |
| 47 | - | if containerID != "" { | 114 | + | // Authenticate request |
| 48 | - | log.Warn("Container already exists", | 115 | + | auth := c.Request().Header.Get("Authorization") |
| 49 | - | "name", containerName, | 116 | + | if auth == "" { |
| 50 | - | "id", containerID) | 117 | + | return sendJSONResponse(c, http.StatusUnauthorized, DeployResponse{ |
| 51 | - | return sendJSONResponse(c, http.StatusConflict, DeployResponse{ | 118 | + | Success: false, |
| 52 | - | Success: false, | 119 | + | Message: "Authentication required", |
| 53 | - | Message: fmt.Sprintf("Container with name '%s' already exists", containerName), | 120 | + | }) |
| 54 | - | ContainerID: containerID, | 121 | + | } |
| 55 | - | ContainerName: containerName, | 122 | + | |
| 56 | - | }) | 123 | + | token := strings.TrimPrefix(auth, "Bearer ") |
| 57 | - | } | 124 | + | if !authToken.IsValidGitHubToken(token) { |
| 58 | - | 125 | + | return sendJSONResponse(c, http.StatusUnauthorized, DeployResponse{ | |
| 59 | - | payload, err := validateAndPrepareDeployPayload(c) | 126 | + | Success: false, |
| 60 | - | if err != nil { | 127 | + | Message: "Invalid or expired token", |
| 61 | - | log.Error("Failed to validate and prepare payload", "error", err) | 128 | + | }) |
| 62 | - | return sendJSONResponse(c, http.StatusBadRequest, DeployResponse{ | 129 | + | } |
| 63 | - | Success: false, | 130 | + | |
| 64 | - | Message: fmt.Sprintf("Invalid payload: %v", err), | 131 | + | if metadata.TotalChunks == 1 { |
| 65 | - | }) | 132 | + | chunkData, err := io.ReadAll(c.Request().Body) |
| 66 | - | } | 133 | + | if err != nil { |
| 67 | - | 134 | + | return sendJSONResponse(c, http.StatusInternalServerError, DeployResponse{ | |
| 68 | - | // Authenticate request | 135 | + | Success: false, |
| 69 | - | auth := c.Request().Header.Get("Authorization") | 136 | + | Message: "Failed to read chunk data", |
| 70 | - | if auth == "" { | 137 | + | }) |
| 71 | - | return sendJSONResponse(c, http.StatusUnauthorized, DeployResponse{ | 138 | + | } |
| 72 | - | Success: false, | 139 | + | |
| 73 | - | Message: "Authentication required", | 140 | + | // Store the data directly |
| 74 | - | }) | 141 | + | chunkStore.mu.Lock() |
| 75 | - | } | 142 | + | chunkStore.chunks[metadata.TransferID] = make(map[int][]byte) |
| 76 | - | 143 | + | chunkStore.chunks[metadata.TransferID][0] = chunkData | |
| 77 | - | token := strings.TrimPrefix(auth, "Bearer ") | 144 | + | chunkStore.metadata[metadata.TransferID] = &metadata |
| 78 | - | if !authToken.IsValidGitHubToken(token) { | 145 | + | chunkStore.mu.Unlock() |
| 79 | - | return sendJSONResponse(c, http.StatusUnauthorized, DeployResponse{ | 146 | + | |
| 80 | - | Success: false, | 147 | + | // Process the complete transfer immediately |
| 81 | - | Message: "Invalid or expired token", | 148 | + | return processCompleteChunkedDeployTansfert(c, a, metadata.TransferID) |
| 82 | - | }) | 149 | + | } |
| 83 | - | } | 150 | + | |
| 84 | - | 151 | + | // Store chunk data | |
| 85 | - | // Parse chunk metadata | 152 | + | chunkStore.mu.Lock() |
| 86 | - | var metadata ChunkMetadata | 153 | + | if chunkStore.chunks[metadata.TransferID] == nil { |
| 87 | - | if err := json.Unmarshal([]byte(metadataStr), &metadata); err != nil { | 154 | + | chunkStore.chunks[metadata.TransferID] = make(map[int][]byte) |
| 88 | - | return sendJSONResponse(c, http.StatusBadRequest, DeployResponse{ | 155 | + | chunkStore.metadata[metadata.TransferID] = &metadata |
| 89 | - | Success: false, | 156 | + | } |
| 90 | - | Message: "Invalid chunk metadata", | 157 | + | |
| 91 | - | }) | 158 | + | chunkData, err := io.ReadAll(c.Request().Body) |
| 92 | - | } | 159 | + | if err != nil { |
| 93 | - | 160 | + | chunkStore.mu.Unlock() | |
| 94 | - | // Store chunk data | 161 | + | return sendJSONResponse(c, http.StatusInternalServerError, DeployResponse{ |
| 95 | - | chunkStore.mu.Lock() | 162 | + | Success: false, |
| 96 | - | if chunkStore.chunks[metadata.TransferID] == nil { | 163 | + | Message: "Failed to read chunk data", |
| 97 | - | chunkStore.chunks[metadata.TransferID] = make(map[int][]byte) | 164 | + | }) |
| 98 | - | chunkStore.metadata[metadata.TransferID] = &metadata | 165 | + | } |
| 99 | - | } | 166 | + | |
| 100 | - | 167 | + | // Store the complete chunk | |
| 101 | - | chunkData, err := io.ReadAll(c.Request().Body) | 168 | + | chunkStore.chunks[metadata.TransferID][metadata.ChunkNumber] = chunkData |
| 102 | - | if err != nil { | 169 | + | chunkStore.mu.Unlock() |
| 103 | - | chunkStore.mu.Unlock() | 170 | + | |
| 104 | - | return sendJSONResponse(c, http.StatusInternalServerError, DeployResponse{ | 171 | + | // If this is the last chunk, process the complete transfer |
| 105 | - | Success: false, | 172 | + | if isTransferComplete(metadata.TransferID) { |
| 106 | - | Message: "Failed to read chunk data", | 173 | + | return processCompleteChunkedDeployTansfert(c, a, metadata.TransferID) |
| 107 | - | }) | 174 | + | } |
| 108 | - | } | 175 | + | |
| 109 | - | 176 | + | // Include domain information in intermediate chunk responses | |
| 110 | - | chunkStore.chunks[metadata.TransferID][metadata.ChunkNumber] = chunkData | 177 | + | return sendJSONResponse(c, http.StatusOK, DeployResponse{ |
| 111 | - | chunkStore.mu.Unlock() | 178 | + | Success: true, |
| 112 | - | 179 | + | Message: fmt.Sprintf("Chunk %d/%d received", metadata.ChunkNumber+1, metadata.TotalChunks), | |
| 113 | - | // If this is the last chunk, process the complete transfer | 180 | + | Domain: payload.TargetDomain, |
| 114 | - | if isTransferComplete(metadata.TransferID) { | 181 | + | }) |
| 115 | - | return processCompleteDeployTransfer(c, a, metadata.TransferID) | |||
| 116 | - | } | |||
| 117 | - | ||||
| 118 | - | // Include domain information in intermediate chunk responses | |||
| 119 | - | return sendJSONResponse(c, http.StatusOK, DeployResponse{ | |||
| 120 | - | Success: true, | |||
| 121 | - | Message: fmt.Sprintf("Chunk %d received successfully", metadata.ChunkNumber), | |||
| 122 | - | Domain: payload.TargetDomain, | |||
| 123 | - | }) | |||
| 124 | } | 182 | } | ||
| 125 | 183 | ||||
| 126 | - | func processCompleteDeployTransfer(c echo.Context, a *server.App, transferID string) error { | 184 | + | func processCompleteChunkedDeployTansfert(c echo.Context, a *server.App, transferID string) error { |
| 127 | - | log.Info("Starting complete deploy transfer processing", "transferID", transferID) | 185 | + | log.Info("Starting complete deploy transfer processing", |
| 128 | - | 186 | + | "transferID", transferID) | |
| 129 | - | // Get deployment-specific headers | 187 | + | |
| 130 | - | port := normalizePort(c.Request().Header.Get("X-Ports")) | 188 | + | // Get transfer data |
| 131 | - | targetDomain := normalizeTargetDomain(c.Request().Header.Get("X-Target-Domain")) | 189 | + | metadata, chunks, err := getTransferData(transferID) |
| 132 | - | imageName := c.Request().Header.Get("X-Image-Name") | 190 | + | if err != nil { |
| 133 | - | 191 | + | return sendErrorResponse(c, "Failed to get transfer data", err) | |
| 134 | - | // Extract container name and check again (safety check) | 192 | + | } |
| 135 | - | cleanDomain := strings.TrimPrefix(strings.TrimPrefix(targetDomain, "https://"), "http://") | 193 | + | |
| 136 | - | containerName := strings.Split(cleanDomain, ".")[0] | 194 | + | // Create temporary directory for processing |
| 137 | - | 195 | + | tmpDir, err := os.MkdirTemp("", "docker-deploy-*") | |
| 138 | - | containerID := docker.GetContainerIDByName(containerName) | 196 | + | if err != nil { |
| 139 | - | if containerID != "" { | 197 | + | return sendErrorResponse(c, "Failed to create temp directory", err) |
| 140 | - | log.Warn("Container already exists during final processing", | 198 | + | } |
| 141 | - | "name", containerName, | 199 | + | defer os.RemoveAll(tmpDir) |
| 142 | - | "id", containerID) | 200 | + | |
| 143 | - | return sendJSONResponse(c, http.StatusConflict, DeployResponse{ | 201 | + | tmpFile, err := os.Create(filepath.Join(tmpDir, "image.tar")) |
| 144 | - | Success: false, | 202 | + | if err != nil { |
| 145 | - | Message: fmt.Sprintf("Container with name '%s' already exists", containerName), | 203 | + | return sendErrorResponse(c, "Failed to create temp file", err) |
| 146 | - | ContainerID: containerID, | 204 | + | } |
| 147 | - | ContainerName: containerName, | 205 | + | defer tmpFile.Close() |
| 148 | - | }) | 206 | + | |
| 149 | - | } | 207 | + | // Write chunks to file |
| 150 | - | 208 | + | if err := writeChunksToFile(tmpFile, chunks, metadata.TotalChunks); err != nil { | |
| 151 | - | // Combine chunks | 209 | + | return sendErrorResponse(c, "Failed to write chunks", err) |
| 152 | - | chunkStore.mu.Lock() | 210 | + | } |
| 153 | - | metadata := chunkStore.metadata[transferID] | 211 | + | |
| 154 | - | chunks := chunkStore.chunks[transferID] | 212 | + | // Get deployment-specific headers |
| 155 | - | chunkStore.mu.Unlock() | 213 | + | port := normalizePort(c.Request().Header.Get("X-Ports")) |
| 156 | - | 214 | + | targetDomain := normalizeTargetDomain(c.Request().Header.Get("X-Target-Domain")) | |
| 157 | - | if metadata == nil { | 215 | + | imageName := c.Request().Header.Get("X-Image-Name") |
| 158 | - | return sendJSONResponse(c, http.StatusInternalServerError, DeployResponse{ | 216 | + | |
| 159 | - | Success: false, | 217 | + | if port == "" || targetDomain == "" || imageName == "" { |
| 160 | - | Message: "Transfer metadata not found", | 218 | + | return sendErrorResponse(c, "Missing required deployment parameters", |
| 161 | - | }) | 219 | + | fmt.Errorf("port: %s, domain: %s, image: %s", port, targetDomain, imageName)) |
| 162 | - | } | 220 | + | } |
| 163 | - | 221 | + | ||
| 164 | - | combinedData := bytes.NewBuffer(nil) | 222 | + | // Extract container name from domain and perform safety checks |
| 165 | - | for i := 0; i < metadata.TotalChunks; i++ { | 223 | + | cleanDomain := strings.TrimPrefix(strings.TrimPrefix(targetDomain, "https://"), "http://") |
| 166 | - | chunk, exists := chunks[i] | 224 | + | containerName := strings.Split(cleanDomain, ".")[0] |
| 167 | - | if !exists { | 225 | + | |
| 168 | - | return sendJSONResponse(c, http.StatusInternalServerError, DeployResponse{ | 226 | + | if containerName == "" { |
| 169 | - | Success: false, | 227 | + | return sendErrorResponse(c, "Invalid target domain", |
| 170 | - | Message: fmt.Sprintf("Missing chunk %d", i), | 228 | + | fmt.Errorf("couldn't extract container name from domain: %s", targetDomain)) |
| 171 | - | }) | 229 | + | } |
| 172 | - | } | 230 | + | |
| 173 | - | combinedData.Write(chunk) | 231 | + | // Check for existing container |
| 174 | - | } | 232 | + | existingContainerID := docker.GetContainerIDByName(containerName) |
| 175 | - | 233 | + | if existingContainerID != "" { | |
| 176 | - | // Clean up the transfer data | 234 | + | log.Warn("Container already exists", |
| 177 | - | defer cleanupTransfer(transferID) | 235 | + | "name", containerName, |
| 178 | - | 236 | + | "id", existingContainerID) | |
| 179 | - | // Save and import the image | 237 | + | return sendJSONResponse(c, http.StatusConflict, DeployResponse{ |
| 180 | - | imageFileName := sanitizeImageFileName(imageName) | 238 | + | Success: false, |
| 181 | - | imagePath, err := store.SaveImageToStorage(&a.Config, imageFileName, combinedData) | 239 | + | Message: fmt.Sprintf("Container with name '%s' already exists", containerName), |
| 182 | - | if err != nil { | 240 | + | ContainerID: existingContainerID, |
| 183 | - | return sendJSONResponse(c, http.StatusInternalServerError, DeployResponse{ | 241 | + | ContainerName: containerName, |
| 184 | - | Success: false, | 242 | + | }) |
| 185 | - | Message: fmt.Sprintf("Failed to save image: %v", err), | 243 | + | } |
| 186 | - | }) | 244 | + | |
| 187 | - | } | 245 | + | // Import the image |
| 188 | - | 246 | + | log.Info("Importing image from file", | |
| 189 | - | imageID, err := docker.ImportImageToEngine(imagePath) | 247 | + | "path", tmpFile.Name(), |
| 190 | - | if err != nil { | 248 | + | "imageName", imageName) |
| 191 | - | store.RemoveFromStorage(imagePath) | 249 | + | |
| 192 | - | return sendJSONResponse(c, http.StatusInternalServerError, DeployResponse{ | 250 | + | imageID, err := docker.ImportImageToEngine(tmpFile.Name()) |
| 193 | - | Success: false, | 251 | + | if err != nil { |
| 194 | - | Message: fmt.Sprintf("Failed to import image: %v", err), | 252 | + | if strings.Contains(err.Error(), "platform compatibility check failed") { |
| 195 | - | }) | 253 | + | log.Error("Platform compatibility check failed", |
| 196 | - | } | 254 | + | "error", err, |
| 197 | - | 255 | + | "imageName", imageName) | |
| 198 | - | if err := store.RemoveFromStorage(imagePath); err != nil { | 256 | + | return c.JSON(http.StatusBadRequest, &common.DeploymentError{ |
| 199 | - | log.Warn("Failed to remove temporary image file", "error", err) | 257 | + | StatusCode: http.StatusBadRequest, |
| 200 | - | } | 258 | + | Message: "Platform compatibility error", |
| 201 | - | 259 | + | RawResponse: err.Error(), | |
| 202 | - | // Create and start the container | 260 | + | }) |
| 203 | - | containerID, containerName, err = createAndStartContainer(a, &common.DeployPayload{ | 261 | + | } |
| 204 | - | Port: port, | 262 | + | return sendErrorResponse(c, "Failed to import image", err) |
| 205 | - | TargetDomain: targetDomain, | 263 | + | } |
| 206 | - | ImageName: imageName, | 264 | + | |
| 207 | - | ImageID: imageID, | 265 | + | // Prepare deployment payload |
| 208 | - | }) | 266 | + | deployPayload := &common.DeployPayload{ |
| 209 | - | 267 | + | Port: port, | |
| 210 | - | if err != nil { | 268 | + | TargetDomain: targetDomain, |
| 211 | - | if strings.Contains(err.Error(), "is already in use") { | 269 | + | ImageName: imageName, |
| 212 | - | existingContainerID := extractContainerID(err.Error()) | 270 | + | ImageID: imageID, |
| 213 | - | existingContainerName, _ := docker.GetContainerName(existingContainerID) | 271 | + | } |
| 214 | - | existingContainerName = strings.TrimPrefix(existingContainerName, "/") | 272 | + | |
| 215 | - | 273 | + | // Create and start the container | |
| 216 | - | return sendJSONResponse(c, http.StatusConflict, DeployResponse{ | 274 | + | containerID, containerName, err := createAndStartContainer(a, deployPayload) |
| 217 | - | Success: false, | 275 | + | if err != nil { |
| 218 | - | Message: "A container for this deploy already exists.", | 276 | + | if strings.Contains(err.Error(), "is already in use") { |
| 219 | - | ContainerID: existingContainerID, | 277 | + | existingContainerID := extractContainerID(err.Error()) |
| 220 | - | ContainerName: existingContainerName, | 278 | + | existingContainerName, _ := docker.GetContainerName(existingContainerID) |
| 221 | - | }) | 279 | + | existingContainerName = strings.TrimPrefix(existingContainerName, "/") |
| 222 | - | } | 280 | + | |
| 223 | - | return sendJSONResponse(c, http.StatusInternalServerError, DeployResponse{ | 281 | + | return c.JSON(http.StatusBadRequest, &common.DeploymentError{ |
| 224 | - | Success: false, | 282 | + | StatusCode: http.StatusBadRequest, |
| 225 | - | Message: fmt.Sprintf("Failed to create or start container: %v", err), | 283 | + | Message: fmt.Sprintf("Container with name '%s' already exists", existingContainerName), |
| 226 | - | }) | 284 | + | RawResponse: err.Error(), |
| 227 | - | } | 285 | + | }) |
| 228 | - | 286 | + | } | |
| 229 | - | // Send final success response with all information | 287 | + | |
| 230 | - | return sendJSONResponse(c, http.StatusOK, DeployResponse{ | 288 | + | return sendErrorResponse(c, "Failed to create or start container", err) |
| 231 | - | Success: true, | 289 | + | } |
| 232 | - | Message: "Deployment successful", | 290 | + | |
| 233 | - | Domain: targetDomain, | 291 | + | // Cleanup transfer data |
| 234 | - | ContainerID: containerID, | 292 | + | cleanupTransfer(transferID) |
| 235 | - | ContainerName: strings.TrimPrefix(containerName, "/"), | 293 | + | |
| 236 | - | }) | 294 | + | log.Info("Deployment completed successfully", |
| 295 | + | "containerID", containerID, | |||
| 296 | + | "containerName", containerName, | |||
| 297 | + | "domain", targetDomain) | |||
| 298 | + | ||||
| 299 | + | return sendJSONResponse(c, http.StatusOK, DeployResponse{ | |||
| 300 | + | Success: true, | |||
| 301 | + | Message: "Deployment successful", | |||
| 302 | + | Domain: targetDomain, | |||
| 303 | + | ContainerID: containerID, | |||
| 304 | + | ContainerName: strings.TrimPrefix(containerName, "/"), | |||
| 305 | + | }) | |||
| 237 | } | 306 | } | ||
| 238 | 307 | ||||
| 239 | - | 308 | + | // func waitForDeployment(domain string, containerID string) error { | |
| 240 | - | func waitForDeployment(domain string, containerID string) error { | 309 | + | // client := &http.Client{Timeout: 10 * time.Second} |
| 241 | - | client := &http.Client{Timeout: 10 * time.Second} | 310 | + | // maxRetries := 10 |
| 242 | - | maxRetries := 20 | 311 | + | // retryInterval := time.Second |
| 243 | - | retryInterval := time.Second | 312 | + | |
| 244 | - | 313 | + | // var shortContainerID string | |
| 245 | - | var shortContainerID string | 314 | + | // if containerID != "" { |
| 246 | - | if containerID != "" { | 315 | + | // shortContainerID = containerID[:12] |
| 247 | - | shortContainerID = containerID[:12] | 316 | + | // } |
| 248 | - | } | 317 | + | |
| 249 | - | 318 | + | // log.Info("Waiting for deployment to be reachable", | |
| 250 | - | log.Info("Waiting for deployment to be reachable", | 319 | + | // "domain", domain, |
| 251 | - | "domain", domain, | 320 | + | // "container_id", shortContainerID) |
| 252 | - | "container_id", shortContainerID) | 321 | + | |
| 253 | - | 322 | + | // for i := 0; i < maxRetries; i++ { | |
| 254 | - | for i := 0; i < maxRetries; i++ { | 323 | + | // resp, err := client.Get(domain) |
| 255 | - | resp, err := client.Get(domain) | 324 | + | // if err == nil { |
| 256 | - | if err == nil { | 325 | + | // defer resp.Body.Close() |
| 257 | - | defer resp.Body.Close() | 326 | + | // if resp.StatusCode == http.StatusOK { |
| 258 | - | if resp.StatusCode == http.StatusOK { | 327 | + | // log.Info("Deployment successful", |
| 259 | - | log.Info("Deployment successful", | 328 | + | // "domain", domain, |
| 260 | - | "domain", domain, | 329 | + | // "container_id", shortContainerID) |
| 261 | - | "container_id", shortContainerID) | 330 | + | // return nil |
| 262 | - | return nil | 331 | + | // } |
| 263 | - | } | 332 | + | // body, _ := io.ReadAll(resp.Body) |
| 264 | - | body, _ := io.ReadAll(resp.Body) | 333 | + | // if strings.Contains(string(body), "failed to create container:") { |
| 265 | - | if strings.Contains(string(body), "failed to create container:") { | 334 | + | // return fmt.Errorf("deployment failed: %s", string(body)) |
| 266 | - | return fmt.Errorf("deployment failed: %s", string(body)) | 335 | + | // } |
| 267 | - | } | 336 | + | // } |
| 268 | - | } | 337 | + | // log.Warn("Deployment not ready yet, retrying", |
| 269 | - | log.Warn("Deployment not ready yet, retrying", | 338 | + | // "attempt", fmt.Sprintf("%d/%d", i+1, maxRetries)) |
| 270 | - | "attempt", fmt.Sprintf("%d/%d", i+1, maxRetries)) | 339 | + | // time.Sleep(retryInterval) |
| 271 | - | time.Sleep(retryInterval) | 340 | + | // } |
| 272 | - | } | 341 | + | |
| 273 | - | 342 | + | // return fmt.Errorf("deployment not ready after %d attempts", maxRetries) | |
| 274 | - | return fmt.Errorf("deployment not ready after %d attempts", maxRetries) | 343 | + | // } |
| 275 | - | } | |||
A internal/httpserve/handlers/endpoint_deploy_check.go +74 -0
| @@ -0,0 +1,74 @@ | |||
| 1 | + | package handlers | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "fmt" | |
| 5 | + | "net/http" | |
| 6 | + | "strings" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/gordon/internal/common" | |
| 9 | + | "github.com/bnema/gordon/internal/server" | |
| 10 | + | "github.com/bnema/gordon/pkg/docker" | |
| 11 | + | "github.com/charmbracelet/log" | |
| 12 | + | "github.com/labstack/echo/v4" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | type ConflictCheckResponse = common.ConflictCheckResponse | |
| 16 | + | ||
| 17 | + | func CheckDeployConflict(c echo.Context, a *server.App) error { | |
| 18 | + | targetDomain := c.QueryParam("domain") | |
| 19 | + | if targetDomain == "" { | |
| 20 | + | return sendJSONResponse(c, http.StatusBadRequest, ConflictCheckResponse{ | |
| 21 | + | Success: false, | |
| 22 | + | Message: "Target domain is required", | |
| 23 | + | }) | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | // Extract container name from the target domain | |
| 27 | + | cleanDomain := strings.TrimPrefix(strings.TrimPrefix(targetDomain, "https://"), "http://") | |
| 28 | + | containerName := strings.Split(cleanDomain, ".")[0] | |
| 29 | + | ||
| 30 | + | if containerName == "" { | |
| 31 | + | return sendJSONResponse(c, http.StatusBadRequest, ConflictCheckResponse{ | |
| 32 | + | Success: false, | |
| 33 | + | Message: "Invalid target domain format", | |
| 34 | + | }) | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | // Check if container exists | |
| 38 | + | containerID := docker.GetContainerIDByName(containerName) | |
| 39 | + | if containerID == "" { | |
| 40 | + | return sendJSONResponse(c, http.StatusOK, ConflictCheckResponse{ | |
| 41 | + | Success: true, | |
| 42 | + | Message: "No conflicts found", | |
| 43 | + | }) | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | // Get container details | |
| 47 | + | state, err := docker.GetContainerState(containerID) | |
| 48 | + | if err != nil { | |
| 49 | + | log.Error("Failed to get container state", "error", err) | |
| 50 | + | state = "unknown" | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | ports, err := docker.GetContainerPorts(containerID) | |
| 54 | + | if err != nil { | |
| 55 | + | log.Error("Failed to get container ports", "error", err) | |
| 56 | + | ports = "unknown" | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | uptime, err := docker.GetContainerUptime(containerID) | |
| 60 | + | if err != nil { | |
| 61 | + | log.Error("Failed to get container uptime", "error", err) | |
| 62 | + | uptime = "unknown" | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | return sendJSONResponse(c, http.StatusConflict, ConflictCheckResponse{ | |
| 66 | + | Success: false, | |
| 67 | + | Message: fmt.Sprintf("Container with name '%s' already exists", containerName), | |
| 68 | + | ContainerID: containerID, | |
| 69 | + | ContainerName: containerName, | |
| 70 | + | State: state, | |
| 71 | + | RunningTime: uptime, | |
| 72 | + | Ports: ports, | |
| 73 | + | }) | |
| 74 | + | } | |
| @@ -0,0 +1,74 @@ | |||||
| 1 | + | package handlers | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "fmt" | |||
| 5 | + | "net/http" | |||
| 6 | + | "strings" | |||
| 7 | + | ||||
| 8 | + | "github.com/bnema/gordon/internal/common" | |||
| 9 | + | "github.com/bnema/gordon/internal/server" | |||
| 10 | + | "github.com/bnema/gordon/pkg/docker" | |||
| 11 | + | "github.com/charmbracelet/log" | |||
| 12 | + | "github.com/labstack/echo/v4" | |||
| 13 | + | ) | |||
| 14 | + | ||||
| 15 | + | type ConflictCheckResponse = common.ConflictCheckResponse | |||
| 16 | + | ||||
| 17 | + | func CheckDeployConflict(c echo.Context, a *server.App) error { | |||
| 18 | + | targetDomain := c.QueryParam("domain") | |||
| 19 | + | if targetDomain == "" { | |||
| 20 | + | return sendJSONResponse(c, http.StatusBadRequest, ConflictCheckResponse{ | |||
| 21 | + | Success: false, | |||
| 22 | + | Message: "Target domain is required", | |||
| 23 | + | }) | |||
| 24 | + | } | |||
| 25 | + | ||||
| 26 | + | // Extract container name from the target domain | |||
| 27 | + | cleanDomain := strings.TrimPrefix(strings.TrimPrefix(targetDomain, "https://"), "http://") | |||
| 28 | + | containerName := strings.Split(cleanDomain, ".")[0] | |||
| 29 | + | ||||
| 30 | + | if containerName == "" { | |||
| 31 | + | return sendJSONResponse(c, http.StatusBadRequest, ConflictCheckResponse{ | |||
| 32 | + | Success: false, | |||
| 33 | + | Message: "Invalid target domain format", | |||
| 34 | + | }) | |||
| 35 | + | } | |||
| 36 | + | ||||
| 37 | + | // Check if container exists | |||
| 38 | + | containerID := docker.GetContainerIDByName(containerName) | |||
| 39 | + | if containerID == "" { | |||
| 40 | + | return sendJSONResponse(c, http.StatusOK, ConflictCheckResponse{ | |||
| 41 | + | Success: true, | |||
| 42 | + | Message: "No conflicts found", | |||
| 43 | + | }) | |||
| 44 | + | } | |||
| 45 | + | ||||
| 46 | + | // Get container details | |||
| 47 | + | state, err := docker.GetContainerState(containerID) | |||
| 48 | + | if err != nil { | |||
| 49 | + | log.Error("Failed to get container state", "error", err) | |||
| 50 | + | state = "unknown" | |||
| 51 | + | } | |||
| 52 | + | ||||
| 53 | + | ports, err := docker.GetContainerPorts(containerID) | |||
| 54 | + | if err != nil { | |||
| 55 | + | log.Error("Failed to get container ports", "error", err) | |||
| 56 | + | ports = "unknown" | |||
| 57 | + | } | |||
| 58 | + | ||||
| 59 | + | uptime, err := docker.GetContainerUptime(containerID) | |||
| 60 | + | if err != nil { | |||
| 61 | + | log.Error("Failed to get container uptime", "error", err) | |||
| 62 | + | uptime = "unknown" | |||
| 63 | + | } | |||
| 64 | + | ||||
| 65 | + | return sendJSONResponse(c, http.StatusConflict, ConflictCheckResponse{ | |||
| 66 | + | Success: false, | |||
| 67 | + | Message: fmt.Sprintf("Container with name '%s' already exists", containerName), | |||
| 68 | + | ContainerID: containerID, | |||
| 69 | + | ContainerName: containerName, | |||
| 70 | + | State: state, | |||
| 71 | + | RunningTime: uptime, | |||
| 72 | + | Ports: ports, | |||
| 73 | + | }) | |||
| 74 | + | } | |||
M internal/httpserve/handlers/endpoint_helpers.go +57 -15
| @@ -101,8 +101,8 @@ func GetInfos(c echo.Context, a *server.App) error { | |||
| 101 | 101 | return sendJSONResponse(c, http.StatusOK, info) | |
| 102 | 102 | } | |
| 103 | 103 | ||
| 104 | - | // Helper functions for validation and preparation | |
| 105 | - | func validateAndPreparePayload(c echo.Context) (*common.PushPayload, error) { | |
| 104 | + | // validateAndPreparePushPayload validates and prepares the push payload | |
| 105 | + | func validateAndPreparePushPayload(c echo.Context) (*common.PushPayload, error) { | |
| 106 | 106 | payload := &common.PushPayload{ | |
| 107 | 107 | ImageName: c.Request().Header.Get("X-Image-Name"), | |
| 108 | 108 | } | |
| @@ -114,6 +114,7 @@ func validateAndPreparePayload(c echo.Context) (*common.PushPayload, error) { | |||
| 114 | 114 | return payload, nil | |
| 115 | 115 | } | |
| 116 | 116 | ||
| 117 | + | // validateAndPrepareDeployPayload validates and prepares the deploy payload | |
| 117 | 118 | func validateAndPrepareDeployPayload(c echo.Context) (*common.DeployPayload, error) { | |
| 118 | 119 | payload := &common.DeployPayload{ | |
| 119 | 120 | Port: c.Request().Header.Get("X-Ports"), | |
| @@ -131,8 +132,8 @@ func validateAndPrepareDeployPayload(c echo.Context) (*common.DeployPayload, err | |||
| 131 | 132 | return payload, nil | |
| 132 | 133 | } | |
| 133 | 134 | ||
| 134 | - | // Helper functions for saving and importing images | |
| 135 | - | func saveAndImportImage(c echo.Context, a *server.App, payload *common.PushPayload) error { | |
| 135 | + | // saveAndImportPushImage saves the image to the storage and imports it to the Docker engine, updating the payload with the image ID and returning the path to the saved image | |
| 136 | + | func saveAndImportPushImage(c echo.Context, a *server.App, payload *common.PushPayload) (string, error) { | |
| 136 | 137 | imageReader := c.Request().Body | |
| 137 | 138 | defer imageReader.Close() | |
| 138 | 139 | ||
| @@ -140,29 +141,31 @@ func saveAndImportImage(c echo.Context, a *server.App, payload *common.PushPaylo | |||
| 140 | 141 | ||
| 141 | 142 | imagePath, err := store.SaveImageToStorage(&a.Config, imageFileName, imageReader) | |
| 142 | 143 | if err != nil { | |
| 143 | - | return fmt.Errorf("failed to save image: %v", err) | |
| 144 | + | return "", fmt.Errorf("failed to save image: %v", err) | |
| 144 | 145 | } | |
| 145 | 146 | ||
| 146 | 147 | imageID, err := docker.ImportImageToEngine(imagePath) | |
| 147 | 148 | if err != nil { | |
| 148 | 149 | store.RemoveFromStorage(imagePath) | |
| 149 | - | return fmt.Errorf("failed to import image: %v", err) | |
| 150 | + | return "", fmt.Errorf("failed to import image: %v", err) | |
| 150 | 151 | } | |
| 151 | 152 | ||
| 152 | 153 | if imageID == "" { | |
| 153 | - | return errors.New("failed to import image") | |
| 154 | + | return "", errors.New("imported image ID is empty") | |
| 154 | 155 | } | |
| 155 | 156 | ||
| 156 | 157 | err = store.RemoveFromStorage(imagePath) | |
| 157 | 158 | if err != nil { | |
| 158 | - | return fmt.Errorf("failed to remove temporary image file: %v", err) | |
| 159 | + | return "", fmt.Errorf("failed to remove temporary image file: %v", err) | |
| 159 | 160 | } | |
| 160 | 161 | ||
| 161 | 162 | payload.ImageID = imageID | |
| 162 | - | return nil | |
| 163 | + | return imagePath, nil | |
| 163 | 164 | } | |
| 164 | 165 | ||
| 166 | + | // saveAndImportDeployImage saves the image to the storage and imports it to the Docker engine, updating the payload with the image ID and returning the path to the saved image | |
| 165 | 167 | func saveAndImportDeployImage(c echo.Context, a *server.App, payload *common.DeployPayload) (string, error) { | |
| 168 | + | ||
| 166 | 169 | imageReader := c.Request().Body | |
| 167 | 170 | defer imageReader.Close() | |
| 168 | 171 | ||
| @@ -192,39 +195,72 @@ func saveAndImportDeployImage(c echo.Context, a *server.App, payload *common.Dep | |||
| 192 | 195 | return imagePath, nil | |
| 193 | 196 | } | |
| 194 | 197 | ||
| 195 | - | // Container management helpers | |
| 198 | + | // createAndStartContainer creates a container from the image, starts it and returns the container ID | |
| 196 | 199 | func createAndStartContainer(a *server.App, payload *common.DeployPayload) (string, string, error) { | |
| 200 | + | log.Info("Starting container creation process", | |
| 201 | + | "imageName", payload.ImageName, | |
| 202 | + | "imageID", payload.ImageID, | |
| 203 | + | "port", payload.Port) | |
| 204 | + | ||
| 197 | 205 | params, err := cmdparams.FromPayloadStructToCmdParams(payload, a, payload.ImageID) | |
| 198 | 206 | if err != nil { | |
| 199 | 207 | return "", "", fmt.Errorf("failed to create command parameters: %v", err) | |
| 200 | 208 | } | |
| 201 | 209 | ||
| 210 | + | log.Debug("Container parameters prepared", "params", params) | |
| 211 | + | ||
| 202 | 212 | containerID, err := docker.CreateContainer(params) | |
| 203 | 213 | if err != nil { | |
| 204 | 214 | return "", "", fmt.Errorf("failed to create container: %v", err) | |
| 205 | 215 | } | |
| 206 | 216 | ||
| 217 | + | log.Info("Container created successfully", "containerID", containerID) | |
| 218 | + | ||
| 207 | 219 | containerName, err := docker.GetContainerName(containerID) | |
| 208 | 220 | if err != nil { | |
| 209 | 221 | return "", "", fmt.Errorf("failed to get container name: %v", err) | |
| 210 | 222 | } | |
| 211 | 223 | ||
| 224 | + | log.Info("Starting container", | |
| 225 | + | "containerID", containerID, | |
| 226 | + | "name", containerName) | |
| 227 | + | ||
| 228 | + | // Use regular StartContainer instead of StartContainerWithContext | |
| 212 | 229 | err = docker.StartContainer(containerID) | |
| 213 | 230 | if err != nil { | |
| 214 | - | docker.RemoveContainer(containerID) | |
| 231 | + | // Get container logs if start failed | |
| 232 | + | logs, logErr := docker.GetContainerLogs(containerID) | |
| 233 | + | if logErr == nil { | |
| 234 | + | log.Error("Container start failed - container logs", | |
| 235 | + | "logs", logs, | |
| 236 | + | "containerID", containerID) | |
| 237 | + | } | |
| 238 | + | ||
| 239 | + | // Cleanup the failed container | |
| 240 | + | cleanupErr := docker.RemoveContainer(containerID) | |
| 241 | + | if cleanupErr != nil { | |
| 242 | + | log.Error("Failed to cleanup container after start failure", | |
| 243 | + | "containerID", containerID, | |
| 244 | + | "error", cleanupErr) | |
| 245 | + | } | |
| 215 | 246 | return "", "", fmt.Errorf("failed to start container: %v", err) | |
| 216 | 247 | } | |
| 217 | 248 | ||
| 249 | + | log.Info("Container started successfully", | |
| 250 | + | "containerID", containerID, | |
| 251 | + | "name", containerName) | |
| 252 | + | ||
| 218 | 253 | return containerID, containerName, nil | |
| 219 | 254 | } | |
| 220 | 255 | ||
| 221 | - | // String manipulation helpers | |
| 256 | + | // Helpers for sanitizing and normalizing input | |
| 222 | 257 | func sanitizeImageFileName(imageName string) string { | |
| 223 | 258 | imageName = regexp.MustCompile(`^([a-zA-Z0-9\-_.]+\/)?`).ReplaceAllString(imageName, "") | |
| 224 | 259 | imageName = regexp.MustCompile(`(:[a-zA-Z0-9\-_.]+)?$`).ReplaceAllString(imageName, "") | |
| 225 | 260 | return imageName + ".tar" | |
| 226 | 261 | } | |
| 227 | 262 | ||
| 263 | + | // normalizePort ensures that the port is in the format of "port/protocol" | |
| 228 | 264 | func normalizePort(port string) string { | |
| 229 | 265 | if !regexp.MustCompile(`\/(tcp|udp)$`).MatchString(port) { | |
| 230 | 266 | return port + "/tcp" | |
| @@ -232,6 +268,7 @@ func normalizePort(port string) string { | |||
| 232 | 268 | return port | |
| 233 | 269 | } | |
| 234 | 270 | ||
| 271 | + | // normalizeTargetDomain ensures that the target domain is in the format of "https://domain" or "http://domain" | |
| 235 | 272 | func normalizeTargetDomain(domain string) string { | |
| 236 | 273 | if !regexp.MustCompile(`^https?:\/\/`).MatchString(domain) { | |
| 237 | 274 | return "https://" + domain | |
| @@ -239,6 +276,7 @@ func normalizeTargetDomain(domain string) string { | |||
| 239 | 276 | return domain | |
| 240 | 277 | } | |
| 241 | 278 | ||
| 279 | + | // generateShortID generates a short ID from the full image ID | |
| 242 | 280 | func generateShortID(imageID string) string { | |
| 243 | 281 | shortID := imageID[7:] | |
| 244 | 282 | if len(shortID) > 12 { | |
| @@ -247,6 +285,7 @@ func generateShortID(imageID string) string { | |||
| 247 | 285 | return shortID | |
| 248 | 286 | } | |
| 249 | 287 | ||
| 288 | + | // generateCreateContainerURL generates the URL for creating a container | |
| 250 | 289 | func generateCreateContainerURL(a *server.App, shortID string) string { | |
| 251 | 290 | return fmt.Sprintf("%s://%s%s/cc/%s", | |
| 252 | 291 | a.Config.Http.Protocol(), | |
| @@ -255,7 +294,7 @@ func generateCreateContainerURL(a *server.App, shortID string) string { | |||
| 255 | 294 | shortID) | |
| 256 | 295 | } | |
| 257 | 296 | ||
| 258 | - | // Chunk management helpers | |
| 297 | + | // generateContainerLogsURL generates the URL for viewing container logs | |
| 259 | 298 | func isTransferComplete(transferID string) bool { | |
| 260 | 299 | chunkStore.mu.Lock() | |
| 261 | 300 | defer chunkStore.mu.Unlock() | |
| @@ -278,6 +317,7 @@ func isTransferComplete(transferID string) bool { | |||
| 278 | 317 | return isComplete | |
| 279 | 318 | } | |
| 280 | 319 | ||
| 320 | + | // Cleanup helpers for old transfers | |
| 281 | 321 | func cleanupTransfer(transferID string) { | |
| 282 | 322 | chunkStore.mu.Lock() | |
| 283 | 323 | defer chunkStore.mu.Unlock() | |
| @@ -287,6 +327,7 @@ func cleanupTransfer(transferID string) { | |||
| 287 | 327 | delete(chunkStore.started, transferID) | |
| 288 | 328 | } | |
| 289 | 329 | ||
| 330 | + | // cleanupOldTransfers periodically checks for and cleans up old transfers | |
| 290 | 331 | func cleanupOldTransfers() { | |
| 291 | 332 | ticker := time.NewTicker(cleanupInterval) | |
| 292 | 333 | defer ticker.Stop() // Good practice to clean up the ticker | |
| @@ -306,7 +347,7 @@ func cleanupOldTransfers() { | |||
| 306 | 347 | } | |
| 307 | 348 | } | |
| 308 | 349 | ||
| 309 | - | // Error extraction helper | |
| 350 | + | // extractContainerID extracts the container ID from an error message | |
| 310 | 351 | func extractContainerID(errorMessage string) string { | |
| 311 | 352 | re := regexp.MustCompile(`by\s+([0-9a-f]+)\.`) | |
| 312 | 353 | match := re.FindStringSubmatch(errorMessage) | |
| @@ -316,7 +357,7 @@ func extractContainerID(errorMessage string) string { | |||
| 316 | 357 | return "" | |
| 317 | 358 | } | |
| 318 | 359 | ||
| 319 | - | // File verification helper | |
| 360 | + | // verifyFileContents checks if the file size matches the expected size | |
| 320 | 361 | func verifyFileContents(file *os.File, expectedSize int64) error { | |
| 321 | 362 | actualSize, err := file.Seek(0, 2) // Seek to end | |
| 322 | 363 | if err != nil { | |
| @@ -331,6 +372,7 @@ func verifyFileContents(file *os.File, expectedSize int64) error { | |||
| 331 | 372 | return err | |
| 332 | 373 | } | |
| 333 | 374 | ||
| 375 | + | // storeIDMapping stores a mapping between a short ID and a full ID | |
| 334 | 376 | func storeIDMapping(shortID, fullID string) { | |
| 335 | 377 | safelyInteractWithIDMap(Update, shortID, fullID) | |
| 336 | 378 | } | |
| @@ -101,8 +101,8 @@ func GetInfos(c echo.Context, a *server.App) error { | |||||
| 101 | return sendJSONResponse(c, http.StatusOK, info) | 101 | return sendJSONResponse(c, http.StatusOK, info) | ||
| 102 | } | 102 | } | ||
| 103 | 103 | ||||
| 104 | - | // Helper functions for validation and preparation | 104 | + | // validateAndPreparePushPayload validates and prepares the push payload |
| 105 | - | func validateAndPreparePayload(c echo.Context) (*common.PushPayload, error) { | 105 | + | func validateAndPreparePushPayload(c echo.Context) (*common.PushPayload, error) { |
| 106 | payload := &common.PushPayload{ | 106 | payload := &common.PushPayload{ | ||
| 107 | ImageName: c.Request().Header.Get("X-Image-Name"), | 107 | ImageName: c.Request().Header.Get("X-Image-Name"), | ||
| 108 | } | 108 | } | ||
| @@ -114,6 +114,7 @@ func validateAndPreparePayload(c echo.Context) (*common.PushPayload, error) { | |||||
| 114 | return payload, nil | 114 | return payload, nil | ||
| 115 | } | 115 | } | ||
| 116 | 116 | ||||
| 117 | + | // validateAndPrepareDeployPayload validates and prepares the deploy payload | |||
| 117 | func validateAndPrepareDeployPayload(c echo.Context) (*common.DeployPayload, error) { | 118 | func validateAndPrepareDeployPayload(c echo.Context) (*common.DeployPayload, error) { | ||
| 118 | payload := &common.DeployPayload{ | 119 | payload := &common.DeployPayload{ | ||
| 119 | Port: c.Request().Header.Get("X-Ports"), | 120 | Port: c.Request().Header.Get("X-Ports"), | ||
| @@ -131,8 +132,8 @@ func validateAndPrepareDeployPayload(c echo.Context) (*common.DeployPayload, err | |||||
| 131 | return payload, nil | 132 | return payload, nil | ||
| 132 | } | 133 | } | ||
| 133 | 134 | ||||
| 134 | - | // Helper functions for saving and importing images | 135 | + | // saveAndImportPushImage saves the image to the storage and imports it to the Docker engine, updating the payload with the image ID and returning the path to the saved image |
| 135 | - | func saveAndImportImage(c echo.Context, a *server.App, payload *common.PushPayload) error { | 136 | + | func saveAndImportPushImage(c echo.Context, a *server.App, payload *common.PushPayload) (string, error) { |
| 136 | imageReader := c.Request().Body | 137 | imageReader := c.Request().Body | ||
| 137 | defer imageReader.Close() | 138 | defer imageReader.Close() | ||
| 138 | 139 | ||||
| @@ -140,29 +141,31 @@ func saveAndImportImage(c echo.Context, a *server.App, payload *common.PushPaylo | |||||
| 140 | 141 | ||||
| 141 | imagePath, err := store.SaveImageToStorage(&a.Config, imageFileName, imageReader) | 142 | imagePath, err := store.SaveImageToStorage(&a.Config, imageFileName, imageReader) | ||
| 142 | if err != nil { | 143 | if err != nil { | ||
| 143 | - | return fmt.Errorf("failed to save image: %v", err) | 144 | + | return "", fmt.Errorf("failed to save image: %v", err) |
| 144 | } | 145 | } | ||
| 145 | 146 | ||||
| 146 | imageID, err := docker.ImportImageToEngine(imagePath) | 147 | imageID, err := docker.ImportImageToEngine(imagePath) | ||
| 147 | if err != nil { | 148 | if err != nil { | ||
| 148 | store.RemoveFromStorage(imagePath) | 149 | store.RemoveFromStorage(imagePath) | ||
| 149 | - | return fmt.Errorf("failed to import image: %v", err) | 150 | + | return "", fmt.Errorf("failed to import image: %v", err) |
| 150 | } | 151 | } | ||
| 151 | 152 | ||||
| 152 | if imageID == "" { | 153 | if imageID == "" { | ||
| 153 | - | return errors.New("failed to import image") | 154 | + | return "", errors.New("imported image ID is empty") |
| 154 | } | 155 | } | ||
| 155 | 156 | ||||
| 156 | err = store.RemoveFromStorage(imagePath) | 157 | err = store.RemoveFromStorage(imagePath) | ||
| 157 | if err != nil { | 158 | if err != nil { | ||
| 158 | - | return fmt.Errorf("failed to remove temporary image file: %v", err) | 159 | + | return "", fmt.Errorf("failed to remove temporary image file: %v", err) |
| 159 | } | 160 | } | ||
| 160 | 161 | ||||
| 161 | payload.ImageID = imageID | 162 | payload.ImageID = imageID | ||
| 162 | - | return nil | 163 | + | return imagePath, nil |
| 163 | } | 164 | } | ||
| 164 | 165 | ||||
| 166 | + | // saveAndImportDeployImage saves the image to the storage and imports it to the Docker engine, updating the payload with the image ID and returning the path to the saved image | |||
| 165 | func saveAndImportDeployImage(c echo.Context, a *server.App, payload *common.DeployPayload) (string, error) { | 167 | func saveAndImportDeployImage(c echo.Context, a *server.App, payload *common.DeployPayload) (string, error) { | ||
| 168 | + | ||||
| 166 | imageReader := c.Request().Body | 169 | imageReader := c.Request().Body | ||
| 167 | defer imageReader.Close() | 170 | defer imageReader.Close() | ||
| 168 | 171 | ||||
| @@ -192,39 +195,72 @@ func saveAndImportDeployImage(c echo.Context, a *server.App, payload *common.Dep | |||||
| 192 | return imagePath, nil | 195 | return imagePath, nil | ||
| 193 | } | 196 | } | ||
| 194 | 197 | ||||
| 195 | - | // Container management helpers | 198 | + | // createAndStartContainer creates a container from the image, starts it and returns the container ID |
| 196 | func createAndStartContainer(a *server.App, payload *common.DeployPayload) (string, string, error) { | 199 | func createAndStartContainer(a *server.App, payload *common.DeployPayload) (string, string, error) { | ||
| 200 | + | log.Info("Starting container creation process", | |||
| 201 | + | "imageName", payload.ImageName, | |||
| 202 | + | "imageID", payload.ImageID, | |||
| 203 | + | "port", payload.Port) | |||
| 204 | + | ||||
| 197 | params, err := cmdparams.FromPayloadStructToCmdParams(payload, a, payload.ImageID) | 205 | params, err := cmdparams.FromPayloadStructToCmdParams(payload, a, payload.ImageID) | ||
| 198 | if err != nil { | 206 | if err != nil { | ||
| 199 | return "", "", fmt.Errorf("failed to create command parameters: %v", err) | 207 | return "", "", fmt.Errorf("failed to create command parameters: %v", err) | ||
| 200 | } | 208 | } | ||
| 201 | 209 | ||||
| 210 | + | log.Debug("Container parameters prepared", "params", params) | |||
| 211 | + | ||||
| 202 | containerID, err := docker.CreateContainer(params) | 212 | containerID, err := docker.CreateContainer(params) | ||
| 203 | if err != nil { | 213 | if err != nil { | ||
| 204 | return "", "", fmt.Errorf("failed to create container: %v", err) | 214 | return "", "", fmt.Errorf("failed to create container: %v", err) | ||
| 205 | } | 215 | } | ||
| 206 | 216 | ||||
| 217 | + | log.Info("Container created successfully", "containerID", containerID) | |||
| 218 | + | ||||
| 207 | containerName, err := docker.GetContainerName(containerID) | 219 | containerName, err := docker.GetContainerName(containerID) | ||
| 208 | if err != nil { | 220 | if err != nil { | ||
| 209 | return "", "", fmt.Errorf("failed to get container name: %v", err) | 221 | return "", "", fmt.Errorf("failed to get container name: %v", err) | ||
| 210 | } | 222 | } | ||
| 211 | 223 | ||||
| 224 | + | log.Info("Starting container", | |||
| 225 | + | "containerID", containerID, | |||
| 226 | + | "name", containerName) | |||
| 227 | + | ||||
| 228 | + | // Use regular StartContainer instead of StartContainerWithContext | |||
| 212 | err = docker.StartContainer(containerID) | 229 | err = docker.StartContainer(containerID) | ||
| 213 | if err != nil { | 230 | if err != nil { | ||
| 214 | - | docker.RemoveContainer(containerID) | 231 | + | // Get container logs if start failed |
| 232 | + | logs, logErr := docker.GetContainerLogs(containerID) | |||
| 233 | + | if logErr == nil { | |||
| 234 | + | log.Error("Container start failed - container logs", | |||
| 235 | + | "logs", logs, | |||
| 236 | + | "containerID", containerID) | |||
| 237 | + | } | |||
| 238 | + | ||||
| 239 | + | // Cleanup the failed container | |||
| 240 | + | cleanupErr := docker.RemoveContainer(containerID) | |||
| 241 | + | if cleanupErr != nil { | |||
| 242 | + | log.Error("Failed to cleanup container after start failure", | |||
| 243 | + | "containerID", containerID, | |||
| 244 | + | "error", cleanupErr) | |||
| 245 | + | } | |||
| 215 | return "", "", fmt.Errorf("failed to start container: %v", err) | 246 | return "", "", fmt.Errorf("failed to start container: %v", err) | ||
| 216 | } | 247 | } | ||
| 217 | 248 | ||||
| 249 | + | log.Info("Container started successfully", | |||
| 250 | + | "containerID", containerID, | |||
| 251 | + | "name", containerName) | |||
| 252 | + | ||||
| 218 | return containerID, containerName, nil | 253 | return containerID, containerName, nil | ||
| 219 | } | 254 | } | ||
| 220 | 255 | ||||
| 221 | - | // String manipulation helpers | 256 | + | // Helpers for sanitizing and normalizing input |
| 222 | func sanitizeImageFileName(imageName string) string { | 257 | func sanitizeImageFileName(imageName string) string { | ||
| 223 | imageName = regexp.MustCompile(`^([a-zA-Z0-9\-_.]+\/)?`).ReplaceAllString(imageName, "") | 258 | imageName = regexp.MustCompile(`^([a-zA-Z0-9\-_.]+\/)?`).ReplaceAllString(imageName, "") | ||
| 224 | imageName = regexp.MustCompile(`(:[a-zA-Z0-9\-_.]+)?$`).ReplaceAllString(imageName, "") | 259 | imageName = regexp.MustCompile(`(:[a-zA-Z0-9\-_.]+)?$`).ReplaceAllString(imageName, "") | ||
| 225 | return imageName + ".tar" | 260 | return imageName + ".tar" | ||
| 226 | } | 261 | } | ||
| 227 | 262 | ||||
| 263 | + | // normalizePort ensures that the port is in the format of "port/protocol" | |||
| 228 | func normalizePort(port string) string { | 264 | func normalizePort(port string) string { | ||
| 229 | if !regexp.MustCompile(`\/(tcp|udp)$`).MatchString(port) { | 265 | if !regexp.MustCompile(`\/(tcp|udp)$`).MatchString(port) { | ||
| 230 | return port + "/tcp" | 266 | return port + "/tcp" | ||
| @@ -232,6 +268,7 @@ func normalizePort(port string) string { | |||||
| 232 | return port | 268 | return port | ||
| 233 | } | 269 | } | ||
| 234 | 270 | ||||
| 271 | + | // normalizeTargetDomain ensures that the target domain is in the format of "https://domain" or "http://domain" | |||
| 235 | func normalizeTargetDomain(domain string) string { | 272 | func normalizeTargetDomain(domain string) string { | ||
| 236 | if !regexp.MustCompile(`^https?:\/\/`).MatchString(domain) { | 273 | if !regexp.MustCompile(`^https?:\/\/`).MatchString(domain) { | ||
| 237 | return "https://" + domain | 274 | return "https://" + domain | ||
| @@ -239,6 +276,7 @@ func normalizeTargetDomain(domain string) string { | |||||
| 239 | return domain | 276 | return domain | ||
| 240 | } | 277 | } | ||
| 241 | 278 | ||||
| 279 | + | // generateShortID generates a short ID from the full image ID | |||
| 242 | func generateShortID(imageID string) string { | 280 | func generateShortID(imageID string) string { | ||
| 243 | shortID := imageID[7:] | 281 | shortID := imageID[7:] | ||
| 244 | if len(shortID) > 12 { | 282 | if len(shortID) > 12 { | ||
| @@ -247,6 +285,7 @@ func generateShortID(imageID string) string { | |||||
| 247 | return shortID | 285 | return shortID | ||
| 248 | } | 286 | } | ||
| 249 | 287 | ||||
| 288 | + | // generateCreateContainerURL generates the URL for creating a container | |||
| 250 | func generateCreateContainerURL(a *server.App, shortID string) string { | 289 | func generateCreateContainerURL(a *server.App, shortID string) string { | ||
| 251 | return fmt.Sprintf("%s://%s%s/cc/%s", | 290 | return fmt.Sprintf("%s://%s%s/cc/%s", | ||
| 252 | a.Config.Http.Protocol(), | 291 | a.Config.Http.Protocol(), | ||
| @@ -255,7 +294,7 @@ func generateCreateContainerURL(a *server.App, shortID string) string { | |||||
| 255 | shortID) | 294 | shortID) | ||
| 256 | } | 295 | } | ||
| 257 | 296 | ||||
| 258 | - | // Chunk management helpers | 297 | + | // generateContainerLogsURL generates the URL for viewing container logs |
| 259 | func isTransferComplete(transferID string) bool { | 298 | func isTransferComplete(transferID string) bool { | ||
| 260 | chunkStore.mu.Lock() | 299 | chunkStore.mu.Lock() | ||
| 261 | defer chunkStore.mu.Unlock() | 300 | defer chunkStore.mu.Unlock() | ||
| @@ -278,6 +317,7 @@ func isTransferComplete(transferID string) bool { | |||||
| 278 | return isComplete | 317 | return isComplete | ||
| 279 | } | 318 | } | ||
| 280 | 319 | ||||
| 320 | + | // Cleanup helpers for old transfers | |||
| 281 | func cleanupTransfer(transferID string) { | 321 | func cleanupTransfer(transferID string) { | ||
| 282 | chunkStore.mu.Lock() | 322 | chunkStore.mu.Lock() | ||
| 283 | defer chunkStore.mu.Unlock() | 323 | defer chunkStore.mu.Unlock() | ||
| @@ -287,6 +327,7 @@ func cleanupTransfer(transferID string) { | |||||
| 287 | delete(chunkStore.started, transferID) | 327 | delete(chunkStore.started, transferID) | ||
| 288 | } | 328 | } | ||
| 289 | 329 | ||||
| 330 | + | // cleanupOldTransfers periodically checks for and cleans up old transfers | |||
| 290 | func cleanupOldTransfers() { | 331 | func cleanupOldTransfers() { | ||
| 291 | ticker := time.NewTicker(cleanupInterval) | 332 | ticker := time.NewTicker(cleanupInterval) | ||
| 292 | defer ticker.Stop() // Good practice to clean up the ticker | 333 | defer ticker.Stop() // Good practice to clean up the ticker | ||
| @@ -306,7 +347,7 @@ func cleanupOldTransfers() { | |||||
| 306 | } | 347 | } | ||
| 307 | } | 348 | } | ||
| 308 | 349 | ||||
| 309 | - | // Error extraction helper | 350 | + | // extractContainerID extracts the container ID from an error message |
| 310 | func extractContainerID(errorMessage string) string { | 351 | func extractContainerID(errorMessage string) string { | ||
| 311 | re := regexp.MustCompile(`by\s+([0-9a-f]+)\.`) | 352 | re := regexp.MustCompile(`by\s+([0-9a-f]+)\.`) | ||
| 312 | match := re.FindStringSubmatch(errorMessage) | 353 | match := re.FindStringSubmatch(errorMessage) | ||
| @@ -316,7 +357,7 @@ func extractContainerID(errorMessage string) string { | |||||
| 316 | return "" | 357 | return "" | ||
| 317 | } | 358 | } | ||
| 318 | 359 | ||||
| 319 | - | // File verification helper | 360 | + | // verifyFileContents checks if the file size matches the expected size |
| 320 | func verifyFileContents(file *os.File, expectedSize int64) error { | 361 | func verifyFileContents(file *os.File, expectedSize int64) error { | ||
| 321 | actualSize, err := file.Seek(0, 2) // Seek to end | 362 | actualSize, err := file.Seek(0, 2) // Seek to end | ||
| 322 | if err != nil { | 363 | if err != nil { | ||
| @@ -331,6 +372,7 @@ func verifyFileContents(file *os.File, expectedSize int64) error { | |||||
| 331 | return err | 372 | return err | ||
| 332 | } | 373 | } | ||
| 333 | 374 | ||||
| 375 | + | // storeIDMapping stores a mapping between a short ID and a full ID | |||
| 334 | func storeIDMapping(shortID, fullID string) { | 376 | func storeIDMapping(shortID, fullID string) { | ||
| 335 | safelyInteractWithIDMap(Update, shortID, fullID) | 377 | safelyInteractWithIDMap(Update, shortID, fullID) | ||
| 336 | } | 378 | } | ||
M internal/httpserve/handlers/endpoint_push.go +180 -112
| @@ -2,101 +2,96 @@ | |||
| 2 | 2 | package handlers | |
| 3 | 3 | ||
| 4 | 4 | import ( | |
| 5 | + | "archive/tar" | |
| 5 | 6 | "bytes" | |
| 6 | 7 | "encoding/json" | |
| 7 | 8 | "fmt" | |
| 8 | 9 | "io" | |
| 9 | 10 | "net/http" | |
| 10 | 11 | "os" | |
| 12 | + | "path/filepath" | |
| 13 | + | "runtime" | |
| 14 | + | "strings" | |
| 11 | 15 | ||
| 12 | 16 | "github.com/bnema/gordon/internal/common" | |
| 13 | 17 | "github.com/bnema/gordon/internal/server" | |
| 14 | 18 | "github.com/bnema/gordon/pkg/docker" | |
| 15 | - | "github.com/bnema/gordon/pkg/store" | |
| 19 | + | "github.com/bnema/gordon/pkg/verify" | |
| 16 | 20 | "github.com/charmbracelet/log" | |
| 17 | 21 | "github.com/labstack/echo/v4" | |
| 18 | 22 | ) | |
| 19 | 23 | ||
| 20 | 24 | // PostPush handles the image push request | |
| 21 | 25 | func PostPush(c echo.Context, a *server.App) error { | |
| 22 | - | ||
| 23 | 26 | requestID := c.Response().Header().Get(echo.HeaderXRequestID) | |
| 24 | - | log.Debug("PostPush started", | |
| 27 | + | log.Info("PostPush started", | |
| 25 | 28 | "requestID", requestID, | |
| 26 | 29 | "path", c.Request().URL.Path) | |
| 30 | + | payload, err := validateAndPreparePushPayload(c) | |
| 31 | + | if err != nil { | |
| 32 | + | return sendJSONResponse(c, http.StatusBadRequest, PushResponse{ | |
| 33 | + | Success: false, | |
| 34 | + | Message: err.Error(), | |
| 35 | + | }) | |
| 36 | + | } | |
| 27 | 37 | ||
| 28 | - | chunkMetadataStr := c.Request().Header.Get("X-Chunk-Metadata") | |
| 29 | - | if chunkMetadataStr != "" { | |
| 30 | - | return handleChunkedPush(c, a, chunkMetadataStr) | |
| 38 | + | _, err = saveAndImportPushImage(c, a, payload) | |
| 39 | + | if err != nil { | |
| 40 | + | return sendJSONResponse(c, http.StatusInternalServerError, PushResponse{ | |
| 41 | + | Success: false, | |
| 42 | + | Message: err.Error(), | |
| 43 | + | }) | |
| 31 | 44 | } | |
| 32 | 45 | ||
| 33 | - | return handleRegularPush(c, a) | |
| 46 | + | shortID := generateShortID(payload.ImageID) | |
| 47 | + | storeIDMapping(shortID, payload.ImageID) | |
| 48 | + | ||
| 49 | + | createContainerURL := generateCreateContainerURL(a, shortID) | |
| 50 | + | ||
| 51 | + | return sendJSONResponse(c, http.StatusOK, common.PushResponse{ | |
| 52 | + | Success: true, | |
| 53 | + | Message: "Image pushed and imported successfully", | |
| 54 | + | CreateContainerURL: createContainerURL, | |
| 55 | + | }) | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | // PostPushChunked handles chunked (large) image pushes | |
| 59 | + | func PostPushChunked(c echo.Context, a *server.App) error { | |
| 60 | + | return handleChunkedPush(c, a, c.Request().Header.Get("X-Chunk-Metadata")) | |
| 34 | 61 | } | |
| 35 | - | func handleChunkedPush(c echo.Context, a *server.App, chunkMetadataStr string) error { | |
| 36 | - | var metadata common.ChunkMetadata | |
| 37 | - | if err := json.Unmarshal([]byte(chunkMetadataStr), &metadata); err != nil { | |
| 62 | + | ||
| 63 | + | func handleChunkedPush(c echo.Context, a *server.App, metadataStr string) error { | |
| 64 | + | var metadata ChunkMetadata | |
| 65 | + | if err := json.Unmarshal([]byte(metadataStr), &metadata); err != nil { | |
| 38 | 66 | return sendJSONResponse(c, http.StatusBadRequest, PushResponse{ | |
| 39 | 67 | Success: false, | |
| 40 | 68 | Message: "Invalid chunk metadata", | |
| 41 | 69 | }) | |
| 42 | 70 | } | |
| 43 | 71 | ||
| 44 | - | // Create temporary file for the transfer if it doesn't exist | |
| 45 | - | transferPath := fmt.Sprintf("data/transfers/%s.tar", metadata.TransferID) | |
| 46 | - | if metadata.ChunkNumber == 0 { | |
| 47 | - | if err := os.MkdirAll("data/transfers", 0755); err != nil { | |
| 48 | - | return sendJSONResponse(c, http.StatusInternalServerError, PushResponse{ | |
| 49 | - | Success: false, | |
| 50 | - | Message: fmt.Sprintf("Failed to create transfers directory: %v", err), | |
| 51 | - | }) | |
| 52 | - | } | |
| 72 | + | // Store chunk data for multi-chunk transfers | |
| 73 | + | chunkStore.mu.Lock() | |
| 74 | + | if chunkStore.chunks[metadata.TransferID] == nil { | |
| 75 | + | chunkStore.chunks[metadata.TransferID] = make(map[int][]byte) | |
| 76 | + | chunkStore.metadata[metadata.TransferID] = &metadata | |
| 53 | 77 | } | |
| 54 | 78 | ||
| 55 | - | // Open file in append mode | |
| 56 | - | f, err := os.OpenFile(transferPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644) | |
| 79 | + | chunkData, err := io.ReadAll(c.Request().Body) | |
| 57 | 80 | if err != nil { | |
| 81 | + | chunkStore.mu.Unlock() | |
| 58 | 82 | return sendJSONResponse(c, http.StatusInternalServerError, PushResponse{ | |
| 59 | 83 | Success: false, | |
| 60 | - | Message: fmt.Sprintf("Failed to open transfer file: %v", err), | |
| 61 | - | }) | |
| 62 | - | } | |
| 63 | - | defer f.Close() | |
| 64 | - | ||
| 65 | - | // Copy chunk data to file | |
| 66 | - | if _, err := io.Copy(f, c.Request().Body); err != nil { | |
| 67 | - | return sendJSONResponse(c, http.StatusInternalServerError, PushResponse{ | |
| 68 | - | Success: false, | |
| 69 | - | Message: fmt.Sprintf("Failed to write chunk: %v", err), | |
| 84 | + | Message: "Failed to read chunk data", | |
| 70 | 85 | }) | |
| 71 | 86 | } | |
| 72 | 87 | ||
| 73 | - | // If this is the last chunk, process the complete image | |
| 74 | - | if metadata.ChunkNumber == metadata.TotalChunks-1 { | |
| 75 | - | // Close the file before importing | |
| 76 | - | f.Close() | |
| 77 | - | ||
| 78 | - | imageID, err := docker.ImportImageToEngine(transferPath) | |
| 79 | - | if err != nil { | |
| 80 | - | os.Remove(transferPath) | |
| 81 | - | return sendJSONResponse(c, http.StatusInternalServerError, PushResponse{ | |
| 82 | - | Success: false, | |
| 83 | - | Message: fmt.Sprintf("Failed to import image: %v", err), | |
| 84 | - | }) | |
| 85 | - | } | |
| 86 | - | ||
| 87 | - | // Clean up | |
| 88 | - | os.Remove(transferPath) | |
| 89 | - | ||
| 90 | - | shortID := generateShortID(imageID) | |
| 91 | - | storeIDMapping(shortID, imageID) | |
| 92 | - | createContainerURL := generateCreateContainerURL(a, shortID) | |
| 88 | + | // Store the chunk | |
| 89 | + | chunkStore.chunks[metadata.TransferID][metadata.ChunkNumber] = chunkData | |
| 90 | + | chunkStore.mu.Unlock() | |
| 93 | 91 | ||
| 94 | - | return sendJSONResponse(c, http.StatusOK, PushResponse{ | |
| 95 | - | Success: true, | |
| 96 | - | Message: "Image pushed and imported successfully", | |
| 97 | - | CreateContainerURL: createContainerURL, | |
| 98 | - | ImageID: imageID, | |
| 99 | - | }) | |
| 92 | + | // If this is the last chunk, process the complete transfer | |
| 93 | + | if isTransferComplete(metadata.TransferID) { | |
| 94 | + | return processCompleteChunkedPushTransfer(c, a, metadata.TransferID) | |
| 100 | 95 | } | |
| 101 | 96 | ||
| 102 | 97 | // For intermediate chunks, just acknowledge receipt | |
| @@ -106,94 +101,167 @@ func handleChunkedPush(c echo.Context, a *server.App, chunkMetadataStr string) e | |||
| 106 | 101 | }) | |
| 107 | 102 | } | |
| 108 | 103 | ||
| 109 | - | func handleRegularPush(c echo.Context, a *server.App) error { | |
| 110 | - | payload, err := validateAndPreparePayload(c) | |
| 104 | + | func processCompleteChunkedPushTransfer(c echo.Context, a *server.App, transferID string) error { | |
| 105 | + | log.Info("Starting complete push transfer processing", "transferID", transferID) | |
| 106 | + | ||
| 107 | + | imageName := c.Request().Header.Get("X-Image-Name") | |
| 108 | + | // Get transfer data | |
| 109 | + | metadata, chunks, err := getTransferData(transferID) | |
| 111 | 110 | if err != nil { | |
| 112 | - | return sendJSONResponse(c, http.StatusBadRequest, PushResponse{ | |
| 113 | - | Success: false, | |
| 114 | - | Message: err.Error(), | |
| 115 | - | }) | |
| 111 | + | return sendErrorResponse(c, "Failed to get transfer data", err) | |
| 116 | 112 | } | |
| 117 | 113 | ||
| 118 | - | err = saveAndImportImage(c, a, payload) | |
| 114 | + | // Create temporary file with .tar extension | |
| 115 | + | tmpDir, err := os.MkdirTemp("", "docker-import-*") | |
| 119 | 116 | if err != nil { | |
| 120 | - | return sendJSONResponse(c, http.StatusInternalServerError, PushResponse{ | |
| 121 | - | Success: false, | |
| 122 | - | Message: err.Error(), | |
| 123 | - | }) | |
| 117 | + | return sendErrorResponse(c, "Failed to create temp directory", err) | |
| 124 | 118 | } | |
| 119 | + | defer os.RemoveAll(tmpDir) | |
| 125 | 120 | ||
| 126 | - | shortID := generateShortID(payload.ImageID) | |
| 127 | - | storeIDMapping(shortID, payload.ImageID) | |
| 121 | + | tmpFile, err := os.Create(filepath.Join(tmpDir, "image.tar")) | |
| 122 | + | if err != nil { | |
| 123 | + | return sendErrorResponse(c, "Failed to create temp file", err) | |
| 124 | + | } | |
| 125 | + | defer tmpFile.Close() | |
| 126 | + | ||
| 127 | + | // Write chunks to file | |
| 128 | + | if err := writeChunksToFile(tmpFile, chunks, metadata.TotalChunks); err != nil { | |
| 129 | + | return sendErrorResponse(c, "Failed to write chunks", err) | |
| 130 | + | } | |
| 131 | + | ||
| 132 | + | // Verify the file is a valid tar archive | |
| 133 | + | if err := verify.VerifyTarFile(tmpFile.Name()); err != nil { | |
| 134 | + | return sendErrorResponse(c, "Invalid tar archive", err) | |
| 135 | + | } | |
| 136 | + | ||
| 137 | + | // Import the image | |
| 138 | + | log.Info("Importing image from file", "path", tmpFile.Name()) | |
| 139 | + | imageID, err := docker.ImportImageToEngine(tmpFile.Name()) | |
| 140 | + | if err != nil { | |
| 141 | + | if strings.Contains(err.Error(), "platform compatibility check failed") { | |
| 142 | + | log.Error("Platform compatibility check failed", | |
| 143 | + | "error", err, | |
| 144 | + | "imageName", imageName) | |
| 145 | + | return sendJSONResponse(c, http.StatusBadRequest, DeployResponse{ | |
| 146 | + | Success: false, | |
| 147 | + | Message: fmt.Sprintf("Image platform not compatible with server. Server is running on %s/%s. Please use a compatible image.", runtime.GOOS, runtime.GOARCH), | |
| 148 | + | }) | |
| 149 | + | } | |
| 150 | + | return sendErrorResponse(c, "Failed to import image", err) | |
| 151 | + | } | |
| 152 | + | ||
| 153 | + | // Cleanup and prepare response | |
| 154 | + | cleanupTransfer(transferID) | |
| 155 | + | shortID := generateShortID(imageID) | |
| 156 | + | storeIDMapping(shortID, imageID) | |
| 128 | 157 | createContainerURL := generateCreateContainerURL(a, shortID) | |
| 129 | 158 | ||
| 130 | 159 | return sendJSONResponse(c, http.StatusOK, PushResponse{ | |
| 131 | 160 | Success: true, | |
| 132 | 161 | Message: "Image pushed and imported successfully", | |
| 133 | 162 | CreateContainerURL: createContainerURL, | |
| 163 | + | ImageID: imageID, | |
| 134 | 164 | }) | |
| 135 | 165 | } | |
| 136 | 166 | ||
| 137 | - | func processCompletePushTransfer(c echo.Context, a *server.App, transferID string) error { | |
| 167 | + | // Helper functions | |
| 168 | + | func getTransferData(transferID string) (*ChunkMetadata, map[int][]byte, error) { | |
| 138 | 169 | chunkStore.mu.Lock() | |
| 139 | - | metadata := chunkStore.metadata[transferID] | |
| 140 | - | chunks := chunkStore.chunks[transferID] | |
| 141 | - | chunkStore.mu.Unlock() | |
| 170 | + | defer chunkStore.mu.Unlock() | |
| 142 | 171 | ||
| 172 | + | metadata := chunkStore.metadata[transferID] | |
| 143 | 173 | if metadata == nil { | |
| 144 | - | return sendJSONResponse(c, http.StatusBadRequest, PushResponse{ | |
| 145 | - | Success: false, | |
| 146 | - | Message: "Transfer metadata not found", | |
| 147 | - | }) | |
| 174 | + | return nil, nil, fmt.Errorf("transfer metadata not found") | |
| 175 | + | } | |
| 176 | + | return metadata, chunkStore.chunks[transferID], nil | |
| 177 | + | } | |
| 178 | + | ||
| 179 | + | func createTempFile() (*os.File, func(), error) { | |
| 180 | + | tmpDir, err := os.MkdirTemp("", "docker-import-*") | |
| 181 | + | if err != nil { | |
| 182 | + | return nil, nil, err | |
| 183 | + | } | |
| 184 | + | ||
| 185 | + | tmpFile, err := os.Create(filepath.Join(tmpDir, "image.tar")) | |
| 186 | + | if err != nil { | |
| 187 | + | os.RemoveAll(tmpDir) | |
| 188 | + | return nil, nil, err | |
| 189 | + | } | |
| 190 | + | ||
| 191 | + | cleanup := func() { | |
| 192 | + | tmpFile.Close() | |
| 193 | + | os.RemoveAll(tmpDir) | |
| 194 | + | } | |
| 195 | + | ||
| 196 | + | return tmpFile, cleanup, nil | |
| 197 | + | } | |
| 198 | + | ||
| 199 | + | func writeChunksToFile(file *os.File, chunks map[int][]byte, totalChunks int) error { | |
| 200 | + | // First, seek to the beginning of the file | |
| 201 | + | if _, err := file.Seek(0, 0); err != nil { | |
| 202 | + | return fmt.Errorf("failed to seek to beginning: %w", err) | |
| 148 | 203 | } | |
| 149 | 204 | ||
| 150 | - | // Combine chunks | |
| 151 | - | combinedData := bytes.NewBuffer(nil) | |
| 152 | - | for i := 0; i < metadata.TotalChunks; i++ { | |
| 205 | + | // Create a temporary buffer to hold all chunks | |
| 206 | + | tempBuffer := new(bytes.Buffer) | |
| 207 | + | ||
| 208 | + | // Write chunks in order | |
| 209 | + | for i := 0; i < totalChunks; i++ { | |
| 153 | 210 | chunk, exists := chunks[i] | |
| 154 | 211 | if !exists { | |
| 155 | - | return sendJSONResponse(c, http.StatusBadRequest, PushResponse{ | |
| 156 | - | Success: false, | |
| 157 | - | Message: fmt.Sprintf("Missing chunk %d", i), | |
| 158 | - | }) | |
| 212 | + | return fmt.Errorf("missing chunk %d", i) | |
| 213 | + | } | |
| 214 | + | ||
| 215 | + | if _, err := tempBuffer.Write(chunk); err != nil { | |
| 216 | + | return fmt.Errorf("failed to write chunk %d to buffer: %w", i, err) | |
| 159 | 217 | } | |
| 160 | - | combinedData.Write(chunk) | |
| 161 | 218 | } | |
| 162 | 219 | ||
| 163 | - | // Clean up the transfer data early | |
| 164 | - | defer cleanupTransfer(transferID) | |
| 220 | + | // Write the complete buffer to file | |
| 221 | + | if _, err := io.Copy(file, tempBuffer); err != nil { | |
| 222 | + | return fmt.Errorf("failed to write buffer to file: %w", err) | |
| 223 | + | } | |
| 165 | 224 | ||
| 166 | - | // Process the image | |
| 167 | - | imageFileName := sanitizeImageFileName(metadata.ImageName) | |
| 168 | - | imagePath, err := store.SaveImageToStorage(&a.Config, imageFileName, combinedData) | |
| 169 | - | if err != nil { | |
| 170 | - | return sendJSONResponse(c, http.StatusInternalServerError, PushResponse{ | |
| 171 | - | Success: false, | |
| 172 | - | Message: fmt.Sprintf("Failed to save image: %v", err), | |
| 173 | - | }) | |
| 225 | + | // Ensure all data is written to disk | |
| 226 | + | if err := file.Sync(); err != nil { | |
| 227 | + | return fmt.Errorf("failed to sync file: %w", err) | |
| 228 | + | } | |
| 229 | + | ||
| 230 | + | // Reset file pointer to beginning | |
| 231 | + | if _, err := file.Seek(0, 0); err != nil { | |
| 232 | + | return fmt.Errorf("failed to reset file pointer: %w", err) | |
| 233 | + | } | |
| 234 | + | ||
| 235 | + | return nil | |
| 236 | + | } | |
| 237 | + | ||
| 238 | + | func prepareFileForImport(file *os.File) error { | |
| 239 | + | // Reset to beginning of file | |
| 240 | + | if _, err := file.Seek(0, 0); err != nil { | |
| 241 | + | return fmt.Errorf("failed to seek to beginning: %w", err) | |
| 174 | 242 | } | |
| 175 | 243 | ||
| 176 | - | imageID, err := docker.ImportImageToEngine(imagePath) | |
| 244 | + | // Verify the tar format | |
| 245 | + | tr := tar.NewReader(file) | |
| 246 | + | header, err := tr.Next() | |
| 177 | 247 | if err != nil { | |
| 178 | - | store.RemoveFromStorage(imagePath) | |
| 179 | - | return sendJSONResponse(c, http.StatusInternalServerError, PushResponse{ | |
| 180 | - | Success: false, | |
| 181 | - | Message: fmt.Sprintf("Failed to import image: %v", err), | |
| 182 | - | }) | |
| 248 | + | return fmt.Errorf("invalid tar format: %w", err) | |
| 183 | 249 | } | |
| 184 | 250 | ||
| 185 | - | if err := store.RemoveFromStorage(imagePath); err != nil { | |
| 186 | - | log.Warn("Failed to remove temporary image file", "error", err) | |
| 251 | + | // Check if this is a valid Docker image tar | |
| 252 | + | if header.Name == "manifest.json" { | |
| 253 | + | // Reset file pointer for subsequent reads | |
| 254 | + | _, err = file.Seek(0, 0) | |
| 255 | + | return err | |
| 187 | 256 | } | |
| 188 | 257 | ||
| 189 | - | shortID := generateShortID(imageID) | |
| 190 | - | storeIDMapping(shortID, imageID) | |
| 191 | - | createContainerURL := generateCreateContainerURL(a, shortID) | |
| 258 | + | return fmt.Errorf("file does not appear to be a valid Docker image archive") | |
| 259 | + | } | |
| 192 | 260 | ||
| 193 | - | return sendJSONResponse(c, http.StatusOK, PushResponse{ | |
| 194 | - | Success: true, | |
| 195 | - | Message: "Image pushed and imported successfully", | |
| 196 | - | CreateContainerURL: createContainerURL, | |
| 197 | - | ImageID: imageID, | |
| 261 | + | func sendErrorResponse(c echo.Context, message string, err error) error { | |
| 262 | + | log.Error(message, "error", err) | |
| 263 | + | return sendJSONResponse(c, http.StatusInternalServerError, PushResponse{ | |
| 264 | + | Success: false, | |
| 265 | + | Message: message, | |
| 198 | 266 | }) | |
| 199 | 267 | } | |
| @@ -2,101 +2,96 @@ | |||||
| 2 | package handlers | 2 | package handlers | ||
| 3 | 3 | ||||
| 4 | import ( | 4 | import ( | ||
| 5 | + | "archive/tar" | |||
| 5 | "bytes" | 6 | "bytes" | ||
| 6 | "encoding/json" | 7 | "encoding/json" | ||
| 7 | "fmt" | 8 | "fmt" | ||
| 8 | "io" | 9 | "io" | ||
| 9 | "net/http" | 10 | "net/http" | ||
| 10 | "os" | 11 | "os" | ||
| 12 | + | "path/filepath" | |||
| 13 | + | "runtime" | |||
| 14 | + | "strings" | |||
| 11 | 15 | ||||
| 12 | "github.com/bnema/gordon/internal/common" | 16 | "github.com/bnema/gordon/internal/common" | ||
| 13 | "github.com/bnema/gordon/internal/server" | 17 | "github.com/bnema/gordon/internal/server" | ||
| 14 | "github.com/bnema/gordon/pkg/docker" | 18 | "github.com/bnema/gordon/pkg/docker" | ||
| 15 | - | "github.com/bnema/gordon/pkg/store" | 19 | + | "github.com/bnema/gordon/pkg/verify" |
| 16 | "github.com/charmbracelet/log" | 20 | "github.com/charmbracelet/log" | ||
| 17 | "github.com/labstack/echo/v4" | 21 | "github.com/labstack/echo/v4" | ||
| 18 | ) | 22 | ) | ||
| 19 | 23 | ||||
| 20 | // PostPush handles the image push request | 24 | // PostPush handles the image push request | ||
| 21 | func PostPush(c echo.Context, a *server.App) error { | 25 | func PostPush(c echo.Context, a *server.App) error { | ||
| 22 | - | ||||
| 23 | requestID := c.Response().Header().Get(echo.HeaderXRequestID) | 26 | requestID := c.Response().Header().Get(echo.HeaderXRequestID) | ||
| 24 | - | log.Debug("PostPush started", | 27 | + | log.Info("PostPush started", |
| 25 | "requestID", requestID, | 28 | "requestID", requestID, | ||
| 26 | "path", c.Request().URL.Path) | 29 | "path", c.Request().URL.Path) | ||
| 30 | + | payload, err := validateAndPreparePushPayload(c) | |||
| 31 | + | if err != nil { | |||
| 32 | + | return sendJSONResponse(c, http.StatusBadRequest, PushResponse{ | |||
| 33 | + | Success: false, | |||
| 34 | + | Message: err.Error(), | |||
| 35 | + | }) | |||
| 36 | + | } | |||
| 27 | 37 | ||||
| 28 | - | chunkMetadataStr := c.Request().Header.Get("X-Chunk-Metadata") | 38 | + | _, err = saveAndImportPushImage(c, a, payload) |
| 29 | - | if chunkMetadataStr != "" { | 39 | + | if err != nil { |
| 30 | - | return handleChunkedPush(c, a, chunkMetadataStr) | 40 | + | return sendJSONResponse(c, http.StatusInternalServerError, PushResponse{ |
| 41 | + | Success: false, | |||
| 42 | + | Message: err.Error(), | |||
| 43 | + | }) | |||
| 31 | } | 44 | } | ||
| 32 | 45 | ||||
| 33 | - | return handleRegularPush(c, a) | 46 | + | shortID := generateShortID(payload.ImageID) |
| 47 | + | storeIDMapping(shortID, payload.ImageID) | |||
| 48 | + | ||||
| 49 | + | createContainerURL := generateCreateContainerURL(a, shortID) | |||
| 50 | + | ||||
| 51 | + | return sendJSONResponse(c, http.StatusOK, common.PushResponse{ | |||
| 52 | + | Success: true, | |||
| 53 | + | Message: "Image pushed and imported successfully", | |||
| 54 | + | CreateContainerURL: createContainerURL, | |||
| 55 | + | }) | |||
| 56 | + | } | |||
| 57 | + | ||||
| 58 | + | // PostPushChunked handles chunked (large) image pushes | |||
| 59 | + | func PostPushChunked(c echo.Context, a *server.App) error { | |||
| 60 | + | return handleChunkedPush(c, a, c.Request().Header.Get("X-Chunk-Metadata")) | |||
| 34 | } | 61 | } | ||
| 35 | - | func handleChunkedPush(c echo.Context, a *server.App, chunkMetadataStr string) error { | 62 | + | |
| 36 | - | var metadata common.ChunkMetadata | 63 | + | func handleChunkedPush(c echo.Context, a *server.App, metadataStr string) error { |
| 37 | - | if err := json.Unmarshal([]byte(chunkMetadataStr), &metadata); err != nil { | 64 | + | var metadata ChunkMetadata |
| 65 | + | if err := json.Unmarshal([]byte(metadataStr), &metadata); err != nil { | |||
| 38 | return sendJSONResponse(c, http.StatusBadRequest, PushResponse{ | 66 | return sendJSONResponse(c, http.StatusBadRequest, PushResponse{ | ||
| 39 | Success: false, | 67 | Success: false, | ||
| 40 | Message: "Invalid chunk metadata", | 68 | Message: "Invalid chunk metadata", | ||
| 41 | }) | 69 | }) | ||
| 42 | } | 70 | } | ||
| 43 | 71 | ||||
| 44 | - | // Create temporary file for the transfer if it doesn't exist | 72 | + | // Store chunk data for multi-chunk transfers |
| 45 | - | transferPath := fmt.Sprintf("data/transfers/%s.tar", metadata.TransferID) | 73 | + | chunkStore.mu.Lock() |
| 46 | - | if metadata.ChunkNumber == 0 { | 74 | + | if chunkStore.chunks[metadata.TransferID] == nil { |
| 47 | - | if err := os.MkdirAll("data/transfers", 0755); err != nil { | 75 | + | chunkStore.chunks[metadata.TransferID] = make(map[int][]byte) |
| 48 | - | return sendJSONResponse(c, http.StatusInternalServerError, PushResponse{ | 76 | + | chunkStore.metadata[metadata.TransferID] = &metadata |
| 49 | - | Success: false, | |||
| 50 | - | Message: fmt.Sprintf("Failed to create transfers directory: %v", err), | |||
| 51 | - | }) | |||
| 52 | - | } | |||
| 53 | } | 77 | } | ||
| 54 | 78 | ||||
| 55 | - | // Open file in append mode | 79 | + | chunkData, err := io.ReadAll(c.Request().Body) |
| 56 | - | f, err := os.OpenFile(transferPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644) | |||
| 57 | if err != nil { | 80 | if err != nil { | ||
| 81 | + | chunkStore.mu.Unlock() | |||
| 58 | return sendJSONResponse(c, http.StatusInternalServerError, PushResponse{ | 82 | return sendJSONResponse(c, http.StatusInternalServerError, PushResponse{ | ||
| 59 | Success: false, | 83 | Success: false, | ||
| 60 | - | Message: fmt.Sprintf("Failed to open transfer file: %v", err), | 84 | + | Message: "Failed to read chunk data", |
| 61 | - | }) | |||
| 62 | - | } | |||
| 63 | - | defer f.Close() | |||
| 64 | - | ||||
| 65 | - | // Copy chunk data to file | |||
| 66 | - | if _, err := io.Copy(f, c.Request().Body); err != nil { | |||
| 67 | - | return sendJSONResponse(c, http.StatusInternalServerError, PushResponse{ | |||
| 68 | - | Success: false, | |||
| 69 | - | Message: fmt.Sprintf("Failed to write chunk: %v", err), | |||
| 70 | }) | 85 | }) | ||
| 71 | } | 86 | } | ||
| 72 | 87 | ||||
| 73 | - | // If this is the last chunk, process the complete image | 88 | + | // Store the chunk |
| 74 | - | if metadata.ChunkNumber == metadata.TotalChunks-1 { | 89 | + | chunkStore.chunks[metadata.TransferID][metadata.ChunkNumber] = chunkData |
| 75 | - | // Close the file before importing | 90 | + | chunkStore.mu.Unlock() |
| 76 | - | f.Close() | |||
| 77 | - | ||||
| 78 | - | imageID, err := docker.ImportImageToEngine(transferPath) | |||
| 79 | - | if err != nil { | |||
| 80 | - | os.Remove(transferPath) | |||
| 81 | - | return sendJSONResponse(c, http.StatusInternalServerError, PushResponse{ | |||
| 82 | - | Success: false, | |||
| 83 | - | Message: fmt.Sprintf("Failed to import image: %v", err), | |||
| 84 | - | }) | |||
| 85 | - | } | |||
| 86 | - | ||||
| 87 | - | // Clean up | |||
| 88 | - | os.Remove(transferPath) | |||
| 89 | - | ||||
| 90 | - | shortID := generateShortID(imageID) | |||
| 91 | - | storeIDMapping(shortID, imageID) | |||
| 92 | - | createContainerURL := generateCreateContainerURL(a, shortID) | |||
| 93 | 91 | ||||
| 94 | - | return sendJSONResponse(c, http.StatusOK, PushResponse{ | 92 | + | // If this is the last chunk, process the complete transfer |
| 95 | - | Success: true, | 93 | + | if isTransferComplete(metadata.TransferID) { |
| 96 | - | Message: "Image pushed and imported successfully", | 94 | + | return processCompleteChunkedPushTransfer(c, a, metadata.TransferID) |
| 97 | - | CreateContainerURL: createContainerURL, | |||
| 98 | - | ImageID: imageID, | |||
| 99 | - | }) | |||
| 100 | } | 95 | } | ||
| 101 | 96 | ||||
| 102 | // For intermediate chunks, just acknowledge receipt | 97 | // For intermediate chunks, just acknowledge receipt | ||
| @@ -106,94 +101,167 @@ func handleChunkedPush(c echo.Context, a *server.App, chunkMetadataStr string) e | |||||
| 106 | }) | 101 | }) | ||
| 107 | } | 102 | } | ||
| 108 | 103 | ||||
| 109 | - | func handleRegularPush(c echo.Context, a *server.App) error { | 104 | + | func processCompleteChunkedPushTransfer(c echo.Context, a *server.App, transferID string) error { |
| 110 | - | payload, err := validateAndPreparePayload(c) | 105 | + | log.Info("Starting complete push transfer processing", "transferID", transferID) |
| 106 | + | ||||
| 107 | + | imageName := c.Request().Header.Get("X-Image-Name") | |||
| 108 | + | // Get transfer data | |||
| 109 | + | metadata, chunks, err := getTransferData(transferID) | |||
| 111 | if err != nil { | 110 | if err != nil { | ||
| 112 | - | return sendJSONResponse(c, http.StatusBadRequest, PushResponse{ | 111 | + | return sendErrorResponse(c, "Failed to get transfer data", err) |
| 113 | - | Success: false, | |||
| 114 | - | Message: err.Error(), | |||
| 115 | - | }) | |||
| 116 | } | 112 | } | ||
| 117 | 113 | ||||
| 118 | - | err = saveAndImportImage(c, a, payload) | 114 | + | // Create temporary file with .tar extension |
| 115 | + | tmpDir, err := os.MkdirTemp("", "docker-import-*") | |||
| 119 | if err != nil { | 116 | if err != nil { | ||
| 120 | - | return sendJSONResponse(c, http.StatusInternalServerError, PushResponse{ | 117 | + | return sendErrorResponse(c, "Failed to create temp directory", err) |
| 121 | - | Success: false, | |||
| 122 | - | Message: err.Error(), | |||
| 123 | - | }) | |||
| 124 | } | 118 | } | ||
| 119 | + | defer os.RemoveAll(tmpDir) | |||
| 125 | 120 | ||||
| 126 | - | shortID := generateShortID(payload.ImageID) | 121 | + | tmpFile, err := os.Create(filepath.Join(tmpDir, "image.tar")) |
| 127 | - | storeIDMapping(shortID, payload.ImageID) | 122 | + | if err != nil { |
| 123 | + | return sendErrorResponse(c, "Failed to create temp file", err) | |||
| 124 | + | } | |||
| 125 | + | defer tmpFile.Close() | |||
| 126 | + | ||||
| 127 | + | // Write chunks to file | |||
| 128 | + | if err := writeChunksToFile(tmpFile, chunks, metadata.TotalChunks); err != nil { | |||
| 129 | + | return sendErrorResponse(c, "Failed to write chunks", err) | |||
| 130 | + | } | |||
| 131 | + | ||||
| 132 | + | // Verify the file is a valid tar archive | |||
| 133 | + | if err := verify.VerifyTarFile(tmpFile.Name()); err != nil { | |||
| 134 | + | return sendErrorResponse(c, "Invalid tar archive", err) | |||
| 135 | + | } | |||
| 136 | + | ||||
| 137 | + | // Import the image | |||
| 138 | + | log.Info("Importing image from file", "path", tmpFile.Name()) | |||
| 139 | + | imageID, err := docker.ImportImageToEngine(tmpFile.Name()) | |||
| 140 | + | if err != nil { | |||
| 141 | + | if strings.Contains(err.Error(), "platform compatibility check failed") { | |||
| 142 | + | log.Error("Platform compatibility check failed", | |||
| 143 | + | "error", err, | |||
| 144 | + | "imageName", imageName) | |||
| 145 | + | return sendJSONResponse(c, http.StatusBadRequest, DeployResponse{ | |||
| 146 | + | Success: false, | |||
| 147 | + | Message: fmt.Sprintf("Image platform not compatible with server. Server is running on %s/%s. Please use a compatible image.", runtime.GOOS, runtime.GOARCH), | |||
| 148 | + | }) | |||
| 149 | + | } | |||
| 150 | + | return sendErrorResponse(c, "Failed to import image", err) | |||
| 151 | + | } | |||
| 152 | + | ||||
| 153 | + | // Cleanup and prepare response | |||
| 154 | + | cleanupTransfer(transferID) | |||
| 155 | + | shortID := generateShortID(imageID) | |||
| 156 | + | storeIDMapping(shortID, imageID) | |||
| 128 | createContainerURL := generateCreateContainerURL(a, shortID) | 157 | createContainerURL := generateCreateContainerURL(a, shortID) | ||
| 129 | 158 | ||||
| 130 | return sendJSONResponse(c, http.StatusOK, PushResponse{ | 159 | return sendJSONResponse(c, http.StatusOK, PushResponse{ | ||
| 131 | Success: true, | 160 | Success: true, | ||
| 132 | Message: "Image pushed and imported successfully", | 161 | Message: "Image pushed and imported successfully", | ||
| 133 | CreateContainerURL: createContainerURL, | 162 | CreateContainerURL: createContainerURL, | ||
| 163 | + | ImageID: imageID, | |||
| 134 | }) | 164 | }) | ||
| 135 | } | 165 | } | ||
| 136 | 166 | ||||
| 137 | - | func processCompletePushTransfer(c echo.Context, a *server.App, transferID string) error { | 167 | + | // Helper functions |
| 168 | + | func getTransferData(transferID string) (*ChunkMetadata, map[int][]byte, error) { | |||
| 138 | chunkStore.mu.Lock() | 169 | chunkStore.mu.Lock() | ||
| 139 | - | metadata := chunkStore.metadata[transferID] | 170 | + | defer chunkStore.mu.Unlock() |
| 140 | - | chunks := chunkStore.chunks[transferID] | |||
| 141 | - | chunkStore.mu.Unlock() | |||
| 142 | 171 | ||||
| 172 | + | metadata := chunkStore.metadata[transferID] | |||
| 143 | if metadata == nil { | 173 | if metadata == nil { | ||
| 144 | - | return sendJSONResponse(c, http.StatusBadRequest, PushResponse{ | 174 | + | return nil, nil, fmt.Errorf("transfer metadata not found") |
| 145 | - | Success: false, | 175 | + | } |
| 146 | - | Message: "Transfer metadata not found", | 176 | + | return metadata, chunkStore.chunks[transferID], nil |
| 147 | - | }) | 177 | + | } |
| 178 | + | ||||
| 179 | + | func createTempFile() (*os.File, func(), error) { | |||
| 180 | + | tmpDir, err := os.MkdirTemp("", "docker-import-*") | |||
| 181 | + | if err != nil { | |||
| 182 | + | return nil, nil, err | |||
| 183 | + | } | |||
| 184 | + | ||||
| 185 | + | tmpFile, err := os.Create(filepath.Join(tmpDir, "image.tar")) | |||
| 186 | + | if err != nil { | |||
| 187 | + | os.RemoveAll(tmpDir) | |||
| 188 | + | return nil, nil, err | |||
| 189 | + | } | |||
| 190 | + | ||||
| 191 | + | cleanup := func() { | |||
| 192 | + | tmpFile.Close() | |||
| 193 | + | os.RemoveAll(tmpDir) | |||
| 194 | + | } | |||
| 195 | + | ||||
| 196 | + | return tmpFile, cleanup, nil | |||
| 197 | + | } | |||
| 198 | + | ||||
| 199 | + | func writeChunksToFile(file *os.File, chunks map[int][]byte, totalChunks int) error { | |||
| 200 | + | // First, seek to the beginning of the file | |||
| 201 | + | if _, err := file.Seek(0, 0); err != nil { | |||
| 202 | + | return fmt.Errorf("failed to seek to beginning: %w", err) | |||
| 148 | } | 203 | } | ||
| 149 | 204 | ||||
| 150 | - | // Combine chunks | 205 | + | // Create a temporary buffer to hold all chunks |
| 151 | - | combinedData := bytes.NewBuffer(nil) | 206 | + | tempBuffer := new(bytes.Buffer) |
| 152 | - | for i := 0; i < metadata.TotalChunks; i++ { | 207 | + | |
| 208 | + | // Write chunks in order | |||
| 209 | + | for i := 0; i < totalChunks; i++ { | |||
| 153 | chunk, exists := chunks[i] | 210 | chunk, exists := chunks[i] | ||
| 154 | if !exists { | 211 | if !exists { | ||
| 155 | - | return sendJSONResponse(c, http.StatusBadRequest, PushResponse{ | 212 | + | return fmt.Errorf("missing chunk %d", i) |
| 156 | - | Success: false, | 213 | + | } |
| 157 | - | Message: fmt.Sprintf("Missing chunk %d", i), | 214 | + | |
| 158 | - | }) | 215 | + | if _, err := tempBuffer.Write(chunk); err != nil { |
| 216 | + | return fmt.Errorf("failed to write chunk %d to buffer: %w", i, err) | |||
| 159 | } | 217 | } | ||
| 160 | - | combinedData.Write(chunk) | |||
| 161 | } | 218 | } | ||
| 162 | 219 | ||||
| 163 | - | // Clean up the transfer data early | 220 | + | // Write the complete buffer to file |
| 164 | - | defer cleanupTransfer(transferID) | 221 | + | if _, err := io.Copy(file, tempBuffer); err != nil { |
| 222 | + | return fmt.Errorf("failed to write buffer to file: %w", err) | |||
| 223 | + | } | |||
| 165 | 224 | ||||
| 166 | - | // Process the image | 225 | + | // Ensure all data is written to disk |
| 167 | - | imageFileName := sanitizeImageFileName(metadata.ImageName) | 226 | + | if err := file.Sync(); err != nil { |
| 168 | - | imagePath, err := store.SaveImageToStorage(&a.Config, imageFileName, combinedData) | 227 | + | return fmt.Errorf("failed to sync file: %w", err) |
| 169 | - | if err != nil { | 228 | + | } |
| 170 | - | return sendJSONResponse(c, http.StatusInternalServerError, PushResponse{ | 229 | + | |
| 171 | - | Success: false, | 230 | + | // Reset file pointer to beginning |
| 172 | - | Message: fmt.Sprintf("Failed to save image: %v", err), | 231 | + | if _, err := file.Seek(0, 0); err != nil { |
| 173 | - | }) | 232 | + | return fmt.Errorf("failed to reset file pointer: %w", err) |
| 233 | + | } | |||
| 234 | + | ||||
| 235 | + | return nil | |||
| 236 | + | } | |||
| 237 | + | ||||
| 238 | + | func prepareFileForImport(file *os.File) error { | |||
| 239 | + | // Reset to beginning of file | |||
| 240 | + | if _, err := file.Seek(0, 0); err != nil { | |||
| 241 | + | return fmt.Errorf("failed to seek to beginning: %w", err) | |||
| 174 | } | 242 | } | ||
| 175 | 243 | ||||
| 176 | - | imageID, err := docker.ImportImageToEngine(imagePath) | 244 | + | // Verify the tar format |
| 245 | + | tr := tar.NewReader(file) | |||
| 246 | + | header, err := tr.Next() | |||
| 177 | if err != nil { | 247 | if err != nil { | ||
| 178 | - | store.RemoveFromStorage(imagePath) | 248 | + | return fmt.Errorf("invalid tar format: %w", err) |
| 179 | - | return sendJSONResponse(c, http.StatusInternalServerError, PushResponse{ | |||
| 180 | - | Success: false, | |||
| 181 | - | Message: fmt.Sprintf("Failed to import image: %v", err), | |||
| 182 | - | }) | |||
| 183 | } | 249 | } | ||
| 184 | 250 | ||||
| 185 | - | if err := store.RemoveFromStorage(imagePath); err != nil { | 251 | + | // Check if this is a valid Docker image tar |
| 186 | - | log.Warn("Failed to remove temporary image file", "error", err) | 252 | + | if header.Name == "manifest.json" { |
| 253 | + | // Reset file pointer for subsequent reads | |||
| 254 | + | _, err = file.Seek(0, 0) | |||
| 255 | + | return err | |||
| 187 | } | 256 | } | ||
| 188 | 257 | ||||
| 189 | - | shortID := generateShortID(imageID) | 258 | + | return fmt.Errorf("file does not appear to be a valid Docker image archive") |
| 190 | - | storeIDMapping(shortID, imageID) | 259 | + | } |
| 191 | - | createContainerURL := generateCreateContainerURL(a, shortID) | |||
| 192 | 260 | ||||
| 193 | - | return sendJSONResponse(c, http.StatusOK, PushResponse{ | 261 | + | func sendErrorResponse(c echo.Context, message string, err error) error { |
| 194 | - | Success: true, | 262 | + | log.Error(message, "error", err) |
| 195 | - | Message: "Image pushed and imported successfully", | 263 | + | return sendJSONResponse(c, http.StatusInternalServerError, PushResponse{ |
| 196 | - | CreateContainerURL: createContainerURL, | 264 | + | Success: false, |
| 197 | - | ImageID: imageID, | 265 | + | Message: message, |
| 198 | }) | 266 | }) | ||
| 199 | } | 267 | } | ||
M internal/httpserve/handlers/login.go +1 -1
| @@ -228,7 +228,7 @@ func handleUser(c echo.Context, a *server.App, accessToken, browserInfo string, | |||
| 228 | 228 | } | |
| 229 | 229 | ||
| 230 | 230 | if !isGoodUser { | |
| 231 | - | return nil, echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized") | |
| 231 | + | return nil, echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized user") | |
| 232 | 232 | } | |
| 233 | 233 | ||
| 234 | 234 | if isGoodUser { | |
| @@ -228,7 +228,7 @@ func handleUser(c echo.Context, a *server.App, accessToken, browserInfo string, | |||||
| 228 | } | 228 | } | ||
| 229 | 229 | ||||
| 230 | if !isGoodUser { | 230 | if !isGoodUser { | ||
| 231 | - | return nil, echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized") | 231 | + | return nil, echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized user") |
| 232 | } | 232 | } | ||
| 233 | 233 | ||||
| 234 | if isGoodUser { | 234 | if isGoodUser { | ||
M internal/httpserve/middleware/error.go +1 -1
| @@ -9,7 +9,7 @@ func ErrorHandler(next echo.HandlerFunc) echo.HandlerFunc { | |||
| 9 | 9 | return func(c echo.Context) error { | |
| 10 | 10 | err := next(c) | |
| 11 | 11 | if err != nil { | |
| 12 | - | log.Error("ErrorHandler caught error", | |
| 12 | + | log.Error("Caught http error", | |
| 13 | 13 | "path", c.Request().URL.Path, | |
| 14 | 14 | "method", c.Request().Method, | |
| 15 | 15 | "error", err, | |
| @@ -9,7 +9,7 @@ func ErrorHandler(next echo.HandlerFunc) echo.HandlerFunc { | |||||
| 9 | return func(c echo.Context) error { | 9 | return func(c echo.Context) error { | ||
| 10 | err := next(c) | 10 | err := next(c) | ||
| 11 | if err != nil { | 11 | if err != nil { | ||
| 12 | - | log.Error("ErrorHandler caught error", | 12 | + | log.Error("Caught http error", |
| 13 | "path", c.Request().URL.Path, | 13 | "path", c.Request().URL.Path, | ||
| 14 | "method", c.Request().Method, | 14 | "method", c.Request().Method, | ||
| 15 | "error", err, | 15 | "error", err, | ||
M internal/httpserve/middleware/requiretoken.go +31 -5
| @@ -26,22 +26,48 @@ func RequireToken(a *server.App) echo.MiddlewareFunc { | |||
| 26 | 26 | return func(c echo.Context) error { | |
| 27 | 27 | token := strings.TrimPrefix(c.Request().Header.Get("Authorization"), "Bearer ") | |
| 28 | 28 | if token == "" { | |
| 29 | - | return c.JSON(http.StatusUnauthorized, map[string]string{ | |
| 30 | - | "error": "Token is missing", | |
| 29 | + | return c.JSON(http.StatusUnauthorized, map[string]interface{}{ | |
| 30 | + | "success": false, | |
| 31 | + | "error": "Token is missing", | |
| 31 | 32 | }) | |
| 32 | 33 | } | |
| 33 | 34 | ||
| 34 | 35 | // Check cache first | |
| 35 | 36 | if _, cachedUser, found := tokenCache.GetWithUser(token); found { | |
| 37 | + | // Verify if user exists in database and is authorized | |
| 38 | + | isAuthorized, err := queries.CheckDBUserIsGood(a, cachedUser) | |
| 39 | + | if err != nil || !isAuthorized { | |
| 40 | + | return c.JSON(http.StatusUnauthorized, map[string]interface{}{ | |
| 41 | + | "success": false, | |
| 42 | + | "error": "User not authorized", | |
| 43 | + | }) | |
| 44 | + | } | |
| 36 | 45 | c.Set("user", cachedUser) | |
| 37 | 46 | return next(c) | |
| 38 | 47 | } | |
| 39 | 48 | ||
| 40 | - | // Validate with GitHub only for new tokens | |
| 49 | + | // Validate with GitHub for new tokens | |
| 41 | 50 | githubUser, err := validateGitHubToken(c) | |
| 42 | 51 | if err != nil { | |
| 43 | - | return c.JSON(http.StatusUnauthorized, map[string]string{ | |
| 44 | - | "error": "Invalid token", | |
| 52 | + | return c.JSON(http.StatusUnauthorized, map[string]interface{}{ | |
| 53 | + | "success": false, | |
| 54 | + | "error": "Invalid token", | |
| 55 | + | }) | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | // Verify if user exists in database and is authorized | |
| 59 | + | isAuthorized, err := queries.CheckDBUserIsGood(a, githubUser) | |
| 60 | + | if err != nil { | |
| 61 | + | return c.JSON(http.StatusInternalServerError, map[string]interface{}{ | |
| 62 | + | "success": false, | |
| 63 | + | "error": "Internal server error", | |
| 64 | + | }) | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | if !isAuthorized { | |
| 68 | + | return c.JSON(http.StatusUnauthorized, map[string]interface{}{ | |
| 69 | + | "success": false, | |
| 70 | + | "error": "User not authorized. Please login through the web interface first.", | |
| 45 | 71 | }) | |
| 46 | 72 | } | |
| 47 | 73 | ||
| @@ -26,22 +26,48 @@ func RequireToken(a *server.App) echo.MiddlewareFunc { | |||||
| 26 | return func(c echo.Context) error { | 26 | return func(c echo.Context) error { | ||
| 27 | token := strings.TrimPrefix(c.Request().Header.Get("Authorization"), "Bearer ") | 27 | token := strings.TrimPrefix(c.Request().Header.Get("Authorization"), "Bearer ") | ||
| 28 | if token == "" { | 28 | if token == "" { | ||
| 29 | - | return c.JSON(http.StatusUnauthorized, map[string]string{ | 29 | + | return c.JSON(http.StatusUnauthorized, map[string]interface{}{ |
| 30 | - | "error": "Token is missing", | 30 | + | "success": false, |
| 31 | + | "error": "Token is missing", | |||
| 31 | }) | 32 | }) | ||
| 32 | } | 33 | } | ||
| 33 | 34 | ||||
| 34 | // Check cache first | 35 | // Check cache first | ||
| 35 | if _, cachedUser, found := tokenCache.GetWithUser(token); found { | 36 | if _, cachedUser, found := tokenCache.GetWithUser(token); found { | ||
| 37 | + | // Verify if user exists in database and is authorized | |||
| 38 | + | isAuthorized, err := queries.CheckDBUserIsGood(a, cachedUser) | |||
| 39 | + | if err != nil || !isAuthorized { | |||
| 40 | + | return c.JSON(http.StatusUnauthorized, map[string]interface{}{ | |||
| 41 | + | "success": false, | |||
| 42 | + | "error": "User not authorized", | |||
| 43 | + | }) | |||
| 44 | + | } | |||
| 36 | c.Set("user", cachedUser) | 45 | c.Set("user", cachedUser) | ||
| 37 | return next(c) | 46 | return next(c) | ||
| 38 | } | 47 | } | ||
| 39 | 48 | ||||
| 40 | - | // Validate with GitHub only for new tokens | 49 | + | // Validate with GitHub for new tokens |
| 41 | githubUser, err := validateGitHubToken(c) | 50 | githubUser, err := validateGitHubToken(c) | ||
| 42 | if err != nil { | 51 | if err != nil { | ||
| 43 | - | return c.JSON(http.StatusUnauthorized, map[string]string{ | 52 | + | return c.JSON(http.StatusUnauthorized, map[string]interface{}{ |
| 44 | - | "error": "Invalid token", | 53 | + | "success": false, |
| 54 | + | "error": "Invalid token", | |||
| 55 | + | }) | |||
| 56 | + | } | |||
| 57 | + | ||||
| 58 | + | // Verify if user exists in database and is authorized | |||
| 59 | + | isAuthorized, err := queries.CheckDBUserIsGood(a, githubUser) | |||
| 60 | + | if err != nil { | |||
| 61 | + | return c.JSON(http.StatusInternalServerError, map[string]interface{}{ | |||
| 62 | + | "success": false, | |||
| 63 | + | "error": "Internal server error", | |||
| 64 | + | }) | |||
| 65 | + | } | |||
| 66 | + | ||||
| 67 | + | if !isAuthorized { | |||
| 68 | + | return c.JSON(http.StatusUnauthorized, map[string]interface{}{ | |||
| 69 | + | "success": false, | |||
| 70 | + | "error": "User not authorized. Please login through the web interface first.", | |||
| 45 | }) | 71 | }) | ||
| 46 | } | 72 | } | ||
| 47 | 73 | ||||
M internal/httpserve/router.go +12 -0
| @@ -67,9 +67,21 @@ func bindAPIEndpoints(e *echo.Echo, a *server.App) { | |||
| 67 | 67 | protectedApiGroup.POST("/deploy", func(c echo.Context) error { | |
| 68 | 68 | return handlers.PostDeploy(c, a) | |
| 69 | 69 | }) | |
| 70 | + | protectedApiGroup.POST("/deploy/chunked", func(c echo.Context) error { | |
| 71 | + | return handlers.PostDeployChunked(c, a) | |
| 72 | + | }) | |
| 73 | + | protectedApiGroup.GET("/deploy/check-conflict", func(c echo.Context) error { | |
| 74 | + | return handlers.CheckDeployConflict(c, a) | |
| 75 | + | }) | |
| 76 | + | // Regular push endpoint for small images | |
| 70 | 77 | protectedApiGroup.POST("/push", func(c echo.Context) error { | |
| 71 | 78 | return handlers.PostPush(c, a) | |
| 72 | 79 | }) | |
| 80 | + | ||
| 81 | + | // Chunked push endpoint for large images | |
| 82 | + | protectedApiGroup.POST("/push/chunked", func(c echo.Context) error { | |
| 83 | + | return handlers.PostPushChunked(c, a) | |
| 84 | + | }) | |
| 73 | 85 | protectedApiGroup.POST("/stop", func(c echo.Context) error { | |
| 74 | 86 | return handlers.PostContainerStop(c, a) | |
| 75 | 87 | }) | |
| @@ -67,9 +67,21 @@ func bindAPIEndpoints(e *echo.Echo, a *server.App) { | |||||
| 67 | protectedApiGroup.POST("/deploy", func(c echo.Context) error { | 67 | protectedApiGroup.POST("/deploy", func(c echo.Context) error { | ||
| 68 | return handlers.PostDeploy(c, a) | 68 | return handlers.PostDeploy(c, a) | ||
| 69 | }) | 69 | }) | ||
| 70 | + | protectedApiGroup.POST("/deploy/chunked", func(c echo.Context) error { | |||
| 71 | + | return handlers.PostDeployChunked(c, a) | |||
| 72 | + | }) | |||
| 73 | + | protectedApiGroup.GET("/deploy/check-conflict", func(c echo.Context) error { | |||
| 74 | + | return handlers.CheckDeployConflict(c, a) | |||
| 75 | + | }) | |||
| 76 | + | // Regular push endpoint for small images | |||
| 70 | protectedApiGroup.POST("/push", func(c echo.Context) error { | 77 | protectedApiGroup.POST("/push", func(c echo.Context) error { | ||
| 71 | return handlers.PostPush(c, a) | 78 | return handlers.PostPush(c, a) | ||
| 72 | }) | 79 | }) | ||
| 80 | + | ||||
| 81 | + | // Chunked push endpoint for large images | |||
| 82 | + | protectedApiGroup.POST("/push/chunked", func(c echo.Context) error { | |||
| 83 | + | return handlers.PostPushChunked(c, a) | |||
| 84 | + | }) | |||
| 73 | protectedApiGroup.POST("/stop", func(c echo.Context) error { | 85 | protectedApiGroup.POST("/stop", func(c echo.Context) error { | ||
| 74 | return handlers.PostContainerStop(c, a) | 86 | return handlers.PostContainerStop(c, a) | ||
| 75 | }) | 87 | }) | ||
M pkg/docker/containers.go +243 -29
| @@ -8,9 +8,11 @@ import ( | |||
| 8 | 8 | "strings" | |
| 9 | 9 | "time" | |
| 10 | 10 | ||
| 11 | + | "github.com/charmbracelet/log" | |
| 11 | 12 | "github.com/docker/docker/api/types" | |
| 12 | 13 | "github.com/docker/docker/api/types/container" | |
| 13 | 14 | "github.com/docker/docker/api/types/network" | |
| 15 | + | "github.com/docker/docker/client" | |
| 14 | 16 | "github.com/docker/go-connections/nat" | |
| 15 | 17 | ) | |
| 16 | 18 | ||
| @@ -179,7 +181,12 @@ func RemoveContainer(containerID string) error { | |||
| 179 | 181 | // StartContainer starts a container | |
| 180 | 182 | func StartContainer(containerID string) error { | |
| 181 | 183 | fmt.Println("Starting container", containerID) | |
| 182 | - | // Check if the container is not already in a running state | |
| 184 | + | ||
| 185 | + | // Add timeout context | |
| 186 | + | ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) | |
| 187 | + | defer cancel() | |
| 188 | + | ||
| 189 | + | // Get initial container state | |
| 183 | 190 | containerInfo, err := GetContainerInfo(containerID) | |
| 184 | 191 | if err != nil { | |
| 185 | 192 | return fmt.Errorf("could not get container info: %v", err) | |
| @@ -189,45 +196,61 @@ func StartContainer(containerID string) error { | |||
| 189 | 196 | return fmt.Errorf("container is already running") | |
| 190 | 197 | } | |
| 191 | 198 | ||
| 192 | - | // Start container using the Docker client | |
| 193 | - | err = dockerCli.ContainerStart(context.Background(), containerID, container.StartOptions{}) | |
| 199 | + | // Start container | |
| 200 | + | err = dockerCli.ContainerStart(ctx, containerID, container.StartOptions{}) | |
| 194 | 201 | if err != nil { | |
| 202 | + | // Get container logs if start failed | |
| 203 | + | logs, logErr := GetContainerLogs(containerID) | |
| 204 | + | if logErr == nil { | |
| 205 | + | fmt.Printf("Container logs after failed start: %s\n", logs) | |
| 206 | + | } | |
| 195 | 207 | return fmt.Errorf("could not start container: %v", err) | |
| 196 | 208 | } | |
| 197 | 209 | ||
| 198 | - | return nil | |
| 210 | + | // Verify container is running | |
| 211 | + | for i := 0; i < 5; i++ { | |
| 212 | + | state, err := GetContainerState(containerID) | |
| 213 | + | if err != nil { | |
| 214 | + | fmt.Printf("Error checking container state: %v\n", err) | |
| 215 | + | continue | |
| 216 | + | } | |
| 217 | + | ||
| 218 | + | if state == "running" { | |
| 219 | + | return nil | |
| 220 | + | } | |
| 221 | + | ||
| 222 | + | time.Sleep(time.Second) | |
| 223 | + | } | |
| 224 | + | ||
| 225 | + | // If we get here, container didn't start properly | |
| 226 | + | logs, _ := GetContainerLogs(containerID) | |
| 227 | + | return fmt.Errorf("container failed to enter running state. Logs: %s", logs) | |
| 199 | 228 | } | |
| 200 | 229 | ||
| 201 | 230 | // CreateContainer creates a container with the given parameters | |
| 202 | 231 | func CreateContainer(cmdParams ContainerCommandParams) (string, error) { | |
| 203 | - | // Check if the Docker client has been initialized | |
| 204 | 232 | CheckIfInitialized() | |
| 205 | 233 | ||
| 234 | + | log.Info("Creating container with params: %+v\n", cmdParams) | |
| 235 | + | ||
| 236 | + | // Check network | |
| 206 | 237 | isNetworkCreated, err := CheckIfNetworkExists(cmdParams.Network) | |
| 207 | 238 | if err != nil { | |
| 208 | - | return "", err | |
| 239 | + | return "", fmt.Errorf("network check failed: %v", err) | |
| 209 | 240 | } | |
| 210 | 241 | ||
| 211 | 242 | if !isNetworkCreated { | |
| 243 | + | fmt.Printf("Creating network: %s\n", cmdParams.Network) | |
| 212 | 244 | _, err := dockerCli.NetworkCreate(context.Background(), cmdParams.Network, types.NetworkCreate{}) | |
| 213 | 245 | if err != nil { | |
| 214 | - | return "", err | |
| 246 | + | return "", fmt.Errorf("network creation failed: %v", err) | |
| 215 | 247 | } | |
| 216 | 248 | } | |
| 217 | 249 | ||
| 218 | - | // if the network exist return its id | |
| 219 | - | // networkInfo, err := GetNetworkInfo(cmdParams.Network) | |
| 220 | - | // if err != nil { | |
| 221 | - | // return "", err | |
| 222 | - | ||
| 223 | - | // } | |
| 224 | - | ||
| 225 | - | // fmt.Print(networkInfo.ID) | |
| 226 | - | ||
| 227 | - | // Prepare port bindings | |
| 250 | + | // Prepare port bindings with logging | |
| 228 | 251 | portBindings := nat.PortMap{} | |
| 252 | + | exposedPorts := nat.PortSet{} | |
| 229 | 253 | for _, portMapping := range cmdParams.PortMappings { | |
| 230 | - | // Prepare exposed port | |
| 231 | 254 | exposedPort := nat.Port(portMapping.ContainerPort + "/" + portMapping.Protocol) | |
| 232 | 255 | portBindings[exposedPort] = []nat.PortBinding{ | |
| 233 | 256 | { | |
| @@ -235,9 +258,14 @@ func CreateContainer(cmdParams ContainerCommandParams) (string, error) { | |||
| 235 | 258 | HostPort: portMapping.HostPort, | |
| 236 | 259 | }, | |
| 237 | 260 | } | |
| 261 | + | exposedPorts[exposedPort] = struct{}{} | |
| 262 | + | fmt.Printf("Adding port mapping: %s:%s/%s\n", | |
| 263 | + | portMapping.HostPort, | |
| 264 | + | portMapping.ContainerPort, | |
| 265 | + | portMapping.Protocol) | |
| 238 | 266 | } | |
| 239 | 267 | ||
| 240 | - | // Prepare labels for Traefik | |
| 268 | + | // Prepare labels | |
| 241 | 269 | labels := map[string]string{} | |
| 242 | 270 | for _, label := range cmdParams.Labels { | |
| 243 | 271 | keyValue := strings.Split(label, "=") | |
| @@ -246,16 +274,14 @@ func CreateContainer(cmdParams ContainerCommandParams) (string, error) { | |||
| 246 | 274 | } | |
| 247 | 275 | } | |
| 248 | 276 | ||
| 249 | - | // Prepare environment variables | |
| 250 | - | envVars := append([]string{}, cmdParams.Environment...) | |
| 251 | - | ||
| 252 | - | // Create container | |
| 277 | + | // Create container with platform specification | |
| 253 | 278 | resp, err := dockerCli.ContainerCreate( | |
| 254 | 279 | context.Background(), | |
| 255 | 280 | &container.Config{ | |
| 256 | - | Image: cmdParams.ImageName, | |
| 257 | - | Labels: labels, | |
| 258 | - | Env: envVars, | |
| 281 | + | Image: cmdParams.ImageName, | |
| 282 | + | Labels: labels, | |
| 283 | + | Env: cmdParams.Environment, | |
| 284 | + | ExposedPorts: exposedPorts, | |
| 259 | 285 | }, | |
| 260 | 286 | &container.HostConfig{ | |
| 261 | 287 | PortBindings: portBindings, | |
| @@ -273,12 +299,40 @@ func CreateContainer(cmdParams ContainerCommandParams) (string, error) { | |||
| 273 | 299 | cmdParams.ContainerName, | |
| 274 | 300 | ) | |
| 275 | 301 | if err != nil { | |
| 276 | - | return "", err | |
| 302 | + | return "", fmt.Errorf("container creation failed: %v", err) | |
| 277 | 303 | } | |
| 278 | 304 | ||
| 305 | + | log.Info("Container created successfully with ID: %s\n", resp.ID) | |
| 306 | + | ||
| 279 | 307 | return resp.ID, nil | |
| 280 | 308 | } | |
| 281 | 309 | ||
| 310 | + | func WaitForContainerToBeRunning(containerID string, timeout time.Duration) error { | |
| 311 | + | ctx, cancel := context.WithTimeout(context.Background(), timeout) | |
| 312 | + | defer cancel() | |
| 313 | + | ||
| 314 | + | for { | |
| 315 | + | select { | |
| 316 | + | case <-ctx.Done(): | |
| 317 | + | return fmt.Errorf("timeout waiting for container to start") | |
| 318 | + | case <-time.After(time.Second): | |
| 319 | + | state, err := GetContainerState(containerID) | |
| 320 | + | if err != nil { | |
| 321 | + | return fmt.Errorf("error checking container state: %v", err) | |
| 322 | + | } | |
| 323 | + | ||
| 324 | + | if state == "running" { | |
| 325 | + | return nil | |
| 326 | + | } | |
| 327 | + | ||
| 328 | + | if state == "exited" { | |
| 329 | + | logs, _ := GetContainerLogs(containerID) | |
| 330 | + | return fmt.Errorf("container exited unexpectedly. Logs: %s", logs) | |
| 331 | + | } | |
| 332 | + | } | |
| 333 | + | } | |
| 334 | + | } | |
| 335 | + | ||
| 282 | 336 | func CheckIfNetworkExists(networkName string) (bool, error) { | |
| 283 | 337 | networks, err := dockerCli.NetworkList(context.Background(), types.NetworkListOptions{}) | |
| 284 | 338 | if err != nil { | |
| @@ -323,8 +377,6 @@ func GetContainerName(containerID string) (string, error) { | |||
| 323 | 377 | return "", err | |
| 324 | 378 | } | |
| 325 | 379 | ||
| 326 | - | // Debug | |
| 327 | - | fmt.Printf("Container name: %s\n", containerInfo.Name) | |
| 328 | 380 | return containerInfo.Name, nil | |
| 329 | 381 | } | |
| 330 | 382 | ||
| @@ -415,3 +467,165 @@ func GetContainerIDByName(containerName string) string { | |||
| 415 | 467 | ||
| 416 | 468 | return "" | |
| 417 | 469 | } | |
| 470 | + | ||
| 471 | + | func ContainerExists(containerID string) bool { | |
| 472 | + | _, err := dockerCli.ContainerInspect(context.Background(), containerID) | |
| 473 | + | return err == nil | |
| 474 | + | } | |
| 475 | + | ||
| 476 | + | func GetContainerState(containerID string) (string, error) { | |
| 477 | + | info, err := dockerCli.ContainerInspect(context.Background(), containerID) | |
| 478 | + | if err != nil { | |
| 479 | + | return "", err | |
| 480 | + | } | |
| 481 | + | return info.State.Status, nil | |
| 482 | + | } | |
| 483 | + | ||
| 484 | + | // StartContainerWithContext starts a container with context | |
| 485 | + | func StartContainerWithContext(ctx context.Context, containerID string) error { | |
| 486 | + | fmt.Println("Starting container", containerID) | |
| 487 | + | ||
| 488 | + | // Check if the container is not already in a running state | |
| 489 | + | containerInfo, err := GetContainerInfo(containerID) | |
| 490 | + | if err != nil { | |
| 491 | + | return fmt.Errorf("could not get container info: %v", err) | |
| 492 | + | } | |
| 493 | + | ||
| 494 | + | if containerInfo.State.Running { | |
| 495 | + | return fmt.Errorf("container is already running") | |
| 496 | + | } | |
| 497 | + | ||
| 498 | + | // Start container using the Docker client with context | |
| 499 | + | err = dockerCli.ContainerStart(ctx, containerID, container.StartOptions{}) | |
| 500 | + | if err != nil { | |
| 501 | + | return fmt.Errorf("could not start container: %v", err) | |
| 502 | + | } | |
| 503 | + | ||
| 504 | + | return nil | |
| 505 | + | } | |
| 506 | + | ||
| 507 | + | func GetContainerPorts(containerID string) (string, error) { | |
| 508 | + | cli, err := client.NewClientWithOpts(client.FromEnv) | |
| 509 | + | if err != nil { | |
| 510 | + | return "", err | |
| 511 | + | } | |
| 512 | + | defer cli.Close() | |
| 513 | + | ||
| 514 | + | container, err := cli.ContainerInspect(context.Background(), containerID) | |
| 515 | + | if err != nil { | |
| 516 | + | return "", err | |
| 517 | + | } | |
| 518 | + | ||
| 519 | + | var ports []string | |
| 520 | + | for port, bindings := range container.NetworkSettings.Ports { | |
| 521 | + | for _, binding := range bindings { | |
| 522 | + | ports = append(ports, fmt.Sprintf("%s:%s->%s", binding.HostIP, binding.HostPort, port)) | |
| 523 | + | } | |
| 524 | + | } | |
| 525 | + | ||
| 526 | + | return strings.Join(ports, ", "), nil | |
| 527 | + | } | |
| 528 | + | ||
| 529 | + | func GetContainerUptime(containerID string) (string, error) { | |
| 530 | + | cli, err := client.NewClientWithOpts(client.FromEnv) | |
| 531 | + | if err != nil { | |
| 532 | + | return "", err | |
| 533 | + | } | |
| 534 | + | defer cli.Close() | |
| 535 | + | ||
| 536 | + | container, err := cli.ContainerInspect(context.Background(), containerID) | |
| 537 | + | if err != nil { | |
| 538 | + | return "", err | |
| 539 | + | } | |
| 540 | + | ||
| 541 | + | if !container.State.Running { | |
| 542 | + | return "not running", nil | |
| 543 | + | } | |
| 544 | + | ||
| 545 | + | startTime, err := time.Parse(time.RFC3339, container.State.StartedAt) | |
| 546 | + | if err != nil { | |
| 547 | + | return "", err | |
| 548 | + | } | |
| 549 | + | ||
| 550 | + | duration := time.Since(startTime) | |
| 551 | + | return formatDuration(duration), nil | |
| 552 | + | } | |
| 553 | + | ||
| 554 | + | func GetContainersUsingPort(port string) ([]string, error) { | |
| 555 | + | cli, err := client.NewClientWithOpts(client.FromEnv) | |
| 556 | + | if err != nil { | |
| 557 | + | return nil, fmt.Errorf("failed to create Docker client: %w", err) | |
| 558 | + | } | |
| 559 | + | defer cli.Close() | |
| 560 | + | ||
| 561 | + | ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) | |
| 562 | + | defer cancel() | |
| 563 | + | ||
| 564 | + | // List all containers (including running and stopped) | |
| 565 | + | containers, err := cli.ContainerList(ctx, container.ListOptions{All: true}) | |
| 566 | + | var containersUsingPort []string | |
| 567 | + | ||
| 568 | + | for _, container := range containers { | |
| 569 | + | // Inspect container to get detailed port information | |
| 570 | + | inspect, err := cli.ContainerInspect(ctx, container.ID) | |
| 571 | + | if err != nil { | |
| 572 | + | continue // Skip containers we can't inspect | |
| 573 | + | } | |
| 574 | + | ||
| 575 | + | // Check exposed ports in container config | |
| 576 | + | if inspect.Config != nil && inspect.Config.ExposedPorts != nil { | |
| 577 | + | for exposedPort := range inspect.Config.ExposedPorts { | |
| 578 | + | if strings.HasPrefix(string(exposedPort), port+"/") { | |
| 579 | + | containersUsingPort = append(containersUsingPort, strings.TrimPrefix(container.Names[0], "/")) | |
| 580 | + | break | |
| 581 | + | } | |
| 582 | + | } | |
| 583 | + | } | |
| 584 | + | ||
| 585 | + | // Check port bindings in host config | |
| 586 | + | if inspect.HostConfig != nil && inspect.HostConfig.PortBindings != nil { | |
| 587 | + | for _, bindings := range inspect.HostConfig.PortBindings { | |
| 588 | + | for _, binding := range bindings { | |
| 589 | + | if binding.HostPort == port { | |
| 590 | + | containersUsingPort = append(containersUsingPort, strings.TrimPrefix(container.Names[0], "/")) | |
| 591 | + | break | |
| 592 | + | } | |
| 593 | + | } | |
| 594 | + | } | |
| 595 | + | } | |
| 596 | + | ||
| 597 | + | // Check currently published ports | |
| 598 | + | for _, portBinding := range container.Ports { | |
| 599 | + | if fmt.Sprintf("%d", portBinding.PublicPort) == port { | |
| 600 | + | containersUsingPort = append(containersUsingPort, strings.TrimPrefix(container.Names[0], "/")) | |
| 601 | + | break | |
| 602 | + | } | |
| 603 | + | } | |
| 604 | + | } | |
| 605 | + | ||
| 606 | + | // Remove duplicates | |
| 607 | + | seen := make(map[string]bool) | |
| 608 | + | unique := make([]string, 0, len(containersUsingPort)) | |
| 609 | + | for _, name := range containersUsingPort { | |
| 610 | + | if !seen[name] { | |
| 611 | + | seen[name] = true | |
| 612 | + | unique = append(unique, name) | |
| 613 | + | } | |
| 614 | + | } | |
| 615 | + | ||
| 616 | + | return unique, nil | |
| 617 | + | } | |
| 618 | + | ||
| 619 | + | func formatDuration(d time.Duration) string { | |
| 620 | + | days := int(d.Hours() / 24) | |
| 621 | + | hours := int(d.Hours()) % 24 | |
| 622 | + | minutes := int(d.Minutes()) % 60 | |
| 623 | + | ||
| 624 | + | if days > 0 { | |
| 625 | + | return fmt.Sprintf("%dd %dh %dm", days, hours, minutes) | |
| 626 | + | } | |
| 627 | + | if hours > 0 { | |
| 628 | + | return fmt.Sprintf("%dh %dm", hours, minutes) | |
| 629 | + | } | |
| 630 | + | return fmt.Sprintf("%dm", minutes) | |
| 631 | + | } | |
| @@ -8,9 +8,11 @@ import ( | |||||
| 8 | "strings" | 8 | "strings" | ||
| 9 | "time" | 9 | "time" | ||
| 10 | 10 | ||||
| 11 | + | "github.com/charmbracelet/log" | |||
| 11 | "github.com/docker/docker/api/types" | 12 | "github.com/docker/docker/api/types" | ||
| 12 | "github.com/docker/docker/api/types/container" | 13 | "github.com/docker/docker/api/types/container" | ||
| 13 | "github.com/docker/docker/api/types/network" | 14 | "github.com/docker/docker/api/types/network" | ||
| 15 | + | "github.com/docker/docker/client" | |||
| 14 | "github.com/docker/go-connections/nat" | 16 | "github.com/docker/go-connections/nat" | ||
| 15 | ) | 17 | ) | ||
| 16 | 18 | ||||
| @@ -179,7 +181,12 @@ func RemoveContainer(containerID string) error { | |||||
| 179 | // StartContainer starts a container | 181 | // StartContainer starts a container | ||
| 180 | func StartContainer(containerID string) error { | 182 | func StartContainer(containerID string) error { | ||
| 181 | fmt.Println("Starting container", containerID) | 183 | fmt.Println("Starting container", containerID) | ||
| 182 | - | // Check if the container is not already in a running state | 184 | + | |
| 185 | + | // Add timeout context | |||
| 186 | + | ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) | |||
| 187 | + | defer cancel() | |||
| 188 | + | ||||
| 189 | + | // Get initial container state | |||
| 183 | containerInfo, err := GetContainerInfo(containerID) | 190 | containerInfo, err := GetContainerInfo(containerID) | ||
| 184 | if err != nil { | 191 | if err != nil { | ||
| 185 | return fmt.Errorf("could not get container info: %v", err) | 192 | return fmt.Errorf("could not get container info: %v", err) | ||
| @@ -189,45 +196,61 @@ func StartContainer(containerID string) error { | |||||
| 189 | return fmt.Errorf("container is already running") | 196 | return fmt.Errorf("container is already running") | ||
| 190 | } | 197 | } | ||
| 191 | 198 | ||||
| 192 | - | // Start container using the Docker client | 199 | + | // Start container |
| 193 | - | err = dockerCli.ContainerStart(context.Background(), containerID, container.StartOptions{}) | 200 | + | err = dockerCli.ContainerStart(ctx, containerID, container.StartOptions{}) |
| 194 | if err != nil { | 201 | if err != nil { | ||
| 202 | + | // Get container logs if start failed | |||
| 203 | + | logs, logErr := GetContainerLogs(containerID) | |||
| 204 | + | if logErr == nil { | |||
| 205 | + | fmt.Printf("Container logs after failed start: %s\n", logs) | |||
| 206 | + | } | |||
| 195 | return fmt.Errorf("could not start container: %v", err) | 207 | return fmt.Errorf("could not start container: %v", err) | ||
| 196 | } | 208 | } | ||
| 197 | 209 | ||||
| 198 | - | return nil | 210 | + | // Verify container is running |
| 211 | + | for i := 0; i < 5; i++ { | |||
| 212 | + | state, err := GetContainerState(containerID) | |||
| 213 | + | if err != nil { | |||
| 214 | + | fmt.Printf("Error checking container state: %v\n", err) | |||
| 215 | + | continue | |||
| 216 | + | } | |||
| 217 | + | ||||
| 218 | + | if state == "running" { | |||
| 219 | + | return nil | |||
| 220 | + | } | |||
| 221 | + | ||||
| 222 | + | time.Sleep(time.Second) | |||
| 223 | + | } | |||
| 224 | + | ||||
| 225 | + | // If we get here, container didn't start properly | |||
| 226 | + | logs, _ := GetContainerLogs(containerID) | |||
| 227 | + | return fmt.Errorf("container failed to enter running state. Logs: %s", logs) | |||
| 199 | } | 228 | } | ||
| 200 | 229 | ||||
| 201 | // CreateContainer creates a container with the given parameters | 230 | // CreateContainer creates a container with the given parameters | ||
| 202 | func CreateContainer(cmdParams ContainerCommandParams) (string, error) { | 231 | func CreateContainer(cmdParams ContainerCommandParams) (string, error) { | ||
| 203 | - | // Check if the Docker client has been initialized | |||
| 204 | CheckIfInitialized() | 232 | CheckIfInitialized() | ||
| 205 | 233 | ||||
| 234 | + | log.Info("Creating container with params: %+v\n", cmdParams) | |||
| 235 | + | ||||
| 236 | + | // Check network | |||
| 206 | isNetworkCreated, err := CheckIfNetworkExists(cmdParams.Network) | 237 | isNetworkCreated, err := CheckIfNetworkExists(cmdParams.Network) | ||
| 207 | if err != nil { | 238 | if err != nil { | ||
| 208 | - | return "", err | 239 | + | return "", fmt.Errorf("network check failed: %v", err) |
| 209 | } | 240 | } | ||
| 210 | 241 | ||||
| 211 | if !isNetworkCreated { | 242 | if !isNetworkCreated { | ||
| 243 | + | fmt.Printf("Creating network: %s\n", cmdParams.Network) | |||
| 212 | _, err := dockerCli.NetworkCreate(context.Background(), cmdParams.Network, types.NetworkCreate{}) | 244 | _, err := dockerCli.NetworkCreate(context.Background(), cmdParams.Network, types.NetworkCreate{}) | ||
| 213 | if err != nil { | 245 | if err != nil { | ||
| 214 | - | return "", err | 246 | + | return "", fmt.Errorf("network creation failed: %v", err) |
| 215 | } | 247 | } | ||
| 216 | } | 248 | } | ||
| 217 | 249 | ||||
| 218 | - | // if the network exist return its id | 250 | + | // Prepare port bindings with logging |
| 219 | - | // networkInfo, err := GetNetworkInfo(cmdParams.Network) | |||
| 220 | - | // if err != nil { | |||
| 221 | - | // return "", err | |||
| 222 | - | ||||
| 223 | - | // } | |||
| 224 | - | ||||
| 225 | - | // fmt.Print(networkInfo.ID) | |||
| 226 | - | ||||
| 227 | - | // Prepare port bindings | |||
| 228 | portBindings := nat.PortMap{} | 251 | portBindings := nat.PortMap{} | ||
| 252 | + | exposedPorts := nat.PortSet{} | |||
| 229 | for _, portMapping := range cmdParams.PortMappings { | 253 | for _, portMapping := range cmdParams.PortMappings { | ||
| 230 | - | // Prepare exposed port | |||
| 231 | exposedPort := nat.Port(portMapping.ContainerPort + "/" + portMapping.Protocol) | 254 | exposedPort := nat.Port(portMapping.ContainerPort + "/" + portMapping.Protocol) | ||
| 232 | portBindings[exposedPort] = []nat.PortBinding{ | 255 | portBindings[exposedPort] = []nat.PortBinding{ | ||
| 233 | { | 256 | { | ||
| @@ -235,9 +258,14 @@ func CreateContainer(cmdParams ContainerCommandParams) (string, error) { | |||||
| 235 | HostPort: portMapping.HostPort, | 258 | HostPort: portMapping.HostPort, | ||
| 236 | }, | 259 | }, | ||
| 237 | } | 260 | } | ||
| 261 | + | exposedPorts[exposedPort] = struct{}{} | |||
| 262 | + | fmt.Printf("Adding port mapping: %s:%s/%s\n", | |||
| 263 | + | portMapping.HostPort, | |||
| 264 | + | portMapping.ContainerPort, | |||
| 265 | + | portMapping.Protocol) | |||
| 238 | } | 266 | } | ||
| 239 | 267 | ||||
| 240 | - | // Prepare labels for Traefik | 268 | + | // Prepare labels |
| 241 | labels := map[string]string{} | 269 | labels := map[string]string{} | ||
| 242 | for _, label := range cmdParams.Labels { | 270 | for _, label := range cmdParams.Labels { | ||
| 243 | keyValue := strings.Split(label, "=") | 271 | keyValue := strings.Split(label, "=") | ||
| @@ -246,16 +274,14 @@ func CreateContainer(cmdParams ContainerCommandParams) (string, error) { | |||||
| 246 | } | 274 | } | ||
| 247 | } | 275 | } | ||
| 248 | 276 | ||||
| 249 | - | // Prepare environment variables | 277 | + | // Create container with platform specification |
| 250 | - | envVars := append([]string{}, cmdParams.Environment...) | |||
| 251 | - | ||||
| 252 | - | // Create container | |||
| 253 | resp, err := dockerCli.ContainerCreate( | 278 | resp, err := dockerCli.ContainerCreate( | ||
| 254 | context.Background(), | 279 | context.Background(), | ||
| 255 | &container.Config{ | 280 | &container.Config{ | ||
| 256 | - | Image: cmdParams.ImageName, | 281 | + | Image: cmdParams.ImageName, |
| 257 | - | Labels: labels, | 282 | + | Labels: labels, |
| 258 | - | Env: envVars, | 283 | + | Env: cmdParams.Environment, |
| 284 | + | ExposedPorts: exposedPorts, | |||
| 259 | }, | 285 | }, | ||
| 260 | &container.HostConfig{ | 286 | &container.HostConfig{ | ||
| 261 | PortBindings: portBindings, | 287 | PortBindings: portBindings, | ||
| @@ -273,12 +299,40 @@ func CreateContainer(cmdParams ContainerCommandParams) (string, error) { | |||||
| 273 | cmdParams.ContainerName, | 299 | cmdParams.ContainerName, | ||
| 274 | ) | 300 | ) | ||
| 275 | if err != nil { | 301 | if err != nil { | ||
| 276 | - | return "", err | 302 | + | return "", fmt.Errorf("container creation failed: %v", err) |
| 277 | } | 303 | } | ||
| 278 | 304 | ||||
| 305 | + | log.Info("Container created successfully with ID: %s\n", resp.ID) | |||
| 306 | + | ||||
| 279 | return resp.ID, nil | 307 | return resp.ID, nil | ||
| 280 | } | 308 | } | ||
| 281 | 309 | ||||
| 310 | + | func WaitForContainerToBeRunning(containerID string, timeout time.Duration) error { | |||
| 311 | + | ctx, cancel := context.WithTimeout(context.Background(), timeout) | |||
| 312 | + | defer cancel() | |||
| 313 | + | ||||
| 314 | + | for { | |||
| 315 | + | select { | |||
| 316 | + | case <-ctx.Done(): | |||
| 317 | + | return fmt.Errorf("timeout waiting for container to start") | |||
| 318 | + | case <-time.After(time.Second): | |||
| 319 | + | state, err := GetContainerState(containerID) | |||
| 320 | + | if err != nil { | |||
| 321 | + | return fmt.Errorf("error checking container state: %v", err) | |||
| 322 | + | } | |||
| 323 | + | ||||
| 324 | + | if state == "running" { | |||
| 325 | + | return nil | |||
| 326 | + | } | |||
| 327 | + | ||||
| 328 | + | if state == "exited" { | |||
| 329 | + | logs, _ := GetContainerLogs(containerID) | |||
| 330 | + | return fmt.Errorf("container exited unexpectedly. Logs: %s", logs) | |||
| 331 | + | } | |||
| 332 | + | } | |||
| 333 | + | } | |||
| 334 | + | } | |||
| 335 | + | ||||
| 282 | func CheckIfNetworkExists(networkName string) (bool, error) { | 336 | func CheckIfNetworkExists(networkName string) (bool, error) { | ||
| 283 | networks, err := dockerCli.NetworkList(context.Background(), types.NetworkListOptions{}) | 337 | networks, err := dockerCli.NetworkList(context.Background(), types.NetworkListOptions{}) | ||
| 284 | if err != nil { | 338 | if err != nil { | ||
| @@ -323,8 +377,6 @@ func GetContainerName(containerID string) (string, error) { | |||||
| 323 | return "", err | 377 | return "", err | ||
| 324 | } | 378 | } | ||
| 325 | 379 | ||||
| 326 | - | // Debug | |||
| 327 | - | fmt.Printf("Container name: %s\n", containerInfo.Name) | |||
| 328 | return containerInfo.Name, nil | 380 | return containerInfo.Name, nil | ||
| 329 | } | 381 | } | ||
| 330 | 382 | ||||
| @@ -415,3 +467,165 @@ func GetContainerIDByName(containerName string) string { | |||||
| 415 | 467 | ||||
| 416 | return "" | 468 | return "" | ||
| 417 | } | 469 | } | ||
| 470 | + | ||||
| 471 | + | func ContainerExists(containerID string) bool { | |||
| 472 | + | _, err := dockerCli.ContainerInspect(context.Background(), containerID) | |||
| 473 | + | return err == nil | |||
| 474 | + | } | |||
| 475 | + | ||||
| 476 | + | func GetContainerState(containerID string) (string, error) { | |||
| 477 | + | info, err := dockerCli.ContainerInspect(context.Background(), containerID) | |||
| 478 | + | if err != nil { | |||
| 479 | + | return "", err | |||
| 480 | + | } | |||
| 481 | + | return info.State.Status, nil | |||
| 482 | + | } | |||
| 483 | + | ||||
| 484 | + | // StartContainerWithContext starts a container with context | |||
| 485 | + | func StartContainerWithContext(ctx context.Context, containerID string) error { | |||
| 486 | + | fmt.Println("Starting container", containerID) | |||
| 487 | + | ||||
| 488 | + | // Check if the container is not already in a running state | |||
| 489 | + | containerInfo, err := GetContainerInfo(containerID) | |||
| 490 | + | if err != nil { | |||
| 491 | + | return fmt.Errorf("could not get container info: %v", err) | |||
| 492 | + | } | |||
| 493 | + | ||||
| 494 | + | if containerInfo.State.Running { | |||
| 495 | + | return fmt.Errorf("container is already running") | |||
| 496 | + | } | |||
| 497 | + | ||||
| 498 | + | // Start container using the Docker client with context | |||
| 499 | + | err = dockerCli.ContainerStart(ctx, containerID, container.StartOptions{}) | |||
| 500 | + | if err != nil { | |||
| 501 | + | return fmt.Errorf("could not start container: %v", err) | |||
| 502 | + | } | |||
| 503 | + | ||||
| 504 | + | return nil | |||
| 505 | + | } | |||
| 506 | + | ||||
| 507 | + | func GetContainerPorts(containerID string) (string, error) { | |||
| 508 | + | cli, err := client.NewClientWithOpts(client.FromEnv) | |||
| 509 | + | if err != nil { | |||
| 510 | + | return "", err | |||
| 511 | + | } | |||
| 512 | + | defer cli.Close() | |||
| 513 | + | ||||
| 514 | + | container, err := cli.ContainerInspect(context.Background(), containerID) | |||
| 515 | + | if err != nil { | |||
| 516 | + | return "", err | |||
| 517 | + | } | |||
| 518 | + | ||||
| 519 | + | var ports []string | |||
| 520 | + | for port, bindings := range container.NetworkSettings.Ports { | |||
| 521 | + | for _, binding := range bindings { | |||
| 522 | + | ports = append(ports, fmt.Sprintf("%s:%s->%s", binding.HostIP, binding.HostPort, port)) | |||
| 523 | + | } | |||
| 524 | + | } | |||
| 525 | + | ||||
| 526 | + | return strings.Join(ports, ", "), nil | |||
| 527 | + | } | |||
| 528 | + | ||||
| 529 | + | func GetContainerUptime(containerID string) (string, error) { | |||
| 530 | + | cli, err := client.NewClientWithOpts(client.FromEnv) | |||
| 531 | + | if err != nil { | |||
| 532 | + | return "", err | |||
| 533 | + | } | |||
| 534 | + | defer cli.Close() | |||
| 535 | + | ||||
| 536 | + | container, err := cli.ContainerInspect(context.Background(), containerID) | |||
| 537 | + | if err != nil { | |||
| 538 | + | return "", err | |||
| 539 | + | } | |||
| 540 | + | ||||
| 541 | + | if !container.State.Running { | |||
| 542 | + | return "not running", nil | |||
| 543 | + | } | |||
| 544 | + | ||||
| 545 | + | startTime, err := time.Parse(time.RFC3339, container.State.StartedAt) | |||
| 546 | + | if err != nil { | |||
| 547 | + | return "", err | |||
| 548 | + | } | |||
| 549 | + | ||||
| 550 | + | duration := time.Since(startTime) | |||
| 551 | + | return formatDuration(duration), nil | |||
| 552 | + | } | |||
| 553 | + | ||||
| 554 | + | func GetContainersUsingPort(port string) ([]string, error) { | |||
| 555 | + | cli, err := client.NewClientWithOpts(client.FromEnv) | |||
| 556 | + | if err != nil { | |||
| 557 | + | return nil, fmt.Errorf("failed to create Docker client: %w", err) | |||
| 558 | + | } | |||
| 559 | + | defer cli.Close() | |||
| 560 | + | ||||
| 561 | + | ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) | |||
| 562 | + | defer cancel() | |||
| 563 | + | ||||
| 564 | + | // List all containers (including running and stopped) | |||
| 565 | + | containers, err := cli.ContainerList(ctx, container.ListOptions{All: true}) | |||
| 566 | + | var containersUsingPort []string | |||
| 567 | + | ||||
| 568 | + | for _, container := range containers { | |||
| 569 | + | // Inspect container to get detailed port information | |||
| 570 | + | inspect, err := cli.ContainerInspect(ctx, container.ID) | |||
| 571 | + | if err != nil { | |||
| 572 | + | continue // Skip containers we can't inspect | |||
| 573 | + | } | |||
| 574 | + | ||||
| 575 | + | // Check exposed ports in container config | |||
| 576 | + | if inspect.Config != nil && inspect.Config.ExposedPorts != nil { | |||
| 577 | + | for exposedPort := range inspect.Config.ExposedPorts { | |||
| 578 | + | if strings.HasPrefix(string(exposedPort), port+"/") { | |||
| 579 | + | containersUsingPort = append(containersUsingPort, strings.TrimPrefix(container.Names[0], "/")) | |||
| 580 | + | break | |||
| 581 | + | } | |||
| 582 | + | } | |||
| 583 | + | } | |||
| 584 | + | ||||
| 585 | + | // Check port bindings in host config | |||
| 586 | + | if inspect.HostConfig != nil && inspect.HostConfig.PortBindings != nil { | |||
| 587 | + | for _, bindings := range inspect.HostConfig.PortBindings { | |||
| 588 | + | for _, binding := range bindings { | |||
| 589 | + | if binding.HostPort == port { | |||
| 590 | + | containersUsingPort = append(containersUsingPort, strings.TrimPrefix(container.Names[0], "/")) | |||
| 591 | + | break | |||
| 592 | + | } | |||
| 593 | + | } | |||
| 594 | + | } | |||
| 595 | + | } | |||
| 596 | + | ||||
| 597 | + | // Check currently published ports | |||
| 598 | + | for _, portBinding := range container.Ports { | |||
| 599 | + | if fmt.Sprintf("%d", portBinding.PublicPort) == port { | |||
| 600 | + | containersUsingPort = append(containersUsingPort, strings.TrimPrefix(container.Names[0], "/")) | |||
| 601 | + | break | |||
| 602 | + | } | |||
| 603 | + | } | |||
| 604 | + | } | |||
| 605 | + | ||||
| 606 | + | // Remove duplicates | |||
| 607 | + | seen := make(map[string]bool) | |||
| 608 | + | unique := make([]string, 0, len(containersUsingPort)) | |||
| 609 | + | for _, name := range containersUsingPort { | |||
| 610 | + | if !seen[name] { | |||
| 611 | + | seen[name] = true | |||
| 612 | + | unique = append(unique, name) | |||
| 613 | + | } | |||
| 614 | + | } | |||
| 615 | + | ||||
| 616 | + | return unique, nil | |||
| 617 | + | } | |||
| 618 | + | ||||
| 619 | + | func formatDuration(d time.Duration) string { | |||
| 620 | + | days := int(d.Hours() / 24) | |||
| 621 | + | hours := int(d.Hours()) % 24 | |||
| 622 | + | minutes := int(d.Minutes()) % 60 | |||
| 623 | + | ||||
| 624 | + | if days > 0 { | |||
| 625 | + | return fmt.Sprintf("%dd %dh %dm", days, hours, minutes) | |||
| 626 | + | } | |||
| 627 | + | if hours > 0 { | |||
| 628 | + | return fmt.Sprintf("%dh %dm", hours, minutes) | |||
| 629 | + | } | |||
| 630 | + | return fmt.Sprintf("%dm", minutes) | |||
| 631 | + | } | |||
M pkg/docker/images.go +54 -25
| @@ -6,11 +6,12 @@ import ( | |||
| 6 | 6 | "encoding/json" | |
| 7 | 7 | "fmt" | |
| 8 | 8 | "io" | |
| 9 | - | "log" | |
| 10 | 9 | "os" | |
| 11 | 10 | "regexp" | |
| 12 | 11 | "strings" | |
| 13 | 12 | ||
| 13 | + | "github.com/bnema/gordon/pkg/verify" | |
| 14 | + | "github.com/charmbracelet/log" | |
| 14 | 15 | "github.com/docker/docker/api/types" | |
| 15 | 16 | "github.com/docker/docker/api/types/image" | |
| 16 | 17 | ) | |
| @@ -123,33 +124,52 @@ func createTagPattern(tag string) *regexp.Regexp { | |||
| 123 | 124 | ||
| 124 | 125 | // ImportImageToEngine imports an image to the Docker engine and returns the image ID | |
| 125 | 126 | func ImportImageToEngine(imageFilePath string) (string, error) { | |
| 126 | - | log.Printf("Starting to import image from file: %s", imageFilePath) | |
| 127 | + | log.Info("Starting to import image from file", "path", imageFilePath) | |
| 127 | 128 | ||
| 128 | 129 | imageFile, err := os.Open(imageFilePath) | |
| 129 | 130 | if err != nil { | |
| 130 | - | log.Printf("Failed to open image file: %v", err) | |
| 131 | 131 | return "", fmt.Errorf("failed to open image file: %w", err) | |
| 132 | 132 | } | |
| 133 | 133 | defer imageFile.Close() | |
| 134 | 134 | ||
| 135 | + | // Verify file size is non-zero | |
| 136 | + | stat, err := imageFile.Stat() | |
| 137 | + | if err != nil { | |
| 138 | + | return "", fmt.Errorf("failed to get file stats: %w", err) | |
| 139 | + | } | |
| 140 | + | if stat.Size() == 0 { | |
| 141 | + | return "", fmt.Errorf("image file is empty") | |
| 142 | + | } | |
| 143 | + | ||
| 144 | + | // Verify tar format | |
| 145 | + | if err := verify.VerifyTarFile(imageFilePath); err != nil { | |
| 146 | + | return "", fmt.Errorf("invalid tar file: %w", err) | |
| 147 | + | } | |
| 148 | + | ||
| 149 | + | // Reset file pointer | |
| 150 | + | if _, err := imageFile.Seek(0, 0); err != nil { | |
| 151 | + | return "", fmt.Errorf("failed to reset file pointer: %w", err) | |
| 152 | + | } | |
| 153 | + | ||
| 135 | 154 | resp, err := dockerCli.ImageLoad(context.Background(), imageFile, true) | |
| 136 | 155 | if err != nil { | |
| 137 | - | log.Printf("Failed to load image: %v", err) | |
| 138 | - | return "", fmt.Errorf("failed to import image: %w", err) | |
| 156 | + | return "", fmt.Errorf("failed to load image: %w", err) | |
| 139 | 157 | } | |
| 140 | 158 | defer resp.Body.Close() | |
| 141 | 159 | ||
| 142 | 160 | var imageName string | |
| 143 | 161 | scanner := bufio.NewScanner(resp.Body) | |
| 144 | 162 | for scanner.Scan() { | |
| 145 | - | line := scanner.Text() | |
| 146 | - | ||
| 147 | 163 | var message struct { | |
| 148 | 164 | Stream string `json:"stream"` | |
| 165 | + | Error string `json:"error"` | |
| 149 | 166 | } | |
| 150 | - | if err := json.Unmarshal([]byte(line), &message); err != nil { | |
| 151 | - | log.Printf("Failed to unmarshal JSON: %v", err) | |
| 152 | - | continue // Skip lines that aren't JSON | |
| 167 | + | if err := json.Unmarshal(scanner.Bytes(), &message); err != nil { | |
| 168 | + | continue | |
| 169 | + | } | |
| 170 | + | ||
| 171 | + | if message.Error != "" { | |
| 172 | + | return "", fmt.Errorf("docker load error: %s", message.Error) | |
| 153 | 173 | } | |
| 154 | 174 | ||
| 155 | 175 | if strings.HasPrefix(message.Stream, "Loaded image: ") { | |
| @@ -159,47 +179,56 @@ func ImportImageToEngine(imageFilePath string) (string, error) { | |||
| 159 | 179 | } | |
| 160 | 180 | ||
| 161 | 181 | if err := scanner.Err(); err != nil { | |
| 162 | - | log.Printf("Error reading response: %v", err) | |
| 163 | - | return "", fmt.Errorf("error reading response: %w", err) | |
| 182 | + | return "", fmt.Errorf("error reading docker response: %w", err) | |
| 164 | 183 | } | |
| 165 | 184 | ||
| 166 | 185 | if imageName == "" { | |
| 167 | - | log.Printf("Failed to find image name in import response") | |
| 168 | - | return "", fmt.Errorf("failed to find image name in import response") | |
| 186 | + | return "", fmt.Errorf("failed to get loaded image name") | |
| 169 | 187 | } | |
| 170 | 188 | ||
| 171 | - | // Now we need to get the image ID from the image name | |
| 172 | 189 | imageID, err := GetImageIDByName(imageName) | |
| 173 | 190 | if err != nil { | |
| 174 | - | log.Printf("Failed to get image ID for name %s: %v", imageName, err) | |
| 175 | 191 | return "", fmt.Errorf("failed to get image ID: %w", err) | |
| 176 | 192 | } | |
| 177 | 193 | ||
| 178 | - | log.Printf("Successfully imported image with ID: %s", imageID) | |
| 194 | + | log.Info("Image imported successfully", "imageID", imageID) | |
| 179 | 195 | return imageID, nil | |
| 180 | 196 | } | |
| 181 | 197 | ||
| 182 | 198 | // ExportImageFromEngine exports an image from the Docker engine and returns it as an io.Reader | |
| 183 | 199 | func ExportImageFromEngine(imageID string) (io.ReadCloser, error) { | |
| 184 | - | // Check if the Docker client has been initialized | |
| 200 | + | log.Debug("Starting image export", "imageID", imageID) | |
| 185 | 201 | ||
| 186 | 202 | // Get the image information using the Docker client | |
| 187 | 203 | imageInfo, err := GetImageInfo(imageID) | |
| 188 | 204 | if err != nil { | |
| 189 | - | return nil, err | |
| 205 | + | return nil, fmt.Errorf("failed to get image info: %w", err) | |
| 190 | 206 | } | |
| 191 | 207 | ||
| 192 | - | if len(imageInfo.RepoTags) == 0 { | |
| 193 | - | return nil, fmt.Errorf("image has no tag") | |
| 208 | + | // Log image details for debugging | |
| 209 | + | log.Debug("Image info retrieved", | |
| 210 | + | "repoTags", imageInfo.RepoTags, | |
| 211 | + | "size", imageInfo.Size) | |
| 212 | + | ||
| 213 | + | // Use ImageSave with both the image ID and repo tags to ensure metadata is preserved | |
| 214 | + | references := []string{imageID} | |
| 215 | + | if len(imageInfo.RepoTags) > 0 { | |
| 216 | + | references = append(references, imageInfo.RepoTags...) | |
| 194 | 217 | } | |
| 195 | 218 | ||
| 196 | - | // Export the image using the Docker client | |
| 197 | - | imageReader, err := dockerCli.ImageSave(context.Background(), []string{imageInfo.RepoTags[0]}) | |
| 219 | + | log.Debug("Saving image with references", "refs", references) | |
| 220 | + | ||
| 221 | + | imageReader, err := dockerCli.ImageSave(context.Background(), references) | |
| 198 | 222 | if err != nil { | |
| 199 | - | return nil, fmt.Errorf("failed to export image: %w", err) | |
| 223 | + | return nil, fmt.Errorf("failed to save image: %w", err) | |
| 200 | 224 | } | |
| 201 | 225 | ||
| 202 | - | return imageReader, nil | |
| 226 | + | // Wrap in buffered reader for better performance | |
| 227 | + | bufferedReader := bufio.NewReader(imageReader) | |
| 228 | + | ||
| 229 | + | log.Debug("Image export successful", "imageID", imageID) | |
| 230 | + | ||
| 231 | + | return io.NopCloser(bufferedReader), nil | |
| 203 | 232 | } | |
| 204 | 233 | ||
| 205 | 234 | // From an ID, get the all the information about the image | |
| @@ -6,11 +6,12 @@ import ( | |||||
| 6 | "encoding/json" | 6 | "encoding/json" | ||
| 7 | "fmt" | 7 | "fmt" | ||
| 8 | "io" | 8 | "io" | ||
| 9 | - | "log" | |||
| 10 | "os" | 9 | "os" | ||
| 11 | "regexp" | 10 | "regexp" | ||
| 12 | "strings" | 11 | "strings" | ||
| 13 | 12 | ||||
| 13 | + | "github.com/bnema/gordon/pkg/verify" | |||
| 14 | + | "github.com/charmbracelet/log" | |||
| 14 | "github.com/docker/docker/api/types" | 15 | "github.com/docker/docker/api/types" | ||
| 15 | "github.com/docker/docker/api/types/image" | 16 | "github.com/docker/docker/api/types/image" | ||
| 16 | ) | 17 | ) | ||
| @@ -123,33 +124,52 @@ func createTagPattern(tag string) *regexp.Regexp { | |||||
| 123 | 124 | ||||
| 124 | // ImportImageToEngine imports an image to the Docker engine and returns the image ID | 125 | // ImportImageToEngine imports an image to the Docker engine and returns the image ID | ||
| 125 | func ImportImageToEngine(imageFilePath string) (string, error) { | 126 | func ImportImageToEngine(imageFilePath string) (string, error) { | ||
| 126 | - | log.Printf("Starting to import image from file: %s", imageFilePath) | 127 | + | log.Info("Starting to import image from file", "path", imageFilePath) |
| 127 | 128 | ||||
| 128 | imageFile, err := os.Open(imageFilePath) | 129 | imageFile, err := os.Open(imageFilePath) | ||
| 129 | if err != nil { | 130 | if err != nil { | ||
| 130 | - | log.Printf("Failed to open image file: %v", err) | |||
| 131 | return "", fmt.Errorf("failed to open image file: %w", err) | 131 | return "", fmt.Errorf("failed to open image file: %w", err) | ||
| 132 | } | 132 | } | ||
| 133 | defer imageFile.Close() | 133 | defer imageFile.Close() | ||
| 134 | 134 | ||||
| 135 | + | // Verify file size is non-zero | |||
| 136 | + | stat, err := imageFile.Stat() | |||
| 137 | + | if err != nil { | |||
| 138 | + | return "", fmt.Errorf("failed to get file stats: %w", err) | |||
| 139 | + | } | |||
| 140 | + | if stat.Size() == 0 { | |||
| 141 | + | return "", fmt.Errorf("image file is empty") | |||
| 142 | + | } | |||
| 143 | + | ||||
| 144 | + | // Verify tar format | |||
| 145 | + | if err := verify.VerifyTarFile(imageFilePath); err != nil { | |||
| 146 | + | return "", fmt.Errorf("invalid tar file: %w", err) | |||
| 147 | + | } | |||
| 148 | + | ||||
| 149 | + | // Reset file pointer | |||
| 150 | + | if _, err := imageFile.Seek(0, 0); err != nil { | |||
| 151 | + | return "", fmt.Errorf("failed to reset file pointer: %w", err) | |||
| 152 | + | } | |||
| 153 | + | ||||
| 135 | resp, err := dockerCli.ImageLoad(context.Background(), imageFile, true) | 154 | resp, err := dockerCli.ImageLoad(context.Background(), imageFile, true) | ||
| 136 | if err != nil { | 155 | if err != nil { | ||
| 137 | - | log.Printf("Failed to load image: %v", err) | 156 | + | return "", fmt.Errorf("failed to load image: %w", err) |
| 138 | - | return "", fmt.Errorf("failed to import image: %w", err) | |||
| 139 | } | 157 | } | ||
| 140 | defer resp.Body.Close() | 158 | defer resp.Body.Close() | ||
| 141 | 159 | ||||
| 142 | var imageName string | 160 | var imageName string | ||
| 143 | scanner := bufio.NewScanner(resp.Body) | 161 | scanner := bufio.NewScanner(resp.Body) | ||
| 144 | for scanner.Scan() { | 162 | for scanner.Scan() { | ||
| 145 | - | line := scanner.Text() | |||
| 146 | - | ||||
| 147 | var message struct { | 163 | var message struct { | ||
| 148 | Stream string `json:"stream"` | 164 | Stream string `json:"stream"` | ||
| 165 | + | Error string `json:"error"` | |||
| 149 | } | 166 | } | ||
| 150 | - | if err := json.Unmarshal([]byte(line), &message); err != nil { | 167 | + | if err := json.Unmarshal(scanner.Bytes(), &message); err != nil { |
| 151 | - | log.Printf("Failed to unmarshal JSON: %v", err) | 168 | + | continue |
| 152 | - | continue // Skip lines that aren't JSON | 169 | + | } |
| 170 | + | ||||
| 171 | + | if message.Error != "" { | |||
| 172 | + | return "", fmt.Errorf("docker load error: %s", message.Error) | |||
| 153 | } | 173 | } | ||
| 154 | 174 | ||||
| 155 | if strings.HasPrefix(message.Stream, "Loaded image: ") { | 175 | if strings.HasPrefix(message.Stream, "Loaded image: ") { | ||
| @@ -159,47 +179,56 @@ func ImportImageToEngine(imageFilePath string) (string, error) { | |||||
| 159 | } | 179 | } | ||
| 160 | 180 | ||||
| 161 | if err := scanner.Err(); err != nil { | 181 | if err := scanner.Err(); err != nil { | ||
| 162 | - | log.Printf("Error reading response: %v", err) | 182 | + | return "", fmt.Errorf("error reading docker response: %w", err) |
| 163 | - | return "", fmt.Errorf("error reading response: %w", err) | |||
| 164 | } | 183 | } | ||
| 165 | 184 | ||||
| 166 | if imageName == "" { | 185 | if imageName == "" { | ||
| 167 | - | log.Printf("Failed to find image name in import response") | 186 | + | return "", fmt.Errorf("failed to get loaded image name") |
| 168 | - | return "", fmt.Errorf("failed to find image name in import response") | |||
| 169 | } | 187 | } | ||
| 170 | 188 | ||||
| 171 | - | // Now we need to get the image ID from the image name | |||
| 172 | imageID, err := GetImageIDByName(imageName) | 189 | imageID, err := GetImageIDByName(imageName) | ||
| 173 | if err != nil { | 190 | if err != nil { | ||
| 174 | - | log.Printf("Failed to get image ID for name %s: %v", imageName, err) | |||
| 175 | return "", fmt.Errorf("failed to get image ID: %w", err) | 191 | return "", fmt.Errorf("failed to get image ID: %w", err) | ||
| 176 | } | 192 | } | ||
| 177 | 193 | ||||
| 178 | - | log.Printf("Successfully imported image with ID: %s", imageID) | 194 | + | log.Info("Image imported successfully", "imageID", imageID) |
| 179 | return imageID, nil | 195 | return imageID, nil | ||
| 180 | } | 196 | } | ||
| 181 | 197 | ||||
| 182 | // ExportImageFromEngine exports an image from the Docker engine and returns it as an io.Reader | 198 | // ExportImageFromEngine exports an image from the Docker engine and returns it as an io.Reader | ||
| 183 | func ExportImageFromEngine(imageID string) (io.ReadCloser, error) { | 199 | func ExportImageFromEngine(imageID string) (io.ReadCloser, error) { | ||
| 184 | - | // Check if the Docker client has been initialized | 200 | + | log.Debug("Starting image export", "imageID", imageID) |
| 185 | 201 | ||||
| 186 | // Get the image information using the Docker client | 202 | // Get the image information using the Docker client | ||
| 187 | imageInfo, err := GetImageInfo(imageID) | 203 | imageInfo, err := GetImageInfo(imageID) | ||
| 188 | if err != nil { | 204 | if err != nil { | ||
| 189 | - | return nil, err | 205 | + | return nil, fmt.Errorf("failed to get image info: %w", err) |
| 190 | } | 206 | } | ||
| 191 | 207 | ||||
| 192 | - | if len(imageInfo.RepoTags) == 0 { | 208 | + | // Log image details for debugging |
| 193 | - | return nil, fmt.Errorf("image has no tag") | 209 | + | log.Debug("Image info retrieved", |
| 210 | + | "repoTags", imageInfo.RepoTags, | |||
| 211 | + | "size", imageInfo.Size) | |||
| 212 | + | ||||
| 213 | + | // Use ImageSave with both the image ID and repo tags to ensure metadata is preserved | |||
| 214 | + | references := []string{imageID} | |||
| 215 | + | if len(imageInfo.RepoTags) > 0 { | |||
| 216 | + | references = append(references, imageInfo.RepoTags...) | |||
| 194 | } | 217 | } | ||
| 195 | 218 | ||||
| 196 | - | // Export the image using the Docker client | 219 | + | log.Debug("Saving image with references", "refs", references) |
| 197 | - | imageReader, err := dockerCli.ImageSave(context.Background(), []string{imageInfo.RepoTags[0]}) | 220 | + | |
| 221 | + | imageReader, err := dockerCli.ImageSave(context.Background(), references) | |||
| 198 | if err != nil { | 222 | if err != nil { | ||
| 199 | - | return nil, fmt.Errorf("failed to export image: %w", err) | 223 | + | return nil, fmt.Errorf("failed to save image: %w", err) |
| 200 | } | 224 | } | ||
| 201 | 225 | ||||
| 202 | - | return imageReader, nil | 226 | + | // Wrap in buffered reader for better performance |
| 227 | + | bufferedReader := bufio.NewReader(imageReader) | |||
| 228 | + | ||||
| 229 | + | log.Debug("Image export successful", "imageID", imageID) | |||
| 230 | + | ||||
| 231 | + | return io.NopCloser(bufferedReader), nil | |||
| 203 | } | 232 | } | ||
| 204 | 233 | ||||
| 205 | // From an ID, get the all the information about the image | 234 | // From an ID, get the all the information about the image | ||
A pkg/verify/tar.go +31 -0
| @@ -0,0 +1,31 @@ | |||
| 1 | + | package verify | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "fmt" | |
| 5 | + | "os" | |
| 6 | + | ) | |
| 7 | + | ||
| 8 | + | // VerifyTarFile checks if the file is a valid tar archive | |
| 9 | + | func VerifyTarFile(filepath string) error { | |
| 10 | + | file, err := os.Open(filepath) | |
| 11 | + | if err != nil { | |
| 12 | + | return fmt.Errorf("failed to open file: %w", err) | |
| 13 | + | } | |
| 14 | + | defer file.Close() | |
| 15 | + | ||
| 16 | + | // Read first few bytes to check tar magic number | |
| 17 | + | header := make([]byte, 512) | |
| 18 | + | _, err = file.Read(header) | |
| 19 | + | if err != nil { | |
| 20 | + | return fmt.Errorf("failed to read header: %w", err) | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | // Check for ustar format | |
| 24 | + | if string(header[257:262]) != "ustar" { | |
| 25 | + | return fmt.Errorf("not a valid tar archive") | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | // Reset file pointer | |
| 29 | + | _, err = file.Seek(0, 0) | |
| 30 | + | return err | |
| 31 | + | } | |
| @@ -0,0 +1,31 @@ | |||||
| 1 | + | package verify | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "fmt" | |||
| 5 | + | "os" | |||
| 6 | + | ) | |||
| 7 | + | ||||
| 8 | + | // VerifyTarFile checks if the file is a valid tar archive | |||
| 9 | + | func VerifyTarFile(filepath string) error { | |||
| 10 | + | file, err := os.Open(filepath) | |||
| 11 | + | if err != nil { | |||
| 12 | + | return fmt.Errorf("failed to open file: %w", err) | |||
| 13 | + | } | |||
| 14 | + | defer file.Close() | |||
| 15 | + | ||||
| 16 | + | // Read first few bytes to check tar magic number | |||
| 17 | + | header := make([]byte, 512) | |||
| 18 | + | _, err = file.Read(header) | |||
| 19 | + | if err != nil { | |||
| 20 | + | return fmt.Errorf("failed to read header: %w", err) | |||
| 21 | + | } | |||
| 22 | + | ||||
| 23 | + | // Check for ustar format | |||
| 24 | + | if string(header[257:262]) != "ustar" { | |||
| 25 | + | return fmt.Errorf("not a valid tar archive") | |||
| 26 | + | } | |||
| 27 | + | ||||
| 28 | + | // Reset file pointer | |||
| 29 | + | _, err = file.Seek(0, 0) | |||
| 30 | + | return err | |||
| 31 | + | } | |||