detached 388adc3

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
388adc3a64295b4af8d3e80b3b2173f24786ead7
author
Brice · · 2024-11-18 04:08 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
22
33 COPY gordon /gordon
44
@@ -1,4 +1,4 @@
1-FROM alpine:latest1+FROM --platform=$TARGETPLATFORM alpine:latest
2 2
3 COPY gordon /gordon3 COPY gordon /gordon
4 4
M go.mod +10 -2
@@ -5,6 +5,8 @@ go 1.23
55 require (
66 github.com/AlecAivazis/survey/v2 v2.3.7
77 github.com/PuerkitoBio/goquery v1.9.2
8+ github.com/charmbracelet/bubbles v0.20.0
9+ github.com/charmbracelet/lipgloss v0.13.0
810 github.com/charmbracelet/log v0.4.0
911 github.com/docker/docker v27.1.2+incompatible
1012 github.com/docker/go-connections v0.5.0
@@ -22,12 +24,14 @@ require (
2224
2325 require (
2426 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
2628 github.com/charmbracelet/x/ansi v0.2.3 // indirect
29+ github.com/charmbracelet/x/term v0.2.0 // indirect
2730 github.com/containerd/log v0.1.0 // indirect
2831 github.com/creack/pty v1.1.18 // indirect
2932 github.com/distribution/reference v0.6.0 // indirect
3033 github.com/dustin/go-humanize v1.0.1 // indirect
34+ github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
3135 github.com/felixge/httpsnoop v1.0.4 // indirect
3236 github.com/go-logfmt/logfmt v0.6.0 // indirect
3337 github.com/go-logr/logr v1.4.2 // indirect
@@ -37,9 +41,12 @@ require (
3741 github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
3842 github.com/kr/pretty v0.3.1 // indirect
3943 github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
44+ github.com/mattn/go-localereader v0.0.1 // indirect
4045 github.com/mattn/go-runewidth v0.0.16 // indirect
4146 github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d // indirect
4247 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
4350 github.com/muesli/termenv v0.15.2 // indirect
4451 github.com/ncruces/go-strftime v0.1.9 // indirect
4552 github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
@@ -51,7 +58,8 @@ require (
5158 go.opentelemetry.io/otel/metric v1.29.0 // indirect
5259 go.opentelemetry.io/otel/sdk v1.21.0 // indirect
5360 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
5563 golang.org/x/term v0.23.0 // indirect
5664 google.golang.org/genproto/googleapis/api v0.0.0-20240528184218-531527333157 // indirect
5765 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.76 github.com/AlecAivazis/survey/v2 v2.3.7
7 github.com/PuerkitoBio/goquery v1.9.27 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.010 github.com/charmbracelet/log v0.4.0
9 github.com/docker/docker v27.1.2+incompatible11 github.com/docker/docker v27.1.2+incompatible
10 github.com/docker/go-connections v0.5.012 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 // indirect26 github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
25- github.com/charmbracelet/lipgloss v0.13.0 // indirect27+ github.com/charmbracelet/bubbletea v1.1.0 // direct
26 github.com/charmbracelet/x/ansi v0.2.3 // indirect28 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 // indirect30 github.com/containerd/log v0.1.0 // indirect
28 github.com/creack/pty v1.1.18 // indirect31 github.com/creack/pty v1.1.18 // indirect
29 github.com/distribution/reference v0.6.0 // indirect32 github.com/distribution/reference v0.6.0 // indirect
30 github.com/dustin/go-humanize v1.0.1 // indirect33 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 // indirect35 github.com/felixge/httpsnoop v1.0.4 // indirect
32 github.com/go-logfmt/logfmt v0.6.0 // indirect36 github.com/go-logfmt/logfmt v0.6.0 // indirect
33 github.com/go-logr/logr v1.4.2 // indirect37 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 // indirect41 github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
38 github.com/kr/pretty v0.3.1 // indirect42 github.com/kr/pretty v0.3.1 // indirect
39 github.com/lucasb-eyer/go-colorful v1.2.0 // indirect43 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 // indirect45 github.com/mattn/go-runewidth v0.0.16 // indirect
41 github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d // indirect46 github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d // indirect
42 github.com/moby/docker-image-spec v1.3.1 // indirect47 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 // indirect50 github.com/muesli/termenv v0.15.2 // indirect
44 github.com/ncruces/go-strftime v0.1.9 // indirect51 github.com/ncruces/go-strftime v0.1.9 // indirect
45 github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect52 github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
@@ -51,7 +58,8 @@ require (
51 go.opentelemetry.io/otel/metric v1.29.0 // indirect58 go.opentelemetry.io/otel/metric v1.29.0 // indirect
52 go.opentelemetry.io/otel/sdk v1.21.0 // indirect59 go.opentelemetry.io/otel/sdk v1.21.0 // indirect
53 go.opentelemetry.io/otel/trace v1.29.0 // indirect60 go.opentelemetry.io/otel/trace v1.29.0 // indirect
54- golang.org/x/exp v0.0.0-20231108232855-2478ac86f678 // indirect61+ 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 // indirect63 golang.org/x/term v0.23.0 // indirect
56 google.golang.org/genproto/googleapis/api v0.0.0-20240528184218-531527333157 // indirect64 google.golang.org/genproto/googleapis/api v0.0.0-20240528184218-531527333157 // indirect
57 modernc.org/gc/v3 v3.0.0-20240801135723-a856999a2e4a // indirect65 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
12 package auth
23
34 import (
@@ -1,3 +1,4 @@
1+// internal/cli/auth/device_flow.go
1 package auth2 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 {
7373 }
7474
7575 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+
76109 var buf bytes.Buffer
77110 size, err := io.Copy(&buf, reader)
78111 if err != nil {
@@ -95,31 +128,21 @@ func deployImage(a *cli.App, reader io.Reader, imageName, port, targetDomain str
95128 "Content-Type": {"application/octet-stream"},
96129 }
97130
98- // Send a small chunk first to detect any conflicts
99131 chunkedClient := handler.NewChunkedClient(a)
100132 ctx, cancel := context.WithTimeout(context.Background(), 60*time.Minute)
101133 defer cancel()
102134
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)
108136 if err != nil {
109137 var deployErr *common.DeploymentError
110138 if errors.As(err, &deployErr) && deployErr.StatusCode == http.StatusConflict {
111139 var deployResponse common.DeployResponse
112140 if jsonErr := json.Unmarshal([]byte(deployErr.RawResponse), &deployResponse); jsonErr == nil {
113141 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
121144 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)
123146 if err != nil {
124147 return fmt.Errorf("deployment failed after container removal: %w", err)
125148 }
@@ -128,13 +151,10 @@ func deployImage(a *cli.App, reader io.Reader, imageName, port, targetDomain str
128151 } else {
129152 return fmt.Errorf("deployment failed: %w", err)
130153 }
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")
138158 }
139159
140160 var deployResp common.DeployResponse
@@ -147,7 +167,7 @@ func deployImage(a *cli.App, reader io.Reader, imageName, port, targetDomain str
147167
148168 func waitForDeployment(domain string, containerID string) error {
149169 client := &http.Client{Timeout: 10 * time.Second}
150- maxRetries := 20
170+ maxRetries := 10
151171 retryInterval := time.Second
152172
153173 var shortContainerID string
@@ -200,9 +220,10 @@ func handleDeployError(a *cli.App, err error, imageName, port, targetDomain stri
200220 var deployErr *common.DeploymentError
201221 if errors.As(err, &deployErr) {
202222 var deployResponse common.DeployResponse
223+ var conflictResp common.ConflictCheckResponse
203224 if jsonErr := json.Unmarshal([]byte(deployErr.RawResponse), &deployResponse); jsonErr == nil {
204225 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 {
206227 if strings.Contains(err.Error(), "cancelled by user") {
207228 log.Warn("Deployment cancelled by user")
208229 return nil
@@ -217,3 +238,36 @@ func handleDeployError(a *cli.App, err error, imageName, port, targetDomain stri
217238 }
218239 return err
219240 }
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.Buffer109 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 chunk135+ 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.DeploymentError137 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.DeployResponse139 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 beginning144 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 upload155+
133- imageReader.Seek(1024, 0) // Skip the first 1KB we already sent156+ 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.DeployResponse160 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 := 20170+ maxRetries := 10
151 retryInterval := time.Second171 retryInterval := time.Second
152 172
153 var shortContainerID string173 var shortContainerID string
@@ -200,9 +220,10 @@ func handleDeployError(a *cli.App, err error, imageName, port, targetDomain stri
200 var deployErr *common.DeploymentError220 var deployErr *common.DeploymentError
201 if errors.As(err, &deployErr) {221 if errors.As(err, &deployErr) {
202 var deployResponse common.DeployResponse222 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 nil229 return nil
@@ -217,3 +238,36 @@ func handleDeployError(a *cli.App, err error, imageName, port, targetDomain stri
217 }238 }
218 return err239 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
22
33 import (
44 "context"
5- "errors"
5+ "encoding/json"
66 "fmt"
77 "net/http"
88 "os"
9- "time"
9+ "strings"
1010
1111 "github.com/bnema/gordon/internal/cli"
1212 "github.com/bnema/gordon/internal/cli/handler"
1313 "github.com/bnema/gordon/internal/common"
14+ "github.com/bnema/gordon/pkg/docker"
1415 "github.com/charmbracelet/log"
1516 "github.com/spf13/cobra"
1617 )
@@ -50,6 +51,12 @@ func NewPushCommand(a *cli.App) *cobra.Command {
5051 }
5152
5253 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+
5360 if err := handler.ValidateImageName(imageName); err != nil {
5461 return fmt.Errorf("invalid image name: %w", err)
5562 }
@@ -62,28 +69,72 @@ func pushImage(a *cli.App, imageName string) error {
6269 }
6370 defer reader.Close()
6471
72+ _, err = docker.GetImageIDByName(imageName)
73+ if err != nil {
74+ return fmt.Errorf("failed to get image ID: %w", err)
75+ }
76+
6577 sizeInMB := float64(actualSize) / 1024 / 1024
6678 log.Info("Image exported successfully",
6779 "image", imageName,
6880 "size", fmt.Sprintf("%.2fMB", sizeInMB))
6981
70- headers := http.Header{
71- "X-Image-Name": {imageName},
72- "Content-Type": {"application/octet-stream"},
73- }
82+ var resp *handler.Response
7483
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+ }
7690
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")
7995
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)
85114 }
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)
87138 }
88139
89140 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 / 102477 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.DeploymentError98+ 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 nil140 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+
23 package handler
34
45 import (
56 "bytes"
67 "context"
8+ "crypto/sha256"
79 "encoding/json"
10+ "errors"
811 "fmt"
912 "io"
13+ "net"
1014 "net/http"
15+ "os"
16+ "os/signal"
1117 "strings"
18+ "sync"
19+ "syscall"
1220 "time"
1321
1422 "github.com/bnema/gordon/internal/cli"
1523 "github.com/bnema/gordon/internal/common"
24+ "github.com/charmbracelet/bubbles/spinner"
25+ tea "github.com/charmbracelet/bubbletea"
26+ "github.com/charmbracelet/lipgloss"
1627 "github.com/charmbracelet/log"
1728 "github.com/google/uuid"
29+ "golang.org/x/exp/rand"
1830 )
1931
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+
20201 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
26217 }
27218
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+}
29224
225+// NewChunkedClient creates a new chunked client
30226 func NewChunkedClient(app *cli.App) *ChunkedClient {
31227 baseURL := app.Config.Http.BackendURL + "/api"
32228 token := app.Config.General.Token
33229
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-
43230 return &ChunkedClient{
44231 client: &http.Client{
45232 Timeout: 30 * time.Minute,
@@ -48,18 +235,48 @@ func NewChunkedClient(app *cli.App) *ChunkedClient {
48235 token: token,
49236 chunkSize: 5 * 1024 * 1024,
50237 app: app,
238+ progress: NewProgressDisplay(),
51239 }
52240 }
53241
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+
55268 transferID := uuid.New().String()
56269 totalChunks := (int(totalSize) + int(c.chunkSize) - 1) / int(c.chunkSize)
57270 buf := make([]byte, c.chunkSize)
58- var lastResponse *Response
59271
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()
62276
277+ defer cleanupClientTransfer(transferID)
278+
279+ var lastResponse *Response
63280 for chunkNum := 0; chunkNum < totalChunks; chunkNum++ {
64281 select {
65282 case <-ctx.Done():
@@ -70,11 +287,11 @@ func (c *ChunkedClient) SendFile(ctx context.Context, endpoint string, headers h
70287 return nil, fmt.Errorf("failed to read chunk %d: %w", chunkNum, err)
71288 }
72289
73- chunk := buf[:n]
74290 if n == 0 {
75291 continue
76292 }
77293
294+ chunk := buf[:n]
78295 metadata := common.ChunkMetadata{
79296 ChunkNumber: chunkNum,
80297 TotalChunks: totalChunks,
@@ -84,28 +301,20 @@ func (c *ChunkedClient) SendFile(ctx context.Context, endpoint string, headers h
84301 TransferID: transferID,
85302 }
86303
87- // Send chunk and get response
88304 resp, err := c.sendChunkWithRetry(ctx, endpoint, headers, chunk, metadata)
89305 if err != nil {
90306 return nil, fmt.Errorf("failed to send chunk %d: %w", chunkNum, err)
91307 }
92308 lastResponse = resp
93309
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)
104312 }
105313 }
106314
107- // Handle final response
108315 if lastResponse != nil {
316+ c.progress.Clear()
317+ responseType := determineResponseType(endpoint)
109318 if err := handleFinalResponse(lastResponse, responseType); err != nil {
110319 log.Error("Failed to handle final response", "error", err)
111320 }
@@ -114,6 +323,81 @@ func (c *ChunkedClient) SendFile(ctx context.Context, endpoint string, headers h
114323 return lastResponse, nil
115324 }
116325
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
117401 func determineResponseType(endpoint string) string {
118402 switch {
119403 case strings.Contains(endpoint, "/deploy"):
@@ -125,158 +409,170 @@ func determineResponseType(endpoint string) string {
125409 }
126410 }
127411
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+
129418 switch responseType {
130419 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)
137421 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)
145423 default:
146424 return fmt.Errorf("unknown response type: %s", responseType)
147425 }
148426 }
149427
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+ }
159435
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{}{}
166438
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]
171446 }
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+ }
177452
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"
180457 }
181-}
182458
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)
188464 }
189465
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+}
197468
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)
206474 }
207- return nil, fmt.Errorf("max retries exceeded: %w", lastErr)
475+ return nil
208476 }
209477
478+// sendChunk sends a single chunk to the server
210479 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+ }
213483 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
227493 }
494+ }
495+}
228496
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+ }
233502
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+ }
237508
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)))
244514
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+ }
248518
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()
253524
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+ }
259529
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{
264537 StatusCode: resp.StatusCode,
538+ Message: "Request failed",
265539 RawResponse: string(body),
266540 }
267- }
541+ }
268542
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+}
274550
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")
281555 }
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)
282578 }
@@ -1,45 +1,232 @@
1-// internal/cli/handler/chunked_client.go1+// internal/cli/handler/progress_display.go
2+
2 package handler3 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.Client202+ client *http.Client
22- baseURL string203+ baseURL string
23- token string204+ token string
24- chunkSize int64205+ chunkSize int64
25- app *cli.App206+ 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.ChunkMetadata219+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.Token228 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 endpoint272+ // 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 continue291 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 = resp308 lastResponse = resp
93 309
94- // Handle chunk response310+ // 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, nil323 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.DeployResponse420+ 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.PushResponse422+ 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.DeployResponse431+ 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.PushResponse440+ 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 nil451+ }
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 once460+ if len(logFields) > 0 {
185- maxRetries := 3461+ log.Info(message, logFields...)
186- if metadata.ChunkNumber == 0 && len(chunk) == 1024 {462+ } else {
187- maxRetries = 1463+ log.Info(message)
188 }464 }
189 465
190- var lastErr error466+ 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 check469+// 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 := false480+ 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 request492+ 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 headers497+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] = v504+ 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 request509+ // 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 Unauthorized515+ 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 one525+ body, err := io.ReadAll(resp.Body)
255- c.token = c.app.Config.General.Token526+ if err != nil {
256- reauthenticated = true527+ return nil, fmt.Errorf("failed to read response: %w", err)
257- continue528+ }
258- }
259 529
260- // Handle non-200 status codes530+ 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 body543+ 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
55 import (
66 "encoding/json"
77 "fmt"
8+ "time"
89
910 "github.com/AlecAivazis/survey/v2"
1011 "github.com/bnema/gordon/internal/cli"
@@ -12,13 +13,26 @@ import (
1213 "github.com/charmbracelet/log"
1314 )
1415
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+ }
1828
1929 var proceed bool
2030 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+ ),
2236 Default: true,
2337 }
2438
@@ -33,14 +47,14 @@ func HandleExistingContainer(a *cli.App, deployResponse *common.DeployResponse)
3347 log.Info("Stopping container...", "name", containerName, "id", shortID)
3448
3549 // Stop the container
36- if err := stopContainer(a, deployResponse.ContainerID); err != nil {
50+ if err := stopContainer(app, conflictResp.ContainerID); err != nil {
3751 return fmt.Errorf("failed to stop container '%s': %w", containerName, err)
3852 }
3953
4054 log.Info("Removing container...", "name", containerName, "id", shortID)
4155
4256 // Remove the container
43- if err := removeContainer(a, deployResponse.ContainerID); err != nil {
57+ if err := removeContainer(app, conflictResp.ContainerID); err != nil {
4458 return fmt.Errorf("failed to remove container '%s': %w", containerName, err)
4559 }
4660
@@ -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.ContainerName17+ // 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 bool29 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 container49 // 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 container56 // 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 (
88 )
99
1010 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)
1218 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)
1420 }
1521
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)
2130 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
2333 }
24- }
34+ defer reader.Close()
2535
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+ }()
3043
31- reader, err := docker.ExportImageFromEngine(imageID)
44+ // Get the actual size
45+ size, err := docker.GetImageSize(imageID)
3246 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)
3448 }
3549
36- return reader, actualSize, nil
50+ return pr, size, nil
3751 }
@@ -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 string22+ // Create a pipe for streaming
17- if exists {23+ pr, pw := io.Pipe()
18- imageID = imageName24+
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, nil50+ return pr, size, nil
37 }51 }
M internal/cli/handler/http.go +0 -26
@@ -8,7 +8,6 @@ import (
88 "net/http"
99
1010 "github.com/bnema/gordon/internal/cli"
11- "github.com/bnema/gordon/internal/cli/auth"
1211 "github.com/bnema/gordon/internal/common"
1312 )
1413
@@ -152,28 +151,3 @@ func createRequest(apiUrl, endpoint, method string, rp *common.RequestPayload, t
152151
153152 return req, nil
154153 }
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, nil152 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 {
1414 return nil
1515 }
1616
17-func EnsureImageTag(imageName *string) {
17+func EnsureImageTag(imageName *string) error {
18+ // if not tags returns an error
1819 if !strings.Contains(*imageName, ":") {
19- *imageName += ":latest"
20+ return fmt.Errorf("You must specify a tag for the image")
2021 }
22+
23+ return nil
2124 }
2225
2326 func ValidatePortMapping(port string) error {
@@ -14,10 +14,13 @@ func ValidateImageName(imageName string) error {
14 return nil14 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 {
8686 TransferID string `json:"transfer_id"`
8787 }
8888
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+
89110 // UnmarshalJSON custom unmarshaler for RequestPayload
90111 func (p *RequestPayload) UnmarshalJSON(data []byte) error {
91112 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 RequestPayload110 // 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.RawMessage112 var raw map[string]json.RawMessage
M internal/httpserve/handlers/endpoint_deploy.go +315 -247
@@ -2,274 +2,342 @@
22 package handlers
33
44 import (
5- "bytes"
65 "encoding/json"
76 "fmt"
87 "io"
98 "net/http"
9+ "os"
10+ "path/filepath"
1011 "strings"
11- "time"
1212
1313 authToken "github.com/bnema/gordon/internal/cli/auth"
1414 "github.com/bnema/gordon/internal/common"
1515 "github.com/bnema/gordon/internal/server"
1616 "github.com/bnema/gordon/pkg/docker"
17- "github.com/bnema/gordon/pkg/store"
1817 "github.com/charmbracelet/log"
1918 "github.com/labstack/echo/v4"
2019 )
2120
2221 // PostDeploy handles the container deployment request
2322 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+}
2590
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"))
2794 }
2895
2996 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+ })
124182 }
125183
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+ })
237306 }
238307
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 handlers2 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 request21 // 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 headers97+ 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 domain106+ 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 exists112+ }
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 request135+ 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 metadata152+ chunkStore.mu.Lock()
86- var metadata ChunkMetadata153+ 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 data161+ 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] = &metadata165+ }
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] = chunkData177+ 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 transfer180+ 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 headers187+
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 chunks209+ 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 data234+ log.Warn("Container already exists",
177- defer cleanupTransfer(transferID)235+ "name", containerName,
178-236+ "id", existingContainerID)
179- // Save and import the image237+ 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 container260+ })
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 information287+
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 := 20311+// retryInterval := time.Second
243- retryInterval := time.Second312+
244-313+// var shortContainerID string
245- var shortContainerID string314+// 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 nil331+// }
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 {
101101 return sendJSONResponse(c, http.StatusOK, info)
102102 }
103103
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) {
106106 payload := &common.PushPayload{
107107 ImageName: c.Request().Header.Get("X-Image-Name"),
108108 }
@@ -114,6 +114,7 @@ func validateAndPreparePayload(c echo.Context) (*common.PushPayload, error) {
114114 return payload, nil
115115 }
116116
117+// validateAndPrepareDeployPayload validates and prepares the deploy payload
117118 func validateAndPrepareDeployPayload(c echo.Context) (*common.DeployPayload, error) {
118119 payload := &common.DeployPayload{
119120 Port: c.Request().Header.Get("X-Ports"),
@@ -131,8 +132,8 @@ func validateAndPrepareDeployPayload(c echo.Context) (*common.DeployPayload, err
131132 return payload, nil
132133 }
133134
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) {
136137 imageReader := c.Request().Body
137138 defer imageReader.Close()
138139
@@ -140,29 +141,31 @@ func saveAndImportImage(c echo.Context, a *server.App, payload *common.PushPaylo
140141
141142 imagePath, err := store.SaveImageToStorage(&a.Config, imageFileName, imageReader)
142143 if err != nil {
143- return fmt.Errorf("failed to save image: %v", err)
144+ return "", fmt.Errorf("failed to save image: %v", err)
144145 }
145146
146147 imageID, err := docker.ImportImageToEngine(imagePath)
147148 if err != nil {
148149 store.RemoveFromStorage(imagePath)
149- return fmt.Errorf("failed to import image: %v", err)
150+ return "", fmt.Errorf("failed to import image: %v", err)
150151 }
151152
152153 if imageID == "" {
153- return errors.New("failed to import image")
154+ return "", errors.New("imported image ID is empty")
154155 }
155156
156157 err = store.RemoveFromStorage(imagePath)
157158 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)
159160 }
160161
161162 payload.ImageID = imageID
162- return nil
163+ return imagePath, nil
163164 }
164165
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
165167 func saveAndImportDeployImage(c echo.Context, a *server.App, payload *common.DeployPayload) (string, error) {
168+
166169 imageReader := c.Request().Body
167170 defer imageReader.Close()
168171
@@ -192,39 +195,72 @@ func saveAndImportDeployImage(c echo.Context, a *server.App, payload *common.Dep
192195 return imagePath, nil
193196 }
194197
195-// Container management helpers
198+// createAndStartContainer creates a container from the image, starts it and returns the container ID
196199 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+
197205 params, err := cmdparams.FromPayloadStructToCmdParams(payload, a, payload.ImageID)
198206 if err != nil {
199207 return "", "", fmt.Errorf("failed to create command parameters: %v", err)
200208 }
201209
210+ log.Debug("Container parameters prepared", "params", params)
211+
202212 containerID, err := docker.CreateContainer(params)
203213 if err != nil {
204214 return "", "", fmt.Errorf("failed to create container: %v", err)
205215 }
206216
217+ log.Info("Container created successfully", "containerID", containerID)
218+
207219 containerName, err := docker.GetContainerName(containerID)
208220 if err != nil {
209221 return "", "", fmt.Errorf("failed to get container name: %v", err)
210222 }
211223
224+ log.Info("Starting container",
225+ "containerID", containerID,
226+ "name", containerName)
227+
228+ // Use regular StartContainer instead of StartContainerWithContext
212229 err = docker.StartContainer(containerID)
213230 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+ }
215246 return "", "", fmt.Errorf("failed to start container: %v", err)
216247 }
217248
249+ log.Info("Container started successfully",
250+ "containerID", containerID,
251+ "name", containerName)
252+
218253 return containerID, containerName, nil
219254 }
220255
221-// String manipulation helpers
256+// Helpers for sanitizing and normalizing input
222257 func sanitizeImageFileName(imageName string) string {
223258 imageName = regexp.MustCompile(`^([a-zA-Z0-9\-_.]+\/)?`).ReplaceAllString(imageName, "")
224259 imageName = regexp.MustCompile(`(:[a-zA-Z0-9\-_.]+)?$`).ReplaceAllString(imageName, "")
225260 return imageName + ".tar"
226261 }
227262
263+// normalizePort ensures that the port is in the format of "port/protocol"
228264 func normalizePort(port string) string {
229265 if !regexp.MustCompile(`\/(tcp|udp)$`).MatchString(port) {
230266 return port + "/tcp"
@@ -232,6 +268,7 @@ func normalizePort(port string) string {
232268 return port
233269 }
234270
271+// normalizeTargetDomain ensures that the target domain is in the format of "https://domain" or "http://domain"
235272 func normalizeTargetDomain(domain string) string {
236273 if !regexp.MustCompile(`^https?:\/\/`).MatchString(domain) {
237274 return "https://" + domain
@@ -239,6 +276,7 @@ func normalizeTargetDomain(domain string) string {
239276 return domain
240277 }
241278
279+// generateShortID generates a short ID from the full image ID
242280 func generateShortID(imageID string) string {
243281 shortID := imageID[7:]
244282 if len(shortID) > 12 {
@@ -247,6 +285,7 @@ func generateShortID(imageID string) string {
247285 return shortID
248286 }
249287
288+// generateCreateContainerURL generates the URL for creating a container
250289 func generateCreateContainerURL(a *server.App, shortID string) string {
251290 return fmt.Sprintf("%s://%s%s/cc/%s",
252291 a.Config.Http.Protocol(),
@@ -255,7 +294,7 @@ func generateCreateContainerURL(a *server.App, shortID string) string {
255294 shortID)
256295 }
257296
258-// Chunk management helpers
297+// generateContainerLogsURL generates the URL for viewing container logs
259298 func isTransferComplete(transferID string) bool {
260299 chunkStore.mu.Lock()
261300 defer chunkStore.mu.Unlock()
@@ -278,6 +317,7 @@ func isTransferComplete(transferID string) bool {
278317 return isComplete
279318 }
280319
320+// Cleanup helpers for old transfers
281321 func cleanupTransfer(transferID string) {
282322 chunkStore.mu.Lock()
283323 defer chunkStore.mu.Unlock()
@@ -287,6 +327,7 @@ func cleanupTransfer(transferID string) {
287327 delete(chunkStore.started, transferID)
288328 }
289329
330+// cleanupOldTransfers periodically checks for and cleans up old transfers
290331 func cleanupOldTransfers() {
291332 ticker := time.NewTicker(cleanupInterval)
292333 defer ticker.Stop() // Good practice to clean up the ticker
@@ -306,7 +347,7 @@ func cleanupOldTransfers() {
306347 }
307348 }
308349
309-// Error extraction helper
350+// extractContainerID extracts the container ID from an error message
310351 func extractContainerID(errorMessage string) string {
311352 re := regexp.MustCompile(`by\s+([0-9a-f]+)\.`)
312353 match := re.FindStringSubmatch(errorMessage)
@@ -316,7 +357,7 @@ func extractContainerID(errorMessage string) string {
316357 return ""
317358 }
318359
319-// File verification helper
360+// verifyFileContents checks if the file size matches the expected size
320361 func verifyFileContents(file *os.File, expectedSize int64) error {
321362 actualSize, err := file.Seek(0, 2) // Seek to end
322363 if err != nil {
@@ -331,6 +372,7 @@ func verifyFileContents(file *os.File, expectedSize int64) error {
331372 return err
332373 }
333374
375+// storeIDMapping stores a mapping between a short ID and a full ID
334376 func storeIDMapping(shortID, fullID string) {
335377 safelyInteractWithIDMap(Update, shortID, fullID)
336378 }
@@ -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 preparation104+// 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, nil114 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, nil132 return payload, nil
132 }133 }
133 134
134-// Helper functions for saving and importing images135+// 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().Body137 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 = imageID162 payload.ImageID = imageID
162- return nil163+ 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().Body169 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, nil195 return imagePath, nil
193 }196 }
194 197
195-// Container management helpers198+// 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, nil253 return containerID, containerName, nil
219 }254 }
220 255
221-// String manipulation helpers256+// 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 port268 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://" + domain274 return "https://" + domain
@@ -239,6 +276,7 @@ func normalizeTargetDomain(domain string) string {
239 return domain276 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 shortID285 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 helpers297+// 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 isComplete317 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 ticker333 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 helper350+// 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 helper360+// 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 end362 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 err372 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 @@
22 package handlers
33
44 import (
5+ "archive/tar"
56 "bytes"
67 "encoding/json"
78 "fmt"
89 "io"
910 "net/http"
1011 "os"
12+ "path/filepath"
13+ "runtime"
14+ "strings"
1115
1216 "github.com/bnema/gordon/internal/common"
1317 "github.com/bnema/gordon/internal/server"
1418 "github.com/bnema/gordon/pkg/docker"
15- "github.com/bnema/gordon/pkg/store"
19+ "github.com/bnema/gordon/pkg/verify"
1620 "github.com/charmbracelet/log"
1721 "github.com/labstack/echo/v4"
1822 )
1923
2024 // PostPush handles the image push request
2125 func PostPush(c echo.Context, a *server.App) error {
22-
2326 requestID := c.Response().Header().Get(echo.HeaderXRequestID)
24- log.Debug("PostPush started",
27+ log.Info("PostPush started",
2528 "requestID", requestID,
2629 "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+ }
2737
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+ })
3144 }
3245
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"))
3461 }
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 {
3866 return sendJSONResponse(c, http.StatusBadRequest, PushResponse{
3967 Success: false,
4068 Message: "Invalid chunk metadata",
4169 })
4270 }
4371
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
5377 }
5478
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)
5780 if err != nil {
81+ chunkStore.mu.Unlock()
5882 return sendJSONResponse(c, http.StatusInternalServerError, PushResponse{
5983 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",
7085 })
7186 }
7287
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()
9391
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)
10095 }
10196
10297 // For intermediate chunks, just acknowledge receipt
@@ -106,94 +101,167 @@ func handleChunkedPush(c echo.Context, a *server.App, chunkMetadataStr string) e
106101 })
107102 }
108103
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)
111110 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)
116112 }
117113
118- err = saveAndImportImage(c, a, payload)
114+ // Create temporary file with .tar extension
115+ tmpDir, err := os.MkdirTemp("", "docker-import-*")
119116 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)
124118 }
119+ defer os.RemoveAll(tmpDir)
125120
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)
128157 createContainerURL := generateCreateContainerURL(a, shortID)
129158
130159 return sendJSONResponse(c, http.StatusOK, PushResponse{
131160 Success: true,
132161 Message: "Image pushed and imported successfully",
133162 CreateContainerURL: createContainerURL,
163+ ImageID: imageID,
134164 })
135165 }
136166
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) {
138169 chunkStore.mu.Lock()
139- metadata := chunkStore.metadata[transferID]
140- chunks := chunkStore.chunks[transferID]
141- chunkStore.mu.Unlock()
170+ defer chunkStore.mu.Unlock()
142171
172+ metadata := chunkStore.metadata[transferID]
143173 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)
148203 }
149204
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++ {
153210 chunk, exists := chunks[i]
154211 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)
159217 }
160- combinedData.Write(chunk)
161218 }
162219
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+ }
165224
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)
174242 }
175243
176- imageID, err := docker.ImportImageToEngine(imagePath)
244+ // Verify the tar format
245+ tr := tar.NewReader(file)
246+ header, err := tr.Next()
177247 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)
183249 }
184250
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
187256 }
188257
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+}
192260
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,
198266 })
199267 }
@@ -2,101 +2,96 @@
2 package handlers2 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 request24 // 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.ChunkMetadata63+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 exist72+ // 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 mode79+ 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 image88+ // Store the chunk
74- if metadata.ChunkNumber == metadata.TotalChunks-1 {89+ chunkStore.chunks[metadata.TransferID][metadata.ChunkNumber] = chunkData
75- // Close the file before importing90+ 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 receipt97 // 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 chunks205+ // 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 early220+ // 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 image225+ // 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,
228228 }
229229
230230 if !isGoodUser {
231- return nil, echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized")
231+ return nil, echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized user")
232232 }
233233
234234 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 {
99 return func(c echo.Context) error {
1010 err := next(c)
1111 if err != nil {
12- log.Error("ErrorHandler caught error",
12+ log.Error("Caught http error",
1313 "path", c.Request().URL.Path,
1414 "method", c.Request().Method,
1515 "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 {
2626 return func(c echo.Context) error {
2727 token := strings.TrimPrefix(c.Request().Header.Get("Authorization"), "Bearer ")
2828 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",
3132 })
3233 }
3334
3435 // Check cache first
3536 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+ }
3645 c.Set("user", cachedUser)
3746 return next(c)
3847 }
3948
40- // Validate with GitHub only for new tokens
49+ // Validate with GitHub for new tokens
4150 githubUser, err := validateGitHubToken(c)
4251 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.",
4571 })
4672 }
4773
@@ -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 first35 // 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 tokens49+ // 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) {
6767 protectedApiGroup.POST("/deploy", func(c echo.Context) error {
6868 return handlers.PostDeploy(c, a)
6969 })
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
7077 protectedApiGroup.POST("/push", func(c echo.Context) error {
7178 return handlers.PostPush(c, a)
7279 })
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+ })
7385 protectedApiGroup.POST("/stop", func(c echo.Context) error {
7486 return handlers.PostContainerStop(c, a)
7587 })
@@ -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 (
88 "strings"
99 "time"
1010
11+ "github.com/charmbracelet/log"
1112 "github.com/docker/docker/api/types"
1213 "github.com/docker/docker/api/types/container"
1314 "github.com/docker/docker/api/types/network"
15+ "github.com/docker/docker/client"
1416 "github.com/docker/go-connections/nat"
1517 )
1618
@@ -179,7 +181,12 @@ func RemoveContainer(containerID string) error {
179181 // StartContainer starts a container
180182 func StartContainer(containerID string) error {
181183 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
183190 containerInfo, err := GetContainerInfo(containerID)
184191 if err != nil {
185192 return fmt.Errorf("could not get container info: %v", err)
@@ -189,45 +196,61 @@ func StartContainer(containerID string) error {
189196 return fmt.Errorf("container is already running")
190197 }
191198
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{})
194201 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+ }
195207 return fmt.Errorf("could not start container: %v", err)
196208 }
197209
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)
199228 }
200229
201230 // CreateContainer creates a container with the given parameters
202231 func CreateContainer(cmdParams ContainerCommandParams) (string, error) {
203- // Check if the Docker client has been initialized
204232 CheckIfInitialized()
205233
234+ log.Info("Creating container with params: %+v\n", cmdParams)
235+
236+ // Check network
206237 isNetworkCreated, err := CheckIfNetworkExists(cmdParams.Network)
207238 if err != nil {
208- return "", err
239+ return "", fmt.Errorf("network check failed: %v", err)
209240 }
210241
211242 if !isNetworkCreated {
243+ fmt.Printf("Creating network: %s\n", cmdParams.Network)
212244 _, err := dockerCli.NetworkCreate(context.Background(), cmdParams.Network, types.NetworkCreate{})
213245 if err != nil {
214- return "", err
246+ return "", fmt.Errorf("network creation failed: %v", err)
215247 }
216248 }
217249
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
228251 portBindings := nat.PortMap{}
252+ exposedPorts := nat.PortSet{}
229253 for _, portMapping := range cmdParams.PortMappings {
230- // Prepare exposed port
231254 exposedPort := nat.Port(portMapping.ContainerPort + "/" + portMapping.Protocol)
232255 portBindings[exposedPort] = []nat.PortBinding{
233256 {
@@ -235,9 +258,14 @@ func CreateContainer(cmdParams ContainerCommandParams) (string, error) {
235258 HostPort: portMapping.HostPort,
236259 },
237260 }
261+ exposedPorts[exposedPort] = struct{}{}
262+ fmt.Printf("Adding port mapping: %s:%s/%s\n",
263+ portMapping.HostPort,
264+ portMapping.ContainerPort,
265+ portMapping.Protocol)
238266 }
239267
240- // Prepare labels for Traefik
268+ // Prepare labels
241269 labels := map[string]string{}
242270 for _, label := range cmdParams.Labels {
243271 keyValue := strings.Split(label, "=")
@@ -246,16 +274,14 @@ func CreateContainer(cmdParams ContainerCommandParams) (string, error) {
246274 }
247275 }
248276
249- // Prepare environment variables
250- envVars := append([]string{}, cmdParams.Environment...)
251-
252- // Create container
277+ // Create container with platform specification
253278 resp, err := dockerCli.ContainerCreate(
254279 context.Background(),
255280 &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,
259285 },
260286 &container.HostConfig{
261287 PortBindings: portBindings,
@@ -273,12 +299,40 @@ func CreateContainer(cmdParams ContainerCommandParams) (string, error) {
273299 cmdParams.ContainerName,
274300 )
275301 if err != nil {
276- return "", err
302+ return "", fmt.Errorf("container creation failed: %v", err)
277303 }
278304
305+ log.Info("Container created successfully with ID: %s\n", resp.ID)
306+
279307 return resp.ID, nil
280308 }
281309
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+
282336 func CheckIfNetworkExists(networkName string) (bool, error) {
283337 networks, err := dockerCli.NetworkList(context.Background(), types.NetworkListOptions{})
284338 if err != nil {
@@ -323,8 +377,6 @@ func GetContainerName(containerID string) (string, error) {
323377 return "", err
324378 }
325379
326- // Debug
327- fmt.Printf("Container name: %s\n", containerInfo.Name)
328380 return containerInfo.Name, nil
329381 }
330382
@@ -415,3 +467,165 @@ func GetContainerIDByName(containerName string) string {
415467
416468 return ""
417469 }
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 container181 // 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 state184+
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 client199+ // 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 nil210+ // 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 parameters230 // 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 "", err239+ 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 "", err246+ return "", fmt.Errorf("network creation failed: %v", err)
215 }247 }
216 }248 }
217 249
218- // if the network exist return its id250+ // 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 Traefik268+ // 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 variables277+ // 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 "", err302+ 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, nil307 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 "", err377 return "", err
324 }378 }
325 379
326- // Debug
327- fmt.Printf("Container name: %s\n", containerInfo.Name)
328 return containerInfo.Name, nil380 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 (
66 "encoding/json"
77 "fmt"
88 "io"
9- "log"
109 "os"
1110 "regexp"
1211 "strings"
1312
13+ "github.com/bnema/gordon/pkg/verify"
14+ "github.com/charmbracelet/log"
1415 "github.com/docker/docker/api/types"
1516 "github.com/docker/docker/api/types/image"
1617 )
@@ -123,33 +124,52 @@ func createTagPattern(tag string) *regexp.Regexp {
123124
124125 // ImportImageToEngine imports an image to the Docker engine and returns the image ID
125126 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)
127128
128129 imageFile, err := os.Open(imageFilePath)
129130 if err != nil {
130- log.Printf("Failed to open image file: %v", err)
131131 return "", fmt.Errorf("failed to open image file: %w", err)
132132 }
133133 defer imageFile.Close()
134134
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+
135154 resp, err := dockerCli.ImageLoad(context.Background(), imageFile, true)
136155 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)
139157 }
140158 defer resp.Body.Close()
141159
142160 var imageName string
143161 scanner := bufio.NewScanner(resp.Body)
144162 for scanner.Scan() {
145- line := scanner.Text()
146-
147163 var message struct {
148164 Stream string `json:"stream"`
165+ Error string `json:"error"`
149166 }
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)
153173 }
154174
155175 if strings.HasPrefix(message.Stream, "Loaded image: ") {
@@ -159,47 +179,56 @@ func ImportImageToEngine(imageFilePath string) (string, error) {
159179 }
160180
161181 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)
164183 }
165184
166185 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")
169187 }
170188
171- // Now we need to get the image ID from the image name
172189 imageID, err := GetImageIDByName(imageName)
173190 if err != nil {
174- log.Printf("Failed to get image ID for name %s: %v", imageName, err)
175191 return "", fmt.Errorf("failed to get image ID: %w", err)
176192 }
177193
178- log.Printf("Successfully imported image with ID: %s", imageID)
194+ log.Info("Image imported successfully", "imageID", imageID)
179195 return imageID, nil
180196 }
181197
182198 // ExportImageFromEngine exports an image from the Docker engine and returns it as an io.Reader
183199 func ExportImageFromEngine(imageID string) (io.ReadCloser, error) {
184- // Check if the Docker client has been initialized
200+ log.Debug("Starting image export", "imageID", imageID)
185201
186202 // Get the image information using the Docker client
187203 imageInfo, err := GetImageInfo(imageID)
188204 if err != nil {
189- return nil, err
205+ return nil, fmt.Errorf("failed to get image info: %w", err)
190206 }
191207
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...)
194217 }
195218
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)
198222 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)
200224 }
201225
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
203232 }
204233
205234 // 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 ID125 // 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 string160 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 JSON169+ }
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, nil195 return imageID, nil
180 }196 }
181 197
182 // ExportImageFromEngine exports an image from the Docker engine and returns it as an io.Reader198 // 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 initialized200+ log.Debug("Starting image export", "imageID", imageID)
185 201
186 // Get the image information using the Docker client202 // 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, err205+ 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 client219+ 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, nil226+ // 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 image234 // 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+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close