detached 7c32952

feat: Implement chunked file transfer for push and deploy commands

- Add streaming file upload support for large Docker images
- Implement context-based timeout handling for transfers
- Splitted cli_endpoints.go into push/deploy/helpers logic
- Add progress tracking and size validation for uploads
- Add token caching system to avoid SQLite lockout
- Improve error handling in deployment process
- Enhance logging with structured logs and request IDs
- Update golangci.yml config
commit
7c32952d5211cecde61b814c1045e65f9e8e9f89
author
Brice · · 2024-11-14 22:28 UTC
committer
Brice · · 2024-11-14 21:52 UTC
parent
edc2edf
signature
unsigned
20 files · +1643 -621
A .golangci.yml +38 -0
@@ -0,0 +1,38 @@
1+run:
2+ timeout: 5m
3+ tests: true
4+
5+linters:
6+ enable:
7+ - gofmt
8+ - govet
9+ - errcheck
10+ - staticcheck
11+ - unused
12+ - gosimple
13+ - ineffassign
14+ - typecheck
15+ - misspell
16+ - gosec
17+ - unconvert
18+ - deadcode
19+ - gocyclo
20+
21+linters-settings:
22+ gocyclo:
23+ min-complexity: 15
24+
25+issues:
26+ exclude-rules:
27+ - path: _test\.go
28+ linters:
29+ - gocyclo
30+ - errcheck
31+ - dupl
32+ - gosec
33+
34+ max-same-issues: 50
35+
36+severity:
37+ default-severity: error
38+ case-sensitive: false
@@ -0,0 +1,38 @@
1+run:
2+ timeout: 5m
3+ tests: true
4+
5+linters:
6+ enable:
7+ - gofmt
8+ - govet
9+ - errcheck
10+ - staticcheck
11+ - unused
12+ - gosimple
13+ - ineffassign
14+ - typecheck
15+ - misspell
16+ - gosec
17+ - unconvert
18+ - deadcode
19+ - gocyclo
20+
21+linters-settings:
22+ gocyclo:
23+ min-complexity: 15
24+
25+issues:
26+ exclude-rules:
27+ - path: _test\.go
28+ linters:
29+ - gocyclo
30+ - errcheck
31+ - dupl
32+ - gosec
33+
34+ max-same-issues: 50
35+
36+severity:
37+ default-severity: error
38+ case-sensitive: false
D golangci.yml +0 -27
@@ -1,27 +0,0 @@
1-run:
2- go: 1.23
3- concurrency: 4
4- timeout: 10m
5-
6-linters:
7- disable-all: true
8- enable:
9- - asciicheck
10- - depguard
11- - exportloopref
12- - gofmt
13- - goimports
14- - gomodguard
15- - goprintffuncname
16- - gosimple
17- - govet
18- - ineffassign
19- - misspell
20- - nakedret
21- - nolintlint
22- - prealloc
23- - staticcheck
24- - typecheck
25- - unconvert
26- - unused
27- - whitespace
@@ -1,27 +0,0 @@
1-run:
2- go: 1.23
3- concurrency: 4
4- timeout: 10m
5-
6-linters:
7- disable-all: true
8- enable:
9- - asciicheck
10- - depguard
11- - exportloopref
12- - gofmt
13- - goimports
14- - gomodguard
15- - goprintffuncname
16- - gosimple
17- - govet
18- - ineffassign
19- - misspell
20- - nakedret
21- - nolintlint
22- - prealloc
23- - staticcheck
24- - typecheck
25- - unconvert
26- - unused
27- - whitespace
A internal/cli/auth/token_cache.go +87 -0
@@ -0,0 +1,87 @@
1+package auth
2+
3+import (
4+ "sync"
5+ "time"
6+
7+ "github.com/bnema/gordon/internal/db/queries"
8+ "github.com/charmbracelet/log"
9+)
10+
11+type TokenCache struct {
12+ cache sync.Map
13+}
14+
15+type cacheEntry struct {
16+ token string
17+ expiration time.Time
18+ githubUser *queries.GithubUserInfo
19+}
20+
21+var (
22+ defaultCache *TokenCache
23+ once sync.Once
24+)
25+
26+// GetTokenCache returns a singleton instance of TokenCache
27+func GetTokenCache() *TokenCache {
28+ once.Do(func() {
29+ defaultCache = &TokenCache{}
30+ log.Debug("initialized token cache singleton")
31+ })
32+ return defaultCache
33+}
34+
35+// GetWithUser retrieves a token and GitHub user info from the cache
36+func (tc *TokenCache) GetWithUser(key string) (string, *queries.GithubUserInfo, bool) {
37+ if value, ok := tc.cache.Load(key); ok {
38+ entry := value.(cacheEntry)
39+ if time.Now().Before(entry.expiration) {
40+ log.Debug("cache hit", "key", key, "expires_in", time.Until(entry.expiration))
41+ return entry.token, entry.githubUser, true
42+ }
43+ log.Debug("cache entry expired", "key", key)
44+ tc.cache.Delete(key)
45+ }
46+ log.Debug("cache miss", "key", key)
47+ return "", nil, false
48+}
49+
50+// Get retrieves just the token validation status
51+func (tc *TokenCache) Get(key string) (bool, bool) {
52+ if value, ok := tc.cache.Load(key); ok {
53+ entry := value.(cacheEntry)
54+ if time.Now().Before(entry.expiration) {
55+ log.Debug("cache hit (validation status)", "key", key, "expires_in", time.Until(entry.expiration))
56+ return true, true
57+ }
58+ log.Debug("cache entry expired (validation status)", "key", key)
59+ tc.cache.Delete(key)
60+ }
61+ log.Debug("cache miss (validation status)", "key", key)
62+ return false, false
63+}
64+
65+// SetWithUser stores a token and GitHub user info in the cache
66+func (tc *TokenCache) SetWithUser(key string, token string, user *queries.GithubUserInfo, duration time.Duration) {
67+ tc.cache.Store(key, cacheEntry{
68+ token: token,
69+ githubUser: user,
70+ expiration: time.Now().Add(duration),
71+ })
72+ log.Debug("stored token with user info in cache",
73+ "key", key,
74+ "duration", duration,
75+ "github_user", user.Login)
76+}
77+
78+// Set stores just the token validation status
79+func (tc *TokenCache) Set(key string, duration time.Duration) {
80+ tc.cache.Store(key, cacheEntry{
81+ token: key,
82+ expiration: time.Now().Add(duration),
83+ })
84+ log.Debug("stored token validation status in cache",
85+ "key", key,
86+ "duration", duration)
87+}
@@ -0,0 +1,87 @@
1+package auth
2+
3+import (
4+ "sync"
5+ "time"
6+
7+ "github.com/bnema/gordon/internal/db/queries"
8+ "github.com/charmbracelet/log"
9+)
10+
11+type TokenCache struct {
12+ cache sync.Map
13+}
14+
15+type cacheEntry struct {
16+ token string
17+ expiration time.Time
18+ githubUser *queries.GithubUserInfo
19+}
20+
21+var (
22+ defaultCache *TokenCache
23+ once sync.Once
24+)
25+
26+// GetTokenCache returns a singleton instance of TokenCache
27+func GetTokenCache() *TokenCache {
28+ once.Do(func() {
29+ defaultCache = &TokenCache{}
30+ log.Debug("initialized token cache singleton")
31+ })
32+ return defaultCache
33+}
34+
35+// GetWithUser retrieves a token and GitHub user info from the cache
36+func (tc *TokenCache) GetWithUser(key string) (string, *queries.GithubUserInfo, bool) {
37+ if value, ok := tc.cache.Load(key); ok {
38+ entry := value.(cacheEntry)
39+ if time.Now().Before(entry.expiration) {
40+ log.Debug("cache hit", "key", key, "expires_in", time.Until(entry.expiration))
41+ return entry.token, entry.githubUser, true
42+ }
43+ log.Debug("cache entry expired", "key", key)
44+ tc.cache.Delete(key)
45+ }
46+ log.Debug("cache miss", "key", key)
47+ return "", nil, false
48+}
49+
50+// Get retrieves just the token validation status
51+func (tc *TokenCache) Get(key string) (bool, bool) {
52+ if value, ok := tc.cache.Load(key); ok {
53+ entry := value.(cacheEntry)
54+ if time.Now().Before(entry.expiration) {
55+ log.Debug("cache hit (validation status)", "key", key, "expires_in", time.Until(entry.expiration))
56+ return true, true
57+ }
58+ log.Debug("cache entry expired (validation status)", "key", key)
59+ tc.cache.Delete(key)
60+ }
61+ log.Debug("cache miss (validation status)", "key", key)
62+ return false, false
63+}
64+
65+// SetWithUser stores a token and GitHub user info in the cache
66+func (tc *TokenCache) SetWithUser(key string, token string, user *queries.GithubUserInfo, duration time.Duration) {
67+ tc.cache.Store(key, cacheEntry{
68+ token: token,
69+ githubUser: user,
70+ expiration: time.Now().Add(duration),
71+ })
72+ log.Debug("stored token with user info in cache",
73+ "key", key,
74+ "duration", duration,
75+ "github_user", user.Login)
76+}
77+
78+// Set stores just the token validation status
79+func (tc *TokenCache) Set(key string, duration time.Duration) {
80+ tc.cache.Store(key, cacheEntry{
81+ token: key,
82+ expiration: time.Now().Add(duration),
83+ })
84+ log.Debug("stored token validation status in cache",
85+ "key", key,
86+ "duration", duration)
87+}
A internal/cli/auth/token_check.go +64 -0
@@ -0,0 +1,64 @@
1+package auth
2+
3+import (
4+ "net/http"
5+ "strings"
6+ "time"
7+
8+ "github.com/charmbracelet/log"
9+)
10+
11+// IsValidToken validates a GitHub token
12+func IsValidGitHubToken(token string) bool {
13+ // Use the existing token cache
14+ tokenCache := GetTokenCache()
15+
16+ // Check if token exists in cache
17+ if _, _, found := tokenCache.GetWithUser(token); found {
18+ log.Debug("token found in cache")
19+ return true
20+ }
21+
22+ // If not in cache, validate with GitHub
23+ client := &http.Client{Timeout: 10 * time.Second}
24+ req, err := http.NewRequest("GET", "https://api.github.com/user", nil)
25+ if err != nil {
26+ log.Error("failed to create GitHub API request", "error", err)
27+ return false
28+ }
29+
30+ req.Header.Set("Authorization", "token "+token)
31+ req.Header.Set("Accept", "application/vnd.github.v3+json")
32+
33+ resp, err := client.Do(req)
34+ if err != nil {
35+ log.Error("GitHub API request failed", "error", err)
36+ return false
37+ }
38+ defer resp.Body.Close()
39+
40+ if resp.StatusCode != http.StatusOK {
41+ log.Warn("invalid GitHub token", "status", resp.Status)
42+ return false
43+ }
44+
45+ return true
46+}
47+
48+// ExtractAndValidateToken extracts and validates a token from an Authorization header
49+func ExtractAndValidateToken(authHeader string) (string, bool) {
50+ if authHeader == "" {
51+ return "", false
52+ }
53+
54+ token := strings.TrimPrefix(authHeader, "Bearer ")
55+ if token == "" {
56+ return "", false
57+ }
58+
59+ if !IsValidGitHubToken(token) {
60+ return "", false
61+ }
62+
63+ return token, true
64+}
@@ -0,0 +1,64 @@
1+package auth
2+
3+import (
4+ "net/http"
5+ "strings"
6+ "time"
7+
8+ "github.com/charmbracelet/log"
9+)
10+
11+// IsValidToken validates a GitHub token
12+func IsValidGitHubToken(token string) bool {
13+ // Use the existing token cache
14+ tokenCache := GetTokenCache()
15+
16+ // Check if token exists in cache
17+ if _, _, found := tokenCache.GetWithUser(token); found {
18+ log.Debug("token found in cache")
19+ return true
20+ }
21+
22+ // If not in cache, validate with GitHub
23+ client := &http.Client{Timeout: 10 * time.Second}
24+ req, err := http.NewRequest("GET", "https://api.github.com/user", nil)
25+ if err != nil {
26+ log.Error("failed to create GitHub API request", "error", err)
27+ return false
28+ }
29+
30+ req.Header.Set("Authorization", "token "+token)
31+ req.Header.Set("Accept", "application/vnd.github.v3+json")
32+
33+ resp, err := client.Do(req)
34+ if err != nil {
35+ log.Error("GitHub API request failed", "error", err)
36+ return false
37+ }
38+ defer resp.Body.Close()
39+
40+ if resp.StatusCode != http.StatusOK {
41+ log.Warn("invalid GitHub token", "status", resp.Status)
42+ return false
43+ }
44+
45+ return true
46+}
47+
48+// ExtractAndValidateToken extracts and validates a token from an Authorization header
49+func ExtractAndValidateToken(authHeader string) (string, bool) {
50+ if authHeader == "" {
51+ return "", false
52+ }
53+
54+ token := strings.TrimPrefix(authHeader, "Bearer ")
55+ if token == "" {
56+ return "", false
57+ }
58+
59+ if !IsValidGitHubToken(token) {
60+ return "", false
61+ }
62+
63+ return token, true
64+}
M internal/cli/cmd/deploy.go +92 -84
@@ -4,6 +4,7 @@ package cmd
44
55 import (
66 "bytes"
7+ "context"
78 "encoding/json"
89 "errors"
910 "fmt"
@@ -16,7 +17,6 @@ import (
1617 "github.com/bnema/gordon/internal/cli"
1718 "github.com/bnema/gordon/internal/cli/handler"
1819 "github.com/bnema/gordon/internal/common"
19- "github.com/bnema/gordon/pkg/docker"
2020 "github.com/charmbracelet/log"
2121 "github.com/spf13/cobra"
2222 )
@@ -49,7 +49,7 @@ func NewDeployCommand(a *cli.App) *cobra.Command {
4949 return
5050 }
5151
52- reader, actualSize, err := exportDockerImage(imageName)
52+ reader, actualSize, err := handler.ExportDockerImage(imageName)
5353 if err != nil {
5454 log.Error("Error exporting image", "error", err)
5555 return
@@ -72,123 +72,95 @@ func NewDeployCommand(a *cli.App) *cobra.Command {
7272 return deployCmd
7373 }
7474
75-func validateInputs(imageName, port, targetDomain string) error {
76- if err := handler.ValidateImageName(imageName); err != nil {
77- return err
78- }
79-
80- handler.EnsureImageTag(&imageName)
81-
82- if port == "" {
83- return fmt.Errorf("port is required")
84- }
85-
86- return handler.ValidateTargetDomain(targetDomain)
87-}
88-
89-func exportDockerImage(imageName string) (io.ReadCloser, int64, error) {
90- exists, err := docker.CheckIfImageExists(imageName)
91- if err != nil {
92- return nil, 0, fmt.Errorf("error checking image existence: %w", err)
93- }
94-
95- var imageID string
96- if exists {
97- imageID = imageName
98- } else {
99- imageID, err = docker.GetImageIDByName(imageName)
100- if err != nil {
101- return nil, 0, fmt.Errorf("error searching for image by name: %w", err)
102- }
103- }
104-
105- actualSize, err := docker.GetImageSizeFromReader(imageID)
106- if err != nil {
107- return nil, 0, fmt.Errorf("error retrieving image size: %w", err)
108- }
109-
110- reader, err := docker.ExportImageFromEngine(imageID)
111- if err != nil {
112- return nil, 0, fmt.Errorf("error exporting image: %w", err)
113- }
114-
115- return reader, actualSize, nil
116-}
75+// Client side - deploy.go
11776
11877 func deployImage(a *cli.App, reader io.Reader, imageName, port, targetDomain string) error {
119- // Convert reader to bytes for reuse
120- imageData, err := io.ReadAll(reader)
78+ // Create a buffer to store the entire image data
79+ var buf bytes.Buffer
80+ size, err := io.Copy(&buf, reader)
12181 if err != nil {
12282 return fmt.Errorf("failed to read image data: %w", err)
12383 }
12484
85+ // Create a new reader from the buffer
86+ imageReader := bytes.NewReader(buf.Bytes())
87+
88+ sizeInMB := float64(size) / 1024 / 1024
12589 log.Info("Attempting to deploy...",
12690 "image", imageName,
91+ "size", fmt.Sprintf("%.2fMB", sizeInMB),
12792 "port", port,
12893 "target", targetDomain,
12994 )
13095
96+ headers := http.Header{
97+ "X-Image-Name": {imageName},
98+ "X-Ports": {port},
99+ "X-Target-Domain": {targetDomain},
100+ "Content-Type": {"application/octet-stream"},
101+ }
102+
103+ chunkedClient := handler.NewChunkedClient(a)
104+ var finalResponse *common.DeployResponse
105+
131106 for {
132- // Create new reader from bytes for each attempt
133- reqPayload := common.RequestPayload{
134- Type: "deploy",
135- Payload: common.DeployPayload{
136- Port: port,
137- TargetDomain: targetDomain,
138- ImageName: imageName,
139- Data: io.NopCloser(bytes.NewReader(imageData)),
140- },
141- }
107+ ctx, cancel := context.WithTimeout(context.Background(), 60*time.Minute)
108+ defer cancel()
142109
143- resp, err := handler.SendHTTPRequest(a, &reqPayload, "POST", "/deploy")
110+ resp, err := chunkedClient.SendFile(ctx, "/deploy", headers, imageReader, size, imageName)
144111 if err != nil {
145112 var deployErr *common.DeploymentError
146- if errors.As(err, &deployErr) {
113+ if errors.As(err, &deployErr) && deployErr.StatusCode == http.StatusConflict {
147114 var deployResponse common.DeployResponse
148115 if jsonErr := json.Unmarshal([]byte(deployErr.RawResponse), &deployResponse); jsonErr == nil {
149- if deployErr.StatusCode == http.StatusConflict && deployResponse.ContainerID != "" {
116+ if deployResponse.ContainerID != "" {
150117 if err := handler.HandleExistingContainer(a, &deployResponse); err != nil {
151118 if strings.Contains(err.Error(), "cancelled by user") {
152- log.Warn("Deployment cancelled by user")
153- return nil
119+ return fmt.Errorf("deployment cancelled by user")
154120 }
155- log.Error("Failed to handle existing container", "error", err)
156- return err
121+ return fmt.Errorf("failed to handle existing container: %w", err)
157122 }
158- log.Info("Retrying deployment...")
123+ // Reset the reader for retry
124+ imageReader.Seek(0, 0)
159125 continue // Retry deployment
160126 }
161127 }
162128 }
163- return err
164- }
165-
166- var deployResponse common.DeployResponse
167- if err := json.Unmarshal(resp.Body, &deployResponse); err != nil {
168- log.Error("Error parsing response", "error", err, "response", string(resp.Body))
169- return nil
129+ return fmt.Errorf("deployment failed: %w", err)
170130 }
171131
172- if !deployResponse.Success {
173- log.Error("Deployment failed",
174- "message", deployResponse.Message,
175- "container_id", deployResponse.ContainerID,
176- )
177- return nil
132+ // Parse the final response
133+ if resp != nil {
134+ var deployResp common.DeployResponse
135+ if err := json.Unmarshal(resp.Body, &deployResp); err != nil {
136+ return fmt.Errorf("failed to parse deployment response: %w", err)
137+ }
138+ finalResponse = &deployResp
178139 }
140+ break // Successful deployment
141+ }
179142
180- return waitForDeployment(deployResponse.Domain, deployResponse.ContainerID)
143+ // Use the response data for waiting
144+ if finalResponse != nil {
145+ return waitForDeployment(finalResponse.Domain, finalResponse.ContainerID)
181146 }
147+ return fmt.Errorf("no response received from deployment")
182148 }
183149
184150 func waitForDeployment(domain string, containerID string) error {
185151 client := &http.Client{Timeout: 10 * time.Second}
186152 maxRetries := 20
187153 retryInterval := time.Second
188- // shorten the container id to 12 characters
189- shortContainerID := containerID[:12]
190154
191- log.Info("Waiting for deployment to be reachable")
155+ var shortContainerID string
156+ if containerID != "" {
157+ shortContainerID = containerID[:12]
158+ }
159+
160+ log.Info("Waiting for deployment to be reachable",
161+ "domain", domain,
162+ "container_id", shortContainerID)
163+
192164 for i := 0; i < maxRetries; i++ {
193165 resp, err := client.Get(domain)
194166 if err == nil {
@@ -199,15 +171,51 @@ func waitForDeployment(domain string, containerID string) error {
199171 "container_id", shortContainerID)
200172 return nil
201173 }
202- // Check for error messages in the response body
203174 body, _ := io.ReadAll(resp.Body)
204175 if strings.Contains(string(body), "failed to create container:") {
205176 return fmt.Errorf("deployment failed: %s", string(body))
206177 }
207178 }
208- log.Warn("Deployment not ready yet, retrying", "attempt", fmt.Sprintf("%d/%d", i+1, maxRetries))
179+ log.Warn("Deployment not ready yet, retrying",
180+ "attempt", fmt.Sprintf("%d/%d", i+1, maxRetries))
209181 time.Sleep(retryInterval)
210182 }
211183
212- return fmt.Errorf("deployment not ready after %d attempts, giving up", maxRetries)
184+ return fmt.Errorf("deployment not ready after %d attempts", maxRetries)
185+}
186+
187+func validateInputs(imageName, port, targetDomain string) error {
188+ if err := handler.ValidateImageName(imageName); err != nil {
189+ return err
190+ }
191+
192+ handler.EnsureImageTag(&imageName)
193+
194+ if port == "" {
195+ return fmt.Errorf("port is required")
196+ }
197+
198+ return handler.ValidateTargetDomain(targetDomain)
199+}
200+
201+func handleDeployError(a *cli.App, err error, imageName, port, targetDomain string, reader io.Reader) error {
202+ var deployErr *common.DeploymentError
203+ if errors.As(err, &deployErr) {
204+ var deployResponse common.DeployResponse
205+ if jsonErr := json.Unmarshal([]byte(deployErr.RawResponse), &deployResponse); jsonErr == nil {
206+ if deployErr.StatusCode == http.StatusConflict && deployResponse.ContainerID != "" {
207+ if err := handler.HandleExistingContainer(a, &deployResponse); err != nil {
208+ if strings.Contains(err.Error(), "cancelled by user") {
209+ log.Warn("Deployment cancelled by user")
210+ return nil
211+ }
212+ log.Error("Failed to handle existing container", "error", err)
213+ return err
214+ }
215+ // Retry deployment
216+ return deployImage(a, reader, imageName, port, targetDomain)
217+ }
218+ }
219+ }
220+ return err
213221 }
@@ -4,6 +4,7 @@ package cmd
4 4
5 import (5 import (
6 "bytes"6 "bytes"
7+ "context"
7 "encoding/json"8 "encoding/json"
8 "errors"9 "errors"
9 "fmt"10 "fmt"
@@ -16,7 +17,6 @@ import (
16 "github.com/bnema/gordon/internal/cli"17 "github.com/bnema/gordon/internal/cli"
17 "github.com/bnema/gordon/internal/cli/handler"18 "github.com/bnema/gordon/internal/cli/handler"
18 "github.com/bnema/gordon/internal/common"19 "github.com/bnema/gordon/internal/common"
19- "github.com/bnema/gordon/pkg/docker"
20 "github.com/charmbracelet/log"20 "github.com/charmbracelet/log"
21 "github.com/spf13/cobra"21 "github.com/spf13/cobra"
22 )22 )
@@ -49,7 +49,7 @@ func NewDeployCommand(a *cli.App) *cobra.Command {
49 return49 return
50 }50 }
51 51
52- reader, actualSize, err := exportDockerImage(imageName)52+ reader, actualSize, err := handler.ExportDockerImage(imageName)
53 if err != nil {53 if err != nil {
54 log.Error("Error exporting image", "error", err)54 log.Error("Error exporting image", "error", err)
55 return55 return
@@ -72,123 +72,95 @@ func NewDeployCommand(a *cli.App) *cobra.Command {
72 return deployCmd72 return deployCmd
73 }73 }
74 74
75-func validateInputs(imageName, port, targetDomain string) error {75+// Client side - deploy.go
76- if err := handler.ValidateImageName(imageName); err != nil {
77- return err
78- }
79-
80- handler.EnsureImageTag(&imageName)
81-
82- if port == "" {
83- return fmt.Errorf("port is required")
84- }
85-
86- return handler.ValidateTargetDomain(targetDomain)
87-}
88-
89-func exportDockerImage(imageName string) (io.ReadCloser, int64, error) {
90- exists, err := docker.CheckIfImageExists(imageName)
91- if err != nil {
92- return nil, 0, fmt.Errorf("error checking image existence: %w", err)
93- }
94-
95- var imageID string
96- if exists {
97- imageID = imageName
98- } else {
99- imageID, err = docker.GetImageIDByName(imageName)
100- if err != nil {
101- return nil, 0, fmt.Errorf("error searching for image by name: %w", err)
102- }
103- }
104-
105- actualSize, err := docker.GetImageSizeFromReader(imageID)
106- if err != nil {
107- return nil, 0, fmt.Errorf("error retrieving image size: %w", err)
108- }
109-
110- reader, err := docker.ExportImageFromEngine(imageID)
111- if err != nil {
112- return nil, 0, fmt.Errorf("error exporting image: %w", err)
113- }
114-
115- return reader, actualSize, nil
116-}
117 76
118 func deployImage(a *cli.App, reader io.Reader, imageName, port, targetDomain string) error {77 func deployImage(a *cli.App, reader io.Reader, imageName, port, targetDomain string) error {
119- // Convert reader to bytes for reuse78+ // Create a buffer to store the entire image data
120- imageData, err := io.ReadAll(reader)79+ var buf bytes.Buffer
80+ size, err := io.Copy(&buf, reader)
121 if err != nil {81 if err != nil {
122 return fmt.Errorf("failed to read image data: %w", err)82 return fmt.Errorf("failed to read image data: %w", err)
123 }83 }
124 84
85+ // Create a new reader from the buffer
86+ imageReader := bytes.NewReader(buf.Bytes())
87+
88+ sizeInMB := float64(size) / 1024 / 1024
125 log.Info("Attempting to deploy...",89 log.Info("Attempting to deploy...",
126 "image", imageName,90 "image", imageName,
91+ "size", fmt.Sprintf("%.2fMB", sizeInMB),
127 "port", port,92 "port", port,
128 "target", targetDomain,93 "target", targetDomain,
129 )94 )
130 95
96+ headers := http.Header{
97+ "X-Image-Name": {imageName},
98+ "X-Ports": {port},
99+ "X-Target-Domain": {targetDomain},
100+ "Content-Type": {"application/octet-stream"},
101+ }
102+
103+ chunkedClient := handler.NewChunkedClient(a)
104+ var finalResponse *common.DeployResponse
105+
131 for {106 for {
132- // Create new reader from bytes for each attempt107+ ctx, cancel := context.WithTimeout(context.Background(), 60*time.Minute)
133- reqPayload := common.RequestPayload{108+ defer cancel()
134- Type: "deploy",
135- Payload: common.DeployPayload{
136- Port: port,
137- TargetDomain: targetDomain,
138- ImageName: imageName,
139- Data: io.NopCloser(bytes.NewReader(imageData)),
140- },
141- }
142 109
143- resp, err := handler.SendHTTPRequest(a, &reqPayload, "POST", "/deploy")110+ resp, err := chunkedClient.SendFile(ctx, "/deploy", headers, imageReader, size, imageName)
144 if err != nil {111 if err != nil {
145 var deployErr *common.DeploymentError112 var deployErr *common.DeploymentError
146- if errors.As(err, &deployErr) {113+ if errors.As(err, &deployErr) && deployErr.StatusCode == http.StatusConflict {
147 var deployResponse common.DeployResponse114 var deployResponse common.DeployResponse
148 if jsonErr := json.Unmarshal([]byte(deployErr.RawResponse), &deployResponse); jsonErr == nil {115 if jsonErr := json.Unmarshal([]byte(deployErr.RawResponse), &deployResponse); jsonErr == nil {
149- if deployErr.StatusCode == http.StatusConflict && deployResponse.ContainerID != "" {116+ if deployResponse.ContainerID != "" {
150 if err := handler.HandleExistingContainer(a, &deployResponse); err != nil {117 if err := handler.HandleExistingContainer(a, &deployResponse); err != nil {
151 if strings.Contains(err.Error(), "cancelled by user") {118 if strings.Contains(err.Error(), "cancelled by user") {
152- log.Warn("Deployment cancelled by user")119+ return fmt.Errorf("deployment cancelled by user")
153- return nil
154 }120 }
155- log.Error("Failed to handle existing container", "error", err)121+ return fmt.Errorf("failed to handle existing container: %w", err)
156- return err
157 }122 }
158- log.Info("Retrying deployment...")123+ // Reset the reader for retry
124+ imageReader.Seek(0, 0)
159 continue // Retry deployment125 continue // Retry deployment
160 }126 }
161 }127 }
162 }128 }
163- return err129+ return fmt.Errorf("deployment failed: %w", err)
164- }
165-
166- var deployResponse common.DeployResponse
167- if err := json.Unmarshal(resp.Body, &deployResponse); err != nil {
168- log.Error("Error parsing response", "error", err, "response", string(resp.Body))
169- return nil
170 }130 }
171 131
172- if !deployResponse.Success {132+ // Parse the final response
173- log.Error("Deployment failed",133+ if resp != nil {
174- "message", deployResponse.Message,134+ var deployResp common.DeployResponse
175- "container_id", deployResponse.ContainerID,135+ if err := json.Unmarshal(resp.Body, &deployResp); err != nil {
176- )136+ return fmt.Errorf("failed to parse deployment response: %w", err)
177- return nil137+ }
138+ finalResponse = &deployResp
178 }139 }
140+ break // Successful deployment
141+ }
179 142
180- return waitForDeployment(deployResponse.Domain, deployResponse.ContainerID)143+ // Use the response data for waiting
144+ if finalResponse != nil {
145+ return waitForDeployment(finalResponse.Domain, finalResponse.ContainerID)
181 }146 }
147+ return fmt.Errorf("no response received from deployment")
182 }148 }
183 149
184 func waitForDeployment(domain string, containerID string) error {150 func waitForDeployment(domain string, containerID string) error {
185 client := &http.Client{Timeout: 10 * time.Second}151 client := &http.Client{Timeout: 10 * time.Second}
186 maxRetries := 20152 maxRetries := 20
187 retryInterval := time.Second153 retryInterval := time.Second
188- // shorten the container id to 12 characters
189- shortContainerID := containerID[:12]
190 154
191- log.Info("Waiting for deployment to be reachable")155+ var shortContainerID string
156+ if containerID != "" {
157+ shortContainerID = containerID[:12]
158+ }
159+
160+ log.Info("Waiting for deployment to be reachable",
161+ "domain", domain,
162+ "container_id", shortContainerID)
163+
192 for i := 0; i < maxRetries; i++ {164 for i := 0; i < maxRetries; i++ {
193 resp, err := client.Get(domain)165 resp, err := client.Get(domain)
194 if err == nil {166 if err == nil {
@@ -199,15 +171,51 @@ func waitForDeployment(domain string, containerID string) error {
199 "container_id", shortContainerID)171 "container_id", shortContainerID)
200 return nil172 return nil
201 }173 }
202- // Check for error messages in the response body
203 body, _ := io.ReadAll(resp.Body)174 body, _ := io.ReadAll(resp.Body)
204 if strings.Contains(string(body), "failed to create container:") {175 if strings.Contains(string(body), "failed to create container:") {
205 return fmt.Errorf("deployment failed: %s", string(body))176 return fmt.Errorf("deployment failed: %s", string(body))
206 }177 }
207 }178 }
208- log.Warn("Deployment not ready yet, retrying", "attempt", fmt.Sprintf("%d/%d", i+1, maxRetries))179+ log.Warn("Deployment not ready yet, retrying",
180+ "attempt", fmt.Sprintf("%d/%d", i+1, maxRetries))
209 time.Sleep(retryInterval)181 time.Sleep(retryInterval)
210 }182 }
211 183
212- return fmt.Errorf("deployment not ready after %d attempts, giving up", maxRetries)184+ return fmt.Errorf("deployment not ready after %d attempts", maxRetries)
185+}
186+
187+func validateInputs(imageName, port, targetDomain string) error {
188+ if err := handler.ValidateImageName(imageName); err != nil {
189+ return err
190+ }
191+
192+ handler.EnsureImageTag(&imageName)
193+
194+ if port == "" {
195+ return fmt.Errorf("port is required")
196+ }
197+
198+ return handler.ValidateTargetDomain(targetDomain)
199+}
200+
201+func handleDeployError(a *cli.App, err error, imageName, port, targetDomain string, reader io.Reader) error {
202+ var deployErr *common.DeploymentError
203+ if errors.As(err, &deployErr) {
204+ var deployResponse common.DeployResponse
205+ if jsonErr := json.Unmarshal([]byte(deployErr.RawResponse), &deployResponse); jsonErr == nil {
206+ if deployErr.StatusCode == http.StatusConflict && deployResponse.ContainerID != "" {
207+ if err := handler.HandleExistingContainer(a, &deployResponse); err != nil {
208+ if strings.Contains(err.Error(), "cancelled by user") {
209+ log.Warn("Deployment cancelled by user")
210+ return nil
211+ }
212+ log.Error("Failed to handle existing container", "error", err)
213+ return err
214+ }
215+ // Retry deployment
216+ return deployImage(a, reader, imageName, port, targetDomain)
217+ }
218+ }
219+ }
220+ return err
213 }221 }
M internal/cli/cmd/push.go +23 -31
@@ -1,10 +1,12 @@
11 package cmd
22
33 import (
4- "encoding/json"
4+ "context"
5+ "errors"
56 "fmt"
7+ "net/http"
68 "os"
7- "strings"
9+ "time"
810
911 "github.com/bnema/gordon/internal/cli"
1012 "github.com/bnema/gordon/internal/cli/handler"
@@ -54,49 +56,39 @@ func pushImage(a *cli.App, imageName string) error {
5456
5557 handler.EnsureImageTag(&imageName)
5658
57- reader, actualSize, err := exportDockerImage(imageName)
59+ reader, actualSize, err := handler.ExportDockerImage(imageName)
5860 if err != nil {
5961 return fmt.Errorf("error exporting image: %w", err)
6062 }
6163 defer reader.Close()
6264
6365 sizeInMB := float64(actualSize) / 1024 / 1024
66+ log.Info("Image exported successfully",
67+ "image", imageName,
68+ "size", fmt.Sprintf("%.2fMB", sizeInMB))
6469
65- log.Info("Image exported successfully", "image", imageName, "size", fmt.Sprintf("%.2fMB", sizeInMB))
66-
67- reqPayload := common.RequestPayload{
68- Type: "push",
69- Payload: common.PushPayload{
70- ImageName: imageName,
71- Data: reader,
72- },
70+ headers := http.Header{
71+ "X-Image-Name": {imageName},
72+ "Content-Type": {"application/octet-stream"},
7373 }
7474
75- resp, err := handler.SendHTTPRequest(a, &reqPayload, "POST", "/push")
76- if err != nil {
77- log.Error("Error sending HTTP request", "error", err)
78- return err
79- }
75+ chunkedClient := handler.NewChunkedClient(a)
8076
81- var pushResponse common.PushResponse
82- if err := json.Unmarshal(resp.Body, &pushResponse); err != nil {
83- log.Error("Error parsing response", "error", err)
84- return err
85- }
77+ ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
78+ defer cancel()
8679
87- if !pushResponse.Success {
88- log.Error("Push failed", "resp", pushResponse.Message)
89- return fmt.Errorf(pushResponse.Message)
80+ resp, 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)
85+ }
86+ return fmt.Errorf("chunked transfer failed: %w", err)
9087 }
9188
92- //Remove the double quotes from the message
93- message := strings.Trim(pushResponse.Message, "\"")
89+ fmt.Println("Push response:", resp)
9490
95- log.Info(message)
96-
97- if pushResponse.CreateContainerURL != "" {
98- log.Info("Container creation available", "url", pushResponse.CreateContainerURL)
99- }
91+ log.Info("Image pushed and imported successfully", "image", imageName)
10092
10193 return nil
10294 }
@@ -1,10 +1,12 @@
1 package cmd1 package cmd
2 2
3 import (3 import (
4- "encoding/json"4+ "context"
5+ "errors"
5 "fmt"6 "fmt"
7+ "net/http"
6 "os"8 "os"
7- "strings"9+ "time"
8 10
9 "github.com/bnema/gordon/internal/cli"11 "github.com/bnema/gordon/internal/cli"
10 "github.com/bnema/gordon/internal/cli/handler"12 "github.com/bnema/gordon/internal/cli/handler"
@@ -54,49 +56,39 @@ func pushImage(a *cli.App, imageName string) error {
54 56
55 handler.EnsureImageTag(&imageName)57 handler.EnsureImageTag(&imageName)
56 58
57- reader, actualSize, err := exportDockerImage(imageName)59+ reader, actualSize, err := handler.ExportDockerImage(imageName)
58 if err != nil {60 if err != nil {
59 return fmt.Errorf("error exporting image: %w", err)61 return fmt.Errorf("error exporting image: %w", err)
60 }62 }
61 defer reader.Close()63 defer reader.Close()
62 64
63 sizeInMB := float64(actualSize) / 1024 / 102465 sizeInMB := float64(actualSize) / 1024 / 1024
66+ log.Info("Image exported successfully",
67+ "image", imageName,
68+ "size", fmt.Sprintf("%.2fMB", sizeInMB))
64 69
65- log.Info("Image exported successfully", "image", imageName, "size", fmt.Sprintf("%.2fMB", sizeInMB))70+ headers := http.Header{
66-71+ "X-Image-Name": {imageName},
67- reqPayload := common.RequestPayload{72+ "Content-Type": {"application/octet-stream"},
68- Type: "push",
69- Payload: common.PushPayload{
70- ImageName: imageName,
71- Data: reader,
72- },
73 }73 }
74 74
75- resp, err := handler.SendHTTPRequest(a, &reqPayload, "POST", "/push")75+ chunkedClient := handler.NewChunkedClient(a)
76- if err != nil {
77- log.Error("Error sending HTTP request", "error", err)
78- return err
79- }
80 76
81- var pushResponse common.PushResponse77+ ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
82- if err := json.Unmarshal(resp.Body, &pushResponse); err != nil {78+ defer cancel()
83- log.Error("Error parsing response", "error", err)
84- return err
85- }
86 79
87- if !pushResponse.Success {80+ resp, err := chunkedClient.SendFile(ctx, "/push", headers, reader, actualSize, imageName)
88- log.Error("Push failed", "resp", pushResponse.Message)81+ if err != nil {
89- return fmt.Errorf(pushResponse.Message)82+ var pushErr *common.DeploymentError
83+ if errors.As(err, &pushErr) {
84+ return fmt.Errorf(pushErr.Message)
85+ }
86+ return fmt.Errorf("chunked transfer failed: %w", err)
90 }87 }
91 88
92- //Remove the double quotes from the message89+ fmt.Println("Push response:", resp)
93- message := strings.Trim(pushResponse.Message, "\"")
94 90
95- log.Info(message)91+ log.Info("Image pushed and imported successfully", "image", imageName)
96-
97- if pushResponse.CreateContainerURL != "" {
98- log.Info("Container creation available", "url", pushResponse.CreateContainerURL)
99- }
100 92
101 return nil93 return nil
102 }94 }
A internal/cli/handler/chunked_transfer.go +281 -0
@@ -0,0 +1,281 @@
1+// internal/cli/handler/chunked_client.go
2+package handler
3+
4+import (
5+ "bytes"
6+ "context"
7+ "encoding/json"
8+ "fmt"
9+ "io"
10+ "net/http"
11+ "strings"
12+ "time"
13+
14+ "github.com/bnema/gordon/internal/cli"
15+ "github.com/bnema/gordon/internal/common"
16+ "github.com/charmbracelet/log"
17+ "github.com/google/uuid"
18+)
19+
20+type ChunkedClient struct {
21+ client *http.Client
22+ baseURL string
23+ token string
24+ chunkSize int64
25+ app *cli.App
26+}
27+
28+type ChunkMetadata common.ChunkMetadata
29+
30+func NewChunkedClient(app *cli.App) *ChunkedClient {
31+ baseURL := app.Config.Http.BackendURL + "/api"
32+ token := app.Config.General.Token
33+
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{
44+ client: &http.Client{
45+ Timeout: 30 * time.Minute,
46+ },
47+ baseURL: baseURL,
48+ token: token,
49+ chunkSize: 5 * 1024 * 1024,
50+ app: app,
51+ }
52+}
53+
54+func (c *ChunkedClient) SendFile(ctx context.Context, endpoint string, headers http.Header, reader io.Reader, totalSize int64, imageName string) (resp *Response, error error) {
55+ transferID := uuid.New().String()
56+ totalChunks := (int(totalSize) + int(c.chunkSize) - 1) / int(c.chunkSize)
57+ buf := make([]byte, c.chunkSize)
58+ var lastResponse *Response
59+
60+ // Determine response type based on endpoint
61+ responseType := determineResponseType(endpoint)
62+
63+ for chunkNum := 0; chunkNum < totalChunks; chunkNum++ {
64+ select {
65+ case <-ctx.Done():
66+ return nil, fmt.Errorf("upload cancelled")
67+ default:
68+ n, err := io.ReadFull(reader, buf)
69+ if err != nil && err != io.EOF && err != io.ErrUnexpectedEOF {
70+ return nil, fmt.Errorf("failed to read chunk %d: %w", chunkNum, err)
71+ }
72+
73+ chunk := buf[:n]
74+ if n == 0 {
75+ continue
76+ }
77+
78+ metadata := common.ChunkMetadata{
79+ ChunkNumber: chunkNum,
80+ TotalChunks: totalChunks,
81+ ChunkSize: int64(n),
82+ TotalSize: totalSize,
83+ ImageName: imageName,
84+ TransferID: transferID,
85+ }
86+
87+ // Send chunk and get response
88+ resp, err := c.sendChunkWithRetry(ctx, endpoint, headers, chunk, metadata)
89+ if err != nil {
90+ return nil, fmt.Errorf("failed to send chunk %d: %w", chunkNum, err)
91+ }
92+ lastResponse = resp
93+
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))
104+ }
105+ }
106+
107+ // Handle final response
108+ if lastResponse != nil {
109+ if err := handleFinalResponse(lastResponse, responseType); err != nil {
110+ log.Error("Failed to handle final response", "error", err)
111+ }
112+ }
113+
114+ return lastResponse, nil
115+}
116+
117+func determineResponseType(endpoint string) string {
118+ switch {
119+ case strings.Contains(endpoint, "/deploy"):
120+ return "deploy"
121+ case strings.Contains(endpoint, "/push"):
122+ return "push"
123+ default:
124+ return "unknown"
125+ }
126+}
127+
128+func handleChunkResponse(resp *Response, responseType string) error {
129+ switch responseType {
130+ 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+ log.Info("Deploy chunk response",
136+ "success", deployResp.Success,
137+ "message", deployResp.Message,
138+ "domain", deployResp.Domain,
139+ "containerID", deployResp.ContainerID,
140+ "containerName", deployResp.ContainerName)
141+ return nil
142+
143+ case "push":
144+ var pushResp common.PushResponse
145+ if err := json.Unmarshal(resp.Body, &pushResp); err != nil {
146+ return fmt.Errorf("failed to unmarshal push response: %w", err)
147+ }
148+ log.Info("Push chunk response",
149+ "success", pushResp.Success,
150+ "message", pushResp.Message,
151+ "createContainerURL", pushResp.CreateContainerURL,
152+ "imageID", pushResp.ImageID)
153+ return nil
154+
155+ default:
156+ return fmt.Errorf("unknown response type: %s", responseType)
157+ }
158+}
159+
160+func handleFinalResponse(resp *Response, responseType string) error {
161+ switch responseType {
162+ case "deploy":
163+ var finalResp common.DeployResponse
164+ if err := json.Unmarshal(resp.Body, &finalResp); err != nil {
165+ return fmt.Errorf("failed to unmarshal final deploy response: %w", err)
166+ }
167+ log.Info("Final deploy response",
168+ "success", finalResp.Success,
169+ "message", finalResp.Message,
170+ "domain", finalResp.Domain,
171+ "containerID", finalResp.ContainerID,
172+ "containerName", finalResp.ContainerName)
173+ return nil
174+
175+ case "push":
176+ var finalResp common.PushResponse
177+ if err := json.Unmarshal(resp.Body, &finalResp); err != nil {
178+ return fmt.Errorf("failed to unmarshal final push response: %w", err)
179+ }
180+ log.Info("Final push response",
181+ "success", finalResp.Success,
182+ "message", finalResp.Message,
183+ "createContainerURL", finalResp.CreateContainerURL,
184+ "imageID", finalResp.ImageID)
185+ return nil
186+
187+ default:
188+ return fmt.Errorf("unknown response type: %s", responseType)
189+ }
190+}
191+
192+func (c *ChunkedClient) sendChunkWithRetry(ctx context.Context, endpoint string, headers http.Header, chunk []byte, metadata common.ChunkMetadata) (*Response, error) {
193+ var lastErr error
194+ for attempt := 0; attempt < 3; attempt++ {
195+ resp, err := c.sendChunk(ctx, endpoint, headers, chunk, metadata)
196+ if err == nil {
197+ return resp, nil
198+ }
199+ lastErr = err
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+ }
206+ return nil, fmt.Errorf("max retries exceeded: %w", lastErr)
207+}
208+
209+func (c *ChunkedClient) sendChunk(ctx context.Context, endpoint string, headers http.Header, chunk []byte, metadata common.ChunkMetadata) (*Response, error) {
210+ reauthenticated := false
211+
212+ for {
213+ ctx, cancel := context.WithTimeout(ctx, 10*time.Minute)
214+ defer cancel()
215+
216+ metadataJSON, err := json.Marshal(metadata)
217+ if err != nil {
218+ return nil, fmt.Errorf("failed to marshal metadata: %w", err)
219+ }
220+
221+ // Create the request
222+ url := c.baseURL + endpoint
223+ req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(chunk))
224+ if err != nil {
225+ return nil, fmt.Errorf("failed to create request: %w", err)
226+ }
227+
228+ // Set headers
229+ req.Header.Set("Authorization", "Bearer "+c.token)
230+ req.Header.Set("Content-Type", "application/octet-stream")
231+ req.Header.Set("X-Chunk-Metadata", string(metadataJSON))
232+
233+ for k, v := range headers {
234+ req.Header[k] = v
235+ }
236+
237+ // Send request
238+ resp, err := c.client.Do(req)
239+ if err != nil {
240+ return nil, fmt.Errorf("request failed: %w", err)
241+ }
242+ defer resp.Body.Close()
243+
244+ // Handle 401 Unauthorized
245+ if resp.StatusCode == http.StatusUnauthorized && !reauthenticated {
246+ log.Warn("Token expired, initiating re-authentication")
247+
248+ err := ReAuthenticate(c.app)
249+ if err != nil {
250+ return nil, fmt.Errorf("re-authentication failed: %w", err)
251+ }
252+
253+ // Update the client's token with the new one
254+ c.token = c.app.Config.General.Token
255+ reauthenticated = true
256+ continue
257+ }
258+
259+ // Handle non-200 status codes
260+ if resp.StatusCode != http.StatusOK {
261+ body, _ := io.ReadAll(resp.Body)
262+ return nil, &common.DeploymentError{
263+ StatusCode: resp.StatusCode,
264+ RawResponse: string(body),
265+ }
266+ }
267+
268+ // Read response body
269+ body, err := io.ReadAll(resp.Body)
270+ if err != nil {
271+ return nil, fmt.Errorf("failed to read response: %w", err)
272+ }
273+
274+ return &Response{
275+ Http: resp,
276+ Body: body,
277+ StatusCode: resp.StatusCode,
278+ Header: resp.Header,
279+ }, nil
280+ }
281+}
@@ -0,0 +1,281 @@
1+// internal/cli/handler/chunked_client.go
2+package handler
3+
4+import (
5+ "bytes"
6+ "context"
7+ "encoding/json"
8+ "fmt"
9+ "io"
10+ "net/http"
11+ "strings"
12+ "time"
13+
14+ "github.com/bnema/gordon/internal/cli"
15+ "github.com/bnema/gordon/internal/common"
16+ "github.com/charmbracelet/log"
17+ "github.com/google/uuid"
18+)
19+
20+type ChunkedClient struct {
21+ client *http.Client
22+ baseURL string
23+ token string
24+ chunkSize int64
25+ app *cli.App
26+}
27+
28+type ChunkMetadata common.ChunkMetadata
29+
30+func NewChunkedClient(app *cli.App) *ChunkedClient {
31+ baseURL := app.Config.Http.BackendURL + "/api"
32+ token := app.Config.General.Token
33+
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{
44+ client: &http.Client{
45+ Timeout: 30 * time.Minute,
46+ },
47+ baseURL: baseURL,
48+ token: token,
49+ chunkSize: 5 * 1024 * 1024,
50+ app: app,
51+ }
52+}
53+
54+func (c *ChunkedClient) SendFile(ctx context.Context, endpoint string, headers http.Header, reader io.Reader, totalSize int64, imageName string) (resp *Response, error error) {
55+ transferID := uuid.New().String()
56+ totalChunks := (int(totalSize) + int(c.chunkSize) - 1) / int(c.chunkSize)
57+ buf := make([]byte, c.chunkSize)
58+ var lastResponse *Response
59+
60+ // Determine response type based on endpoint
61+ responseType := determineResponseType(endpoint)
62+
63+ for chunkNum := 0; chunkNum < totalChunks; chunkNum++ {
64+ select {
65+ case <-ctx.Done():
66+ return nil, fmt.Errorf("upload cancelled")
67+ default:
68+ n, err := io.ReadFull(reader, buf)
69+ if err != nil && err != io.EOF && err != io.ErrUnexpectedEOF {
70+ return nil, fmt.Errorf("failed to read chunk %d: %w", chunkNum, err)
71+ }
72+
73+ chunk := buf[:n]
74+ if n == 0 {
75+ continue
76+ }
77+
78+ metadata := common.ChunkMetadata{
79+ ChunkNumber: chunkNum,
80+ TotalChunks: totalChunks,
81+ ChunkSize: int64(n),
82+ TotalSize: totalSize,
83+ ImageName: imageName,
84+ TransferID: transferID,
85+ }
86+
87+ // Send chunk and get response
88+ resp, err := c.sendChunkWithRetry(ctx, endpoint, headers, chunk, metadata)
89+ if err != nil {
90+ return nil, fmt.Errorf("failed to send chunk %d: %w", chunkNum, err)
91+ }
92+ lastResponse = resp
93+
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))
104+ }
105+ }
106+
107+ // Handle final response
108+ if lastResponse != nil {
109+ if err := handleFinalResponse(lastResponse, responseType); err != nil {
110+ log.Error("Failed to handle final response", "error", err)
111+ }
112+ }
113+
114+ return lastResponse, nil
115+}
116+
117+func determineResponseType(endpoint string) string {
118+ switch {
119+ case strings.Contains(endpoint, "/deploy"):
120+ return "deploy"
121+ case strings.Contains(endpoint, "/push"):
122+ return "push"
123+ default:
124+ return "unknown"
125+ }
126+}
127+
128+func handleChunkResponse(resp *Response, responseType string) error {
129+ switch responseType {
130+ 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+ log.Info("Deploy chunk response",
136+ "success", deployResp.Success,
137+ "message", deployResp.Message,
138+ "domain", deployResp.Domain,
139+ "containerID", deployResp.ContainerID,
140+ "containerName", deployResp.ContainerName)
141+ return nil
142+
143+ case "push":
144+ var pushResp common.PushResponse
145+ if err := json.Unmarshal(resp.Body, &pushResp); err != nil {
146+ return fmt.Errorf("failed to unmarshal push response: %w", err)
147+ }
148+ log.Info("Push chunk response",
149+ "success", pushResp.Success,
150+ "message", pushResp.Message,
151+ "createContainerURL", pushResp.CreateContainerURL,
152+ "imageID", pushResp.ImageID)
153+ return nil
154+
155+ default:
156+ return fmt.Errorf("unknown response type: %s", responseType)
157+ }
158+}
159+
160+func handleFinalResponse(resp *Response, responseType string) error {
161+ switch responseType {
162+ case "deploy":
163+ var finalResp common.DeployResponse
164+ if err := json.Unmarshal(resp.Body, &finalResp); err != nil {
165+ return fmt.Errorf("failed to unmarshal final deploy response: %w", err)
166+ }
167+ log.Info("Final deploy response",
168+ "success", finalResp.Success,
169+ "message", finalResp.Message,
170+ "domain", finalResp.Domain,
171+ "containerID", finalResp.ContainerID,
172+ "containerName", finalResp.ContainerName)
173+ return nil
174+
175+ case "push":
176+ var finalResp common.PushResponse
177+ if err := json.Unmarshal(resp.Body, &finalResp); err != nil {
178+ return fmt.Errorf("failed to unmarshal final push response: %w", err)
179+ }
180+ log.Info("Final push response",
181+ "success", finalResp.Success,
182+ "message", finalResp.Message,
183+ "createContainerURL", finalResp.CreateContainerURL,
184+ "imageID", finalResp.ImageID)
185+ return nil
186+
187+ default:
188+ return fmt.Errorf("unknown response type: %s", responseType)
189+ }
190+}
191+
192+func (c *ChunkedClient) sendChunkWithRetry(ctx context.Context, endpoint string, headers http.Header, chunk []byte, metadata common.ChunkMetadata) (*Response, error) {
193+ var lastErr error
194+ for attempt := 0; attempt < 3; attempt++ {
195+ resp, err := c.sendChunk(ctx, endpoint, headers, chunk, metadata)
196+ if err == nil {
197+ return resp, nil
198+ }
199+ lastErr = err
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+ }
206+ return nil, fmt.Errorf("max retries exceeded: %w", lastErr)
207+}
208+
209+func (c *ChunkedClient) sendChunk(ctx context.Context, endpoint string, headers http.Header, chunk []byte, metadata common.ChunkMetadata) (*Response, error) {
210+ reauthenticated := false
211+
212+ for {
213+ ctx, cancel := context.WithTimeout(ctx, 10*time.Minute)
214+ defer cancel()
215+
216+ metadataJSON, err := json.Marshal(metadata)
217+ if err != nil {
218+ return nil, fmt.Errorf("failed to marshal metadata: %w", err)
219+ }
220+
221+ // Create the request
222+ url := c.baseURL + endpoint
223+ req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(chunk))
224+ if err != nil {
225+ return nil, fmt.Errorf("failed to create request: %w", err)
226+ }
227+
228+ // Set headers
229+ req.Header.Set("Authorization", "Bearer "+c.token)
230+ req.Header.Set("Content-Type", "application/octet-stream")
231+ req.Header.Set("X-Chunk-Metadata", string(metadataJSON))
232+
233+ for k, v := range headers {
234+ req.Header[k] = v
235+ }
236+
237+ // Send request
238+ resp, err := c.client.Do(req)
239+ if err != nil {
240+ return nil, fmt.Errorf("request failed: %w", err)
241+ }
242+ defer resp.Body.Close()
243+
244+ // Handle 401 Unauthorized
245+ if resp.StatusCode == http.StatusUnauthorized && !reauthenticated {
246+ log.Warn("Token expired, initiating re-authentication")
247+
248+ err := ReAuthenticate(c.app)
249+ if err != nil {
250+ return nil, fmt.Errorf("re-authentication failed: %w", err)
251+ }
252+
253+ // Update the client's token with the new one
254+ c.token = c.app.Config.General.Token
255+ reauthenticated = true
256+ continue
257+ }
258+
259+ // Handle non-200 status codes
260+ if resp.StatusCode != http.StatusOK {
261+ body, _ := io.ReadAll(resp.Body)
262+ return nil, &common.DeploymentError{
263+ StatusCode: resp.StatusCode,
264+ RawResponse: string(body),
265+ }
266+ }
267+
268+ // Read response body
269+ body, err := io.ReadAll(resp.Body)
270+ if err != nil {
271+ return nil, fmt.Errorf("failed to read response: %w", err)
272+ }
273+
274+ return &Response{
275+ Http: resp,
276+ Body: body,
277+ StatusCode: resp.StatusCode,
278+ Header: resp.Header,
279+ }, nil
280+ }
281+}
A internal/cli/handler/docker.go +37 -0
@@ -0,0 +1,37 @@
1+package handler
2+
3+import (
4+ "fmt"
5+ "io"
6+
7+ "github.com/bnema/gordon/pkg/docker"
8+)
9+
10+func ExportDockerImage(imageName string) (io.ReadCloser, int64, error) {
11+ exists, err := docker.CheckIfImageExists(imageName)
12+ if err != nil {
13+ return nil, 0, fmt.Errorf("error checking image existence: %w", err)
14+ }
15+
16+ var imageID string
17+ if exists {
18+ imageID = imageName
19+ } else {
20+ imageID, err = docker.GetImageIDByName(imageName)
21+ if err != nil {
22+ return nil, 0, fmt.Errorf("error searching for image by name: %w", err)
23+ }
24+ }
25+
26+ actualSize, err := docker.GetImageSizeFromReader(imageID)
27+ if err != nil {
28+ return nil, 0, fmt.Errorf("error retrieving image size: %w", err)
29+ }
30+
31+ reader, err := docker.ExportImageFromEngine(imageID)
32+ if err != nil {
33+ return nil, 0, fmt.Errorf("error exporting image: %w", err)
34+ }
35+
36+ return reader, actualSize, nil
37+}
@@ -0,0 +1,37 @@
1+package handler
2+
3+import (
4+ "fmt"
5+ "io"
6+
7+ "github.com/bnema/gordon/pkg/docker"
8+)
9+
10+func ExportDockerImage(imageName string) (io.ReadCloser, int64, error) {
11+ exists, err := docker.CheckIfImageExists(imageName)
12+ if err != nil {
13+ return nil, 0, fmt.Errorf("error checking image existence: %w", err)
14+ }
15+
16+ var imageID string
17+ if exists {
18+ imageID = imageName
19+ } else {
20+ imageID, err = docker.GetImageIDByName(imageName)
21+ if err != nil {
22+ return nil, 0, fmt.Errorf("error searching for image by name: %w", err)
23+ }
24+ }
25+
26+ actualSize, err := docker.GetImageSizeFromReader(imageID)
27+ if err != nil {
28+ return nil, 0, fmt.Errorf("error retrieving image size: %w", err)
29+ }
30+
31+ reader, err := docker.ExportImageFromEngine(imageID)
32+ if err != nil {
33+ return nil, 0, fmt.Errorf("error exporting image: %w", err)
34+ }
35+
36+ return reader, actualSize, nil
37+}
M internal/common/payload.go +11 -1
@@ -31,6 +31,7 @@ type PushResponse struct {
3131 Success bool `json:"success"`
3232 Message string `json:"message"`
3333 CreateContainerURL string `json:"create_container_url,omitempty"`
34+ ImageID string `json:"image_id,omitempty"`
3435 }
3536
3637 type StopResponse struct {
@@ -73,7 +74,16 @@ type DeployPayload struct {
7374 type PushPayload struct {
7475 ImageName string `json:"imagename"`
7576 ImageID string `json:"imageid"`
76- Data io.ReadCloser
77+ Data io.Reader
78+}
79+
80+type ChunkMetadata struct {
81+ ChunkNumber int `json:"chunk_number"`
82+ TotalChunks int `json:"total_chunks"`
83+ ChunkSize int64 `json:"chunk_size"`
84+ TotalSize int64 `json:"total_size"`
85+ ImageName string `json:"image_name"`
86+ TransferID string `json:"transfer_id"`
7787 }
7888
7989 // UnmarshalJSON custom unmarshaler for RequestPayload
@@ -31,6 +31,7 @@ type PushResponse struct {
31 Success bool `json:"success"`31 Success bool `json:"success"`
32 Message string `json:"message"`32 Message string `json:"message"`
33 CreateContainerURL string `json:"create_container_url,omitempty"`33 CreateContainerURL string `json:"create_container_url,omitempty"`
34+ ImageID string `json:"image_id,omitempty"`
34 }35 }
35 36
36 type StopResponse struct {37 type StopResponse struct {
@@ -73,7 +74,16 @@ type DeployPayload struct {
73 type PushPayload struct {74 type PushPayload struct {
74 ImageName string `json:"imagename"`75 ImageName string `json:"imagename"`
75 ImageID string `json:"imageid"`76 ImageID string `json:"imageid"`
76- Data io.ReadCloser77+ Data io.Reader
78+}
79+
80+type ChunkMetadata struct {
81+ ChunkNumber int `json:"chunk_number"`
82+ TotalChunks int `json:"total_chunks"`
83+ ChunkSize int64 `json:"chunk_size"`
84+ TotalSize int64 `json:"total_size"`
85+ ImageName string `json:"image_name"`
86+ TransferID string `json:"transfer_id"`
77 }87 }
78 88
79 // UnmarshalJSON custom unmarshaler for RequestPayload89 // UnmarshalJSON custom unmarshaler for RequestPayload
M internal/httpserve/handlers/admin_view.go +0 -2
@@ -41,8 +41,6 @@ func renderAdminPage(c echo.Context, a *server.App, templateName string) error {
4141 }
4242
4343 data["AdminPath"] = a.Config.Admin.Path
44- // DEBUG show the admin path here
45- fmt.Println("AdminPath:", a.Config.Admin.Path)
4644
4745 rendererData, err := render.GetHTMLRenderer(mainPath, templateName, a.TemplateFS, a, fragmentsPath)
4846 if err != nil {
@@ -41,8 +41,6 @@ func renderAdminPage(c echo.Context, a *server.App, templateName string) error {
41 }41 }
42 42
43 data["AdminPath"] = a.Config.Admin.Path43 data["AdminPath"] = a.Config.Admin.Path
44- // DEBUG show the admin path here
45- fmt.Println("AdminPath:", a.Config.Admin.Path)
46 44
47 rendererData, err := render.GetHTMLRenderer(mainPath, templateName, a.TemplateFS, a, fragmentsPath)45 rendererData, err := render.GetHTMLRenderer(mainPath, templateName, a.TemplateFS, a, fragmentsPath)
48 if err != nil {46 if err != nil {
D internal/httpserve/handlers/cli_endpoints.go +0 -416
@@ -1,416 +0,0 @@
1-package handlers
2-
3-import (
4- "encoding/json"
5- "errors"
6- "fmt"
7- "net/http"
8- "regexp"
9- "strings"
10-
11- "github.com/bnema/gordon/internal/common"
12- "github.com/bnema/gordon/internal/server"
13- "github.com/bnema/gordon/internal/templating/cmdparams"
14- "github.com/bnema/gordon/pkg/docker"
15- "github.com/bnema/gordon/pkg/store"
16- "github.com/charmbracelet/log"
17- "github.com/labstack/echo/v4"
18-)
19-
20-type InfoResponse struct {
21- Uptime string `json:"uptime"`
22- Version string `json:"version"`
23-}
24-
25-type DeployResponse = common.DeployResponse
26-type PushResponse = common.PushResponse
27-type StopResponse = common.StopResponse
28-type RemoveResponse = common.RemoveResponse
29-
30-// sendJSONResponse is a helper function to send JSON responses
31-func sendJSONResponse(c echo.Context, statusCode int, response interface{}) error {
32- // Debug
33- log.Debug("Sending JSON response", "statusCode", statusCode, "response", response)
34- return c.JSON(statusCode, response)
35-}
36-
37-// Populate fills the InfoResponse struct with data from the App
38-func (info *InfoResponse) Populate(a *server.App) {
39- info.Uptime = a.GetUptime()
40- info.Version = a.GetVersionstring()
41-}
42-
43-// GetInfos handles GET requests on /api/ping endpoint
44-func GetInfos(c echo.Context, a *server.App) error {
45- payload := new(common.RequestPayload)
46- if err := c.Bind(payload); err != nil {
47- return sendJSONResponse(c, http.StatusBadRequest, "Invalid payload: "+err.Error())
48- }
49-
50- if payload.Type != "ping" {
51- return sendJSONResponse(c, http.StatusBadRequest, "Invalid payload type")
52- }
53-
54- pingPayload, ok := payload.Payload.(common.PingPayload)
55- if !ok {
56- return sendJSONResponse(c, http.StatusBadRequest, "Invalid payload structure")
57- }
58-
59- if pingPayload.Message != "ping" {
60- return sendJSONResponse(c, http.StatusBadRequest, "Invalid ping message")
61- }
62-
63- info := &InfoResponse{}
64- info.Populate(a)
65-
66- return sendJSONResponse(c, http.StatusOK, info)
67-}
68-
69-// PostContainerStop handles the container stop request
70-func PostContainerStop(c echo.Context, a *server.App) error {
71- payload := new(common.RequestPayload)
72- if err := c.Bind(payload); err != nil {
73- return sendJSONResponse(c, http.StatusBadRequest, "Invalid payload: "+err.Error())
74- }
75-
76- if payload.Type != "stop" {
77- return sendJSONResponse(c, http.StatusBadRequest, "Invalid payload type")
78- }
79-
80- stopPayload, ok := payload.Payload.(common.StopPayload)
81- if !ok {
82- return sendJSONResponse(c, http.StatusBadRequest, "Invalid payload structure")
83- }
84-
85- if stopPayload.ContainerID == "" {
86- return sendJSONResponse(c, http.StatusBadRequest, "Invalid container ID")
87- }
88-
89- err := docker.StopContainer(stopPayload.ContainerID)
90- if err != nil {
91- return sendJSONResponse(c, http.StatusInternalServerError, StopResponse{
92- Success: false,
93- Message: err.Error(),
94- })
95- }
96-
97- return sendJSONResponse(c, http.StatusOK, StopResponse{
98- Success: true,
99- Message: "Container stopped successfully",
100- })
101-}
102-
103-// PostContainerRemove handles the container remove request
104-func PostContainerRemove(c echo.Context, a *server.App) error {
105- payload := new(common.RequestPayload)
106- if err := c.Bind(payload); err != nil {
107- return sendJSONResponse(c, http.StatusBadRequest, "Invalid payload: "+err.Error())
108- }
109-
110- if payload.Type != "remove" {
111- return sendJSONResponse(c, http.StatusBadRequest, "Invalid payload type")
112- }
113-
114- removePayload, ok := payload.Payload.(common.RemovePayload)
115- if !ok {
116- return sendJSONResponse(c, http.StatusBadRequest, "Invalid payload structure")
117- }
118-
119- if removePayload.ContainerID == "" {
120- return sendJSONResponse(c, http.StatusBadRequest, "Invalid container ID")
121- }
122-
123- err := docker.RemoveContainer(removePayload.ContainerID)
124- if err != nil {
125- return sendJSONResponse(c, http.StatusInternalServerError, RemoveResponse{
126- Success: false,
127- Message: err.Error(),
128- })
129- }
130-
131- return sendJSONResponse(c, http.StatusOK, RemoveResponse{
132- Success: true,
133- Message: "Container removed successfully",
134- })
135-}
136-
137-// PostPush handles the image push request
138-func PostPush(c echo.Context, a *server.App) error {
139- payload, err := validateAndPreparePayload(c)
140- if err != nil {
141- return sendJSONResponse(c, http.StatusBadRequest, PushResponse{
142- Success: false,
143- Message: err.Error(),
144- })
145- }
146-
147- _, err = saveAndImportImage(c, a, payload)
148- if err != nil {
149- return sendJSONResponse(c, http.StatusInternalServerError, PushResponse{
150- Success: false,
151- Message: err.Error(),
152- })
153- }
154-
155- shortID := generateShortID(payload.ImageID)
156- storeIDMapping(shortID, payload.ImageID)
157-
158- createContainerURL := generateCreateContainerURL(a, shortID)
159-
160- return sendJSONResponse(c, http.StatusOK, common.PushResponse{
161- Success: true,
162- Message: "Image pushed and imported successfully",
163- CreateContainerURL: createContainerURL,
164- })
165-}
166-
167-// validateAndPreparePayload checks the request and prepares the payload
168-func validateAndPreparePayload(c echo.Context) (*common.PushPayload, error) {
169- payload := &common.PushPayload{
170- ImageName: c.Request().Header.Get("X-Image-Name"),
171- }
172-
173- if payload.ImageName == "" {
174- return nil, errors.New("invalid image name")
175- }
176-
177- return payload, nil
178-}
179-
180-// saveAndImportImage saves the image to storage and imports it to the Docker engine
181-func saveAndImportImage(c echo.Context, a *server.App, payload *common.PushPayload) (string, error) {
182- imageReader := c.Request().Body
183- defer imageReader.Close()
184-
185- imageFileName := sanitizeImageFileName(payload.ImageName)
186-
187- imagePath, err := store.SaveImageToStorage(&a.Config, imageFileName, imageReader)
188- if err != nil {
189- return "", fmt.Errorf("failed to save image: %v", err)
190- }
191-
192- imageID, err := docker.ImportImageToEngine(imagePath)
193- if err != nil {
194- store.RemoveFromStorage(imagePath)
195- return "", fmt.Errorf("failed to import image: %v", err)
196- }
197-
198- if imageID == "" {
199- return "", errors.New("imported image ID is empty")
200- }
201-
202- err = store.RemoveFromStorage(imagePath)
203- if err != nil {
204- return "", fmt.Errorf("failed to remove temporary image file: %v", err)
205- }
206-
207- payload.ImageID = imageID
208- return imagePath, nil
209-}
210-
211-// sanitizeImageFileName removes unwanted parts from the image name
212-func sanitizeImageFileName(imageName string) string {
213- imageName = regexp.MustCompile(`^([a-zA-Z0-9\-_.]+\/)?`).ReplaceAllString(imageName, "")
214- imageName = regexp.MustCompile(`(:[a-zA-Z0-9\-_.]+)?$`).ReplaceAllString(imageName, "")
215- return imageName + ".tar"
216-}
217-
218-// generateShortID creates a shortened version of the image ID
219-func generateShortID(imageID string) string {
220- shortID := imageID[7:]
221- if len(shortID) > 12 {
222- shortID = shortID[:12]
223- }
224- return shortID
225-}
226-
227-// storeIDMapping saves the mapping between short ID and full ID
228-func storeIDMapping(shortID, fullID string) {
229- safelyInteractWithIDMap(Update, shortID, fullID)
230-}
231-
232-// generateCreateContainerURL creates the URL for the container creation page
233-func generateCreateContainerURL(a *server.App, shortID string) string {
234- return fmt.Sprintf("%s://%s%s/cc/%s",
235- a.Config.Http.Protocol(),
236- a.Config.Http.FullDomain(),
237- a.Config.Admin.Path,
238- shortID)
239-}
240-
241-// PostDeploy handles the container deployment request
242-func PostDeploy(c echo.Context, a *server.App) error {
243- payload, err := validateAndPrepareDeployPayload(c)
244- if err != nil {
245- // Debug
246- log.Error("Failed to validate and prepare payload", "error", err)
247- return sendJSONResponse(c, http.StatusBadRequest, DeployResponse{
248- Success: false,
249- Message: fmt.Sprintf("Invalid payload: %v", err),
250- })
251- }
252-
253- _, err = saveAndImportDeployImage(c, a, payload)
254- if err != nil {
255- // Debug
256- log.Error("Failed to save and import image", "error", err)
257- return sendJSONResponse(c, http.StatusInternalServerError, DeployResponse{
258- Success: false,
259- Message: fmt.Sprintf("Failed to save or import image: %v", err),
260- })
261- }
262-
263- containerID, containerName, err := createAndStartContainer(a, payload)
264- if err != nil {
265- if strings.Contains(err.Error(), "is already in use") {
266- log.Warn("Container already exists", "error", err)
267- existingContainerID := extractContainerID(err.Error())
268- existingContainerName, _ := docker.GetContainerName(existingContainerID)
269- // Remove the / from the container name
270- existingContainerName = strings.TrimLeft(existingContainerName, "/")
271-
272- if existingContainerID == "" || existingContainerName == "" {
273- log.Error("Failed to extract container ID or Name from error message")
274- return sendJSONResponse(c, http.StatusConflict, DeployResponse{
275- Success: false,
276- Message: "A container for this deploy already exists but could not be identified.",
277- })
278- }
279-
280- return sendJSONResponse(c, http.StatusConflict, DeployResponse{
281- Success: false,
282- Message: "A container for this deploy already exists.",
283- ContainerID: existingContainerID,
284- ContainerName: existingContainerName,
285- })
286- }
287- log.Error("Failed to create or start container", "error", err)
288- return sendJSONResponse(c, http.StatusInternalServerError, DeployResponse{
289- Success: false,
290- Message: fmt.Sprintf("Failed to create or start container: %v", err),
291- })
292- }
293- response := DeployResponse{
294- Success: true,
295- Message: "Deployment successful",
296- Domain: payload.TargetDomain,
297- ContainerID: containerID,
298- ContainerName: containerName,
299- }
300-
301- // Debug: Verify containerID in response
302- if response.ContainerID != containerID {
303- log.Error("Container ID mismatch", "responseID", response.ContainerID, "actualID", containerID)
304- } else {
305- log.Info("Container ID successfully stored in response", "containerID", containerID)
306- }
307-
308- return sendJSONResponse(c, http.StatusOK, response)
309-}
310-
311-// extractContainerID extracts the container ID from the error message
312-func extractContainerID(errorMessage string) string {
313- re := regexp.MustCompile(`by\s+([0-9a-f]+)\.`)
314- match := re.FindStringSubmatch(errorMessage)
315- if len(match) > 1 {
316- return match[1]
317- }
318- return ""
319-}
320-
321-// validateAndPrepareDeployPayload prepares the deployment payload
322-func validateAndPrepareDeployPayload(c echo.Context) (*common.DeployPayload, error) {
323- payload := &common.DeployPayload{
324- Port: c.Request().Header.Get("X-Ports"),
325- ImageName: c.Request().Header.Get("X-Image-Name"),
326- TargetDomain: c.Request().Header.Get("X-Target-Domain"),
327- }
328-
329- if payload.ImageName == "" {
330- return nil, errors.New("invalid image name")
331- }
332-
333- payload.Port = normalizePort(payload.Port)
334- payload.TargetDomain = normalizeTargetDomain(payload.TargetDomain)
335-
336- return payload, nil
337-}
338-
339-// normalizePort adds "/tcp" to the port if not present
340-func normalizePort(port string) string {
341- if !regexp.MustCompile(`\/(tcp|udp)$`).MatchString(port) {
342- return port + "/tcp"
343- }
344- return port
345-}
346-
347-// normalizeTargetDomain adds "https://" to the domain if not present
348-func normalizeTargetDomain(domain string) string {
349- if !regexp.MustCompile(`^https?:\/\/`).MatchString(domain) {
350- return "https://" + domain
351- }
352- return domain
353-}
354-
355-// saveAndImportDeployImage saves and imports the deployment image
356-func saveAndImportDeployImage(c echo.Context, a *server.App, payload *common.DeployPayload) (string, error) {
357- imageReader := c.Request().Body
358- defer imageReader.Close()
359-
360- imageFileName := sanitizeImageFileName(payload.ImageName)
361-
362- imagePath, err := store.SaveImageToStorage(&a.Config, imageFileName, imageReader)
363- if err != nil {
364- return "", fmt.Errorf("failed to save image: %v", err)
365- }
366-
367- imageID, err := docker.ImportImageToEngine(imagePath)
368- if err != nil {
369- store.RemoveFromStorage(imagePath)
370- return "", fmt.Errorf("failed to import image: %v", err)
371- }
372-
373- if imageID == "" {
374- return "", errors.New("imported image ID is empty")
375- }
376-
377- err = store.RemoveFromStorage(imagePath)
378- if err != nil {
379- return "", fmt.Errorf("failed to remove temporary image file: %v", err)
380- }
381-
382- payload.ImageID = imageID
383- return imagePath, nil
384-}
385-
386-// createAndStartContainer creates and starts a new container
387-func createAndStartContainer(a *server.App, payload *common.DeployPayload) (string, string, error) {
388- params, err := cmdparams.FromPayloadStructToCmdParams(payload, a, payload.ImageID)
389- if err != nil {
390- return "", "", fmt.Errorf("failed to create command parameters: %v", err)
391- }
392-
393- containerID, err := docker.CreateContainer(params)
394- if err != nil {
395- return "", "", fmt.Errorf("failed to create container: %v", err)
396- }
397-
398- containerName, err := docker.GetContainerName(containerID)
399- if err != nil {
400- return "", "", fmt.Errorf("failed to get container name: %v", err)
401- }
402-
403- err = docker.StartContainer(containerID)
404- if err != nil {
405- docker.RemoveContainer(containerID)
406- return "", "", fmt.Errorf("failed to start container: %v", err)
407- }
408-
409- return containerID, containerName, nil
410-}
411-
412-// logResponse logs the deployment response
413-func logResponse(response DeployResponse) {
414- responseJSON, _ := json.Marshal(response)
415- log.Info("Deployment response", string(responseJSON))
416-}
@@ -1,416 +0,0 @@
1-package handlers
2-
3-import (
4- "encoding/json"
5- "errors"
6- "fmt"
7- "net/http"
8- "regexp"
9- "strings"
10-
11- "github.com/bnema/gordon/internal/common"
12- "github.com/bnema/gordon/internal/server"
13- "github.com/bnema/gordon/internal/templating/cmdparams"
14- "github.com/bnema/gordon/pkg/docker"
15- "github.com/bnema/gordon/pkg/store"
16- "github.com/charmbracelet/log"
17- "github.com/labstack/echo/v4"
18-)
19-
20-type InfoResponse struct {
21- Uptime string `json:"uptime"`
22- Version string `json:"version"`
23-}
24-
25-type DeployResponse = common.DeployResponse
26-type PushResponse = common.PushResponse
27-type StopResponse = common.StopResponse
28-type RemoveResponse = common.RemoveResponse
29-
30-// sendJSONResponse is a helper function to send JSON responses
31-func sendJSONResponse(c echo.Context, statusCode int, response interface{}) error {
32- // Debug
33- log.Debug("Sending JSON response", "statusCode", statusCode, "response", response)
34- return c.JSON(statusCode, response)
35-}
36-
37-// Populate fills the InfoResponse struct with data from the App
38-func (info *InfoResponse) Populate(a *server.App) {
39- info.Uptime = a.GetUptime()
40- info.Version = a.GetVersionstring()
41-}
42-
43-// GetInfos handles GET requests on /api/ping endpoint
44-func GetInfos(c echo.Context, a *server.App) error {
45- payload := new(common.RequestPayload)
46- if err := c.Bind(payload); err != nil {
47- return sendJSONResponse(c, http.StatusBadRequest, "Invalid payload: "+err.Error())
48- }
49-
50- if payload.Type != "ping" {
51- return sendJSONResponse(c, http.StatusBadRequest, "Invalid payload type")
52- }
53-
54- pingPayload, ok := payload.Payload.(common.PingPayload)
55- if !ok {
56- return sendJSONResponse(c, http.StatusBadRequest, "Invalid payload structure")
57- }
58-
59- if pingPayload.Message != "ping" {
60- return sendJSONResponse(c, http.StatusBadRequest, "Invalid ping message")
61- }
62-
63- info := &InfoResponse{}
64- info.Populate(a)
65-
66- return sendJSONResponse(c, http.StatusOK, info)
67-}
68-
69-// PostContainerStop handles the container stop request
70-func PostContainerStop(c echo.Context, a *server.App) error {
71- payload := new(common.RequestPayload)
72- if err := c.Bind(payload); err != nil {
73- return sendJSONResponse(c, http.StatusBadRequest, "Invalid payload: "+err.Error())
74- }
75-
76- if payload.Type != "stop" {
77- return sendJSONResponse(c, http.StatusBadRequest, "Invalid payload type")
78- }
79-
80- stopPayload, ok := payload.Payload.(common.StopPayload)
81- if !ok {
82- return sendJSONResponse(c, http.StatusBadRequest, "Invalid payload structure")
83- }
84-
85- if stopPayload.ContainerID == "" {
86- return sendJSONResponse(c, http.StatusBadRequest, "Invalid container ID")
87- }
88-
89- err := docker.StopContainer(stopPayload.ContainerID)
90- if err != nil {
91- return sendJSONResponse(c, http.StatusInternalServerError, StopResponse{
92- Success: false,
93- Message: err.Error(),
94- })
95- }
96-
97- return sendJSONResponse(c, http.StatusOK, StopResponse{
98- Success: true,
99- Message: "Container stopped successfully",
100- })
101-}
102-
103-// PostContainerRemove handles the container remove request
104-func PostContainerRemove(c echo.Context, a *server.App) error {
105- payload := new(common.RequestPayload)
106- if err := c.Bind(payload); err != nil {
107- return sendJSONResponse(c, http.StatusBadRequest, "Invalid payload: "+err.Error())
108- }
109-
110- if payload.Type != "remove" {
111- return sendJSONResponse(c, http.StatusBadRequest, "Invalid payload type")
112- }
113-
114- removePayload, ok := payload.Payload.(common.RemovePayload)
115- if !ok {
116- return sendJSONResponse(c, http.StatusBadRequest, "Invalid payload structure")
117- }
118-
119- if removePayload.ContainerID == "" {
120- return sendJSONResponse(c, http.StatusBadRequest, "Invalid container ID")
121- }
122-
123- err := docker.RemoveContainer(removePayload.ContainerID)
124- if err != nil {
125- return sendJSONResponse(c, http.StatusInternalServerError, RemoveResponse{
126- Success: false,
127- Message: err.Error(),
128- })
129- }
130-
131- return sendJSONResponse(c, http.StatusOK, RemoveResponse{
132- Success: true,
133- Message: "Container removed successfully",
134- })
135-}
136-
137-// PostPush handles the image push request
138-func PostPush(c echo.Context, a *server.App) error {
139- payload, err := validateAndPreparePayload(c)
140- if err != nil {
141- return sendJSONResponse(c, http.StatusBadRequest, PushResponse{
142- Success: false,
143- Message: err.Error(),
144- })
145- }
146-
147- _, err = saveAndImportImage(c, a, payload)
148- if err != nil {
149- return sendJSONResponse(c, http.StatusInternalServerError, PushResponse{
150- Success: false,
151- Message: err.Error(),
152- })
153- }
154-
155- shortID := generateShortID(payload.ImageID)
156- storeIDMapping(shortID, payload.ImageID)
157-
158- createContainerURL := generateCreateContainerURL(a, shortID)
159-
160- return sendJSONResponse(c, http.StatusOK, common.PushResponse{
161- Success: true,
162- Message: "Image pushed and imported successfully",
163- CreateContainerURL: createContainerURL,
164- })
165-}
166-
167-// validateAndPreparePayload checks the request and prepares the payload
168-func validateAndPreparePayload(c echo.Context) (*common.PushPayload, error) {
169- payload := &common.PushPayload{
170- ImageName: c.Request().Header.Get("X-Image-Name"),
171- }
172-
173- if payload.ImageName == "" {
174- return nil, errors.New("invalid image name")
175- }
176-
177- return payload, nil
178-}
179-
180-// saveAndImportImage saves the image to storage and imports it to the Docker engine
181-func saveAndImportImage(c echo.Context, a *server.App, payload *common.PushPayload) (string, error) {
182- imageReader := c.Request().Body
183- defer imageReader.Close()
184-
185- imageFileName := sanitizeImageFileName(payload.ImageName)
186-
187- imagePath, err := store.SaveImageToStorage(&a.Config, imageFileName, imageReader)
188- if err != nil {
189- return "", fmt.Errorf("failed to save image: %v", err)
190- }
191-
192- imageID, err := docker.ImportImageToEngine(imagePath)
193- if err != nil {
194- store.RemoveFromStorage(imagePath)
195- return "", fmt.Errorf("failed to import image: %v", err)
196- }
197-
198- if imageID == "" {
199- return "", errors.New("imported image ID is empty")
200- }
201-
202- err = store.RemoveFromStorage(imagePath)
203- if err != nil {
204- return "", fmt.Errorf("failed to remove temporary image file: %v", err)
205- }
206-
207- payload.ImageID = imageID
208- return imagePath, nil
209-}
210-
211-// sanitizeImageFileName removes unwanted parts from the image name
212-func sanitizeImageFileName(imageName string) string {
213- imageName = regexp.MustCompile(`^([a-zA-Z0-9\-_.]+\/)?`).ReplaceAllString(imageName, "")
214- imageName = regexp.MustCompile(`(:[a-zA-Z0-9\-_.]+)?$`).ReplaceAllString(imageName, "")
215- return imageName + ".tar"
216-}
217-
218-// generateShortID creates a shortened version of the image ID
219-func generateShortID(imageID string) string {
220- shortID := imageID[7:]
221- if len(shortID) > 12 {
222- shortID = shortID[:12]
223- }
224- return shortID
225-}
226-
227-// storeIDMapping saves the mapping between short ID and full ID
228-func storeIDMapping(shortID, fullID string) {
229- safelyInteractWithIDMap(Update, shortID, fullID)
230-}
231-
232-// generateCreateContainerURL creates the URL for the container creation page
233-func generateCreateContainerURL(a *server.App, shortID string) string {
234- return fmt.Sprintf("%s://%s%s/cc/%s",
235- a.Config.Http.Protocol(),
236- a.Config.Http.FullDomain(),
237- a.Config.Admin.Path,
238- shortID)
239-}
240-
241-// PostDeploy handles the container deployment request
242-func PostDeploy(c echo.Context, a *server.App) error {
243- payload, err := validateAndPrepareDeployPayload(c)
244- if err != nil {
245- // Debug
246- log.Error("Failed to validate and prepare payload", "error", err)
247- return sendJSONResponse(c, http.StatusBadRequest, DeployResponse{
248- Success: false,
249- Message: fmt.Sprintf("Invalid payload: %v", err),
250- })
251- }
252-
253- _, err = saveAndImportDeployImage(c, a, payload)
254- if err != nil {
255- // Debug
256- log.Error("Failed to save and import image", "error", err)
257- return sendJSONResponse(c, http.StatusInternalServerError, DeployResponse{
258- Success: false,
259- Message: fmt.Sprintf("Failed to save or import image: %v", err),
260- })
261- }
262-
263- containerID, containerName, err := createAndStartContainer(a, payload)
264- if err != nil {
265- if strings.Contains(err.Error(), "is already in use") {
266- log.Warn("Container already exists", "error", err)
267- existingContainerID := extractContainerID(err.Error())
268- existingContainerName, _ := docker.GetContainerName(existingContainerID)
269- // Remove the / from the container name
270- existingContainerName = strings.TrimLeft(existingContainerName, "/")
271-
272- if existingContainerID == "" || existingContainerName == "" {
273- log.Error("Failed to extract container ID or Name from error message")
274- return sendJSONResponse(c, http.StatusConflict, DeployResponse{
275- Success: false,
276- Message: "A container for this deploy already exists but could not be identified.",
277- })
278- }
279-
280- return sendJSONResponse(c, http.StatusConflict, DeployResponse{
281- Success: false,
282- Message: "A container for this deploy already exists.",
283- ContainerID: existingContainerID,
284- ContainerName: existingContainerName,
285- })
286- }
287- log.Error("Failed to create or start container", "error", err)
288- return sendJSONResponse(c, http.StatusInternalServerError, DeployResponse{
289- Success: false,
290- Message: fmt.Sprintf("Failed to create or start container: %v", err),
291- })
292- }
293- response := DeployResponse{
294- Success: true,
295- Message: "Deployment successful",
296- Domain: payload.TargetDomain,
297- ContainerID: containerID,
298- ContainerName: containerName,
299- }
300-
301- // Debug: Verify containerID in response
302- if response.ContainerID != containerID {
303- log.Error("Container ID mismatch", "responseID", response.ContainerID, "actualID", containerID)
304- } else {
305- log.Info("Container ID successfully stored in response", "containerID", containerID)
306- }
307-
308- return sendJSONResponse(c, http.StatusOK, response)
309-}
310-
311-// extractContainerID extracts the container ID from the error message
312-func extractContainerID(errorMessage string) string {
313- re := regexp.MustCompile(`by\s+([0-9a-f]+)\.`)
314- match := re.FindStringSubmatch(errorMessage)
315- if len(match) > 1 {
316- return match[1]
317- }
318- return ""
319-}
320-
321-// validateAndPrepareDeployPayload prepares the deployment payload
322-func validateAndPrepareDeployPayload(c echo.Context) (*common.DeployPayload, error) {
323- payload := &common.DeployPayload{
324- Port: c.Request().Header.Get("X-Ports"),
325- ImageName: c.Request().Header.Get("X-Image-Name"),
326- TargetDomain: c.Request().Header.Get("X-Target-Domain"),
327- }
328-
329- if payload.ImageName == "" {
330- return nil, errors.New("invalid image name")
331- }
332-
333- payload.Port = normalizePort(payload.Port)
334- payload.TargetDomain = normalizeTargetDomain(payload.TargetDomain)
335-
336- return payload, nil
337-}
338-
339-// normalizePort adds "/tcp" to the port if not present
340-func normalizePort(port string) string {
341- if !regexp.MustCompile(`\/(tcp|udp)$`).MatchString(port) {
342- return port + "/tcp"
343- }
344- return port
345-}
346-
347-// normalizeTargetDomain adds "https://" to the domain if not present
348-func normalizeTargetDomain(domain string) string {
349- if !regexp.MustCompile(`^https?:\/\/`).MatchString(domain) {
350- return "https://" + domain
351- }
352- return domain
353-}
354-
355-// saveAndImportDeployImage saves and imports the deployment image
356-func saveAndImportDeployImage(c echo.Context, a *server.App, payload *common.DeployPayload) (string, error) {
357- imageReader := c.Request().Body
358- defer imageReader.Close()
359-
360- imageFileName := sanitizeImageFileName(payload.ImageName)
361-
362- imagePath, err := store.SaveImageToStorage(&a.Config, imageFileName, imageReader)
363- if err != nil {
364- return "", fmt.Errorf("failed to save image: %v", err)
365- }
366-
367- imageID, err := docker.ImportImageToEngine(imagePath)
368- if err != nil {
369- store.RemoveFromStorage(imagePath)
370- return "", fmt.Errorf("failed to import image: %v", err)
371- }
372-
373- if imageID == "" {
374- return "", errors.New("imported image ID is empty")
375- }
376-
377- err = store.RemoveFromStorage(imagePath)
378- if err != nil {
379- return "", fmt.Errorf("failed to remove temporary image file: %v", err)
380- }
381-
382- payload.ImageID = imageID
383- return imagePath, nil
384-}
385-
386-// createAndStartContainer creates and starts a new container
387-func createAndStartContainer(a *server.App, payload *common.DeployPayload) (string, string, error) {
388- params, err := cmdparams.FromPayloadStructToCmdParams(payload, a, payload.ImageID)
389- if err != nil {
390- return "", "", fmt.Errorf("failed to create command parameters: %v", err)
391- }
392-
393- containerID, err := docker.CreateContainer(params)
394- if err != nil {
395- return "", "", fmt.Errorf("failed to create container: %v", err)
396- }
397-
398- containerName, err := docker.GetContainerName(containerID)
399- if err != nil {
400- return "", "", fmt.Errorf("failed to get container name: %v", err)
401- }
402-
403- err = docker.StartContainer(containerID)
404- if err != nil {
405- docker.RemoveContainer(containerID)
406- return "", "", fmt.Errorf("failed to start container: %v", err)
407- }
408-
409- return containerID, containerName, nil
410-}
411-
412-// logResponse logs the deployment response
413-func logResponse(response DeployResponse) {
414- responseJSON, _ := json.Marshal(response)
415- log.Info("Deployment response", string(responseJSON))
416-}
A internal/httpserve/handlers/endpoint_deploy.go +275 -0
@@ -0,0 +1,275 @@
1+// endpoint_deploy.go
2+package handlers
3+
4+import (
5+ "bytes"
6+ "encoding/json"
7+ "fmt"
8+ "io"
9+ "net/http"
10+ "strings"
11+ "time"
12+
13+ authToken "github.com/bnema/gordon/internal/cli/auth"
14+ "github.com/bnema/gordon/internal/common"
15+ "github.com/bnema/gordon/internal/server"
16+ "github.com/bnema/gordon/pkg/docker"
17+ "github.com/bnema/gordon/pkg/store"
18+ "github.com/charmbracelet/log"
19+ "github.com/labstack/echo/v4"
20+)
21+
22+// PostDeploy handles the container deployment request
23+func PostDeploy(c echo.Context, a *server.App) error {
24+ chunkMetadataStr := c.Request().Header.Get("X-Chunk-Metadata")
25+
26+ return handleChunkedDeploy(c, a, chunkMetadataStr)
27+}
28+
29+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+ })
124+}
125+
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+ })
237+}
238+
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+}
@@ -0,0 +1,275 @@
1+// endpoint_deploy.go
2+package handlers
3+
4+import (
5+ "bytes"
6+ "encoding/json"
7+ "fmt"
8+ "io"
9+ "net/http"
10+ "strings"
11+ "time"
12+
13+ authToken "github.com/bnema/gordon/internal/cli/auth"
14+ "github.com/bnema/gordon/internal/common"
15+ "github.com/bnema/gordon/internal/server"
16+ "github.com/bnema/gordon/pkg/docker"
17+ "github.com/bnema/gordon/pkg/store"
18+ "github.com/charmbracelet/log"
19+ "github.com/labstack/echo/v4"
20+)
21+
22+// PostDeploy handles the container deployment request
23+func PostDeploy(c echo.Context, a *server.App) error {
24+ chunkMetadataStr := c.Request().Header.Get("X-Chunk-Metadata")
25+
26+ return handleChunkedDeploy(c, a, chunkMetadataStr)
27+}
28+
29+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+ })
124+}
125+
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+ })
237+}
238+
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+}
A internal/httpserve/handlers/endpoint_helpers.go +336 -0
@@ -0,0 +1,336 @@
1+// endpoint_helpers.go
2+package handlers
3+
4+import (
5+ "errors"
6+ "fmt"
7+ "net/http"
8+ "os"
9+ "regexp"
10+ "sync"
11+ "time"
12+
13+ "github.com/bnema/gordon/internal/common"
14+ "github.com/bnema/gordon/internal/server"
15+ "github.com/bnema/gordon/internal/templating/cmdparams"
16+ "github.com/bnema/gordon/pkg/docker"
17+ "github.com/bnema/gordon/pkg/store"
18+ "github.com/charmbracelet/log"
19+ "github.com/labstack/echo/v4"
20+)
21+
22+type ChunkMetadata common.ChunkMetadata
23+
24+type ChunkStore struct {
25+ mu sync.Mutex
26+ chunks map[string]map[int][]byte
27+ metadata map[string]*ChunkMetadata
28+ started map[string]time.Time
29+}
30+
31+type InfoResponse struct {
32+ Uptime string `json:"uptime"`
33+ Version string `json:"version"`
34+}
35+
36+type DeployResponse = common.DeployResponse
37+type PushResponse = common.PushResponse
38+type StopResponse = common.StopResponse
39+type RemoveResponse = common.RemoveResponse
40+
41+var chunkStore = &ChunkStore{
42+ chunks: make(map[string]map[int][]byte),
43+ metadata: make(map[string]*ChunkMetadata),
44+ started: make(map[string]time.Time),
45+}
46+
47+const (
48+ cleanupInterval = 5 * time.Minute
49+ transferTimeout = 30 * time.Minute
50+)
51+
52+func init() {
53+ // Start a cleanup goroutine
54+ go func() {
55+ ticker := time.NewTicker(5 * time.Minute)
56+ for range ticker.C {
57+ cleanupOldTransfers()
58+ }
59+ }()
60+}
61+
62+// Populate fills the InfoResponse struct with data from the App
63+func (info *InfoResponse) Populate(a *server.App) {
64+ info.Uptime = a.GetUptime()
65+ info.Version = a.GetVersionstring()
66+}
67+
68+func sendJSONResponse(c echo.Context, statusCode int, response interface{}) error {
69+ err := c.JSON(statusCode, response)
70+ if err != nil {
71+ log.Error("Failed to send JSON response",
72+ "error", err,
73+ "statusCode", statusCode)
74+ }
75+ return err
76+}
77+
78+// GetInfos handles GET requests on /api/ping endpoint
79+func GetInfos(c echo.Context, a *server.App) error {
80+ payload := new(common.RequestPayload)
81+ if err := c.Bind(payload); err != nil {
82+ return sendJSONResponse(c, http.StatusBadRequest, "Invalid payload: "+err.Error())
83+ }
84+
85+ if payload.Type != "ping" {
86+ return sendJSONResponse(c, http.StatusBadRequest, "Invalid payload type")
87+ }
88+
89+ pingPayload, ok := payload.Payload.(common.PingPayload)
90+ if !ok {
91+ return sendJSONResponse(c, http.StatusBadRequest, "Invalid payload structure")
92+ }
93+
94+ if pingPayload.Message != "ping" {
95+ return sendJSONResponse(c, http.StatusBadRequest, "Invalid ping message")
96+ }
97+
98+ info := &InfoResponse{}
99+ info.Populate(a)
100+
101+ return sendJSONResponse(c, http.StatusOK, info)
102+}
103+
104+// Helper functions for validation and preparation
105+func validateAndPreparePayload(c echo.Context) (*common.PushPayload, error) {
106+ payload := &common.PushPayload{
107+ ImageName: c.Request().Header.Get("X-Image-Name"),
108+ }
109+
110+ if payload.ImageName == "" {
111+ return nil, errors.New("invalid image name")
112+ }
113+
114+ return payload, nil
115+}
116+
117+func validateAndPrepareDeployPayload(c echo.Context) (*common.DeployPayload, error) {
118+ payload := &common.DeployPayload{
119+ Port: c.Request().Header.Get("X-Ports"),
120+ ImageName: c.Request().Header.Get("X-Image-Name"),
121+ TargetDomain: c.Request().Header.Get("X-Target-Domain"),
122+ }
123+
124+ if payload.ImageName == "" {
125+ return nil, errors.New("invalid image name")
126+ }
127+
128+ payload.Port = normalizePort(payload.Port)
129+ payload.TargetDomain = normalizeTargetDomain(payload.TargetDomain)
130+
131+ return payload, nil
132+}
133+
134+// Helper functions for saving and importing images
135+func saveAndImportImage(c echo.Context, a *server.App, payload *common.PushPayload) error {
136+ imageReader := c.Request().Body
137+ defer imageReader.Close()
138+
139+ imageFileName := sanitizeImageFileName(payload.ImageName)
140+
141+ imagePath, err := store.SaveImageToStorage(&a.Config, imageFileName, imageReader)
142+ if err != nil {
143+ return fmt.Errorf("failed to save image: %v", err)
144+ }
145+
146+ imageID, err := docker.ImportImageToEngine(imagePath)
147+ if err != nil {
148+ store.RemoveFromStorage(imagePath)
149+ return fmt.Errorf("failed to import image: %v", err)
150+ }
151+
152+ if imageID == "" {
153+ return errors.New("failed to import image")
154+ }
155+
156+ err = store.RemoveFromStorage(imagePath)
157+ if err != nil {
158+ return fmt.Errorf("failed to remove temporary image file: %v", err)
159+ }
160+
161+ payload.ImageID = imageID
162+ return nil
163+}
164+
165+func saveAndImportDeployImage(c echo.Context, a *server.App, payload *common.DeployPayload) (string, error) {
166+ imageReader := c.Request().Body
167+ defer imageReader.Close()
168+
169+ imageFileName := sanitizeImageFileName(payload.ImageName)
170+
171+ imagePath, err := store.SaveImageToStorage(&a.Config, imageFileName, imageReader)
172+ if err != nil {
173+ return "", fmt.Errorf("failed to save image: %v", err)
174+ }
175+
176+ imageID, err := docker.ImportImageToEngine(imagePath)
177+ if err != nil {
178+ store.RemoveFromStorage(imagePath)
179+ return "", fmt.Errorf("failed to import image: %v", err)
180+ }
181+
182+ if imageID == "" {
183+ return "", errors.New("imported image ID is empty")
184+ }
185+
186+ err = store.RemoveFromStorage(imagePath)
187+ if err != nil {
188+ return "", fmt.Errorf("failed to remove temporary image file: %v", err)
189+ }
190+
191+ payload.ImageID = imageID
192+ return imagePath, nil
193+}
194+
195+// Container management helpers
196+func createAndStartContainer(a *server.App, payload *common.DeployPayload) (string, string, error) {
197+ params, err := cmdparams.FromPayloadStructToCmdParams(payload, a, payload.ImageID)
198+ if err != nil {
199+ return "", "", fmt.Errorf("failed to create command parameters: %v", err)
200+ }
201+
202+ containerID, err := docker.CreateContainer(params)
203+ if err != nil {
204+ return "", "", fmt.Errorf("failed to create container: %v", err)
205+ }
206+
207+ containerName, err := docker.GetContainerName(containerID)
208+ if err != nil {
209+ return "", "", fmt.Errorf("failed to get container name: %v", err)
210+ }
211+
212+ err = docker.StartContainer(containerID)
213+ if err != nil {
214+ docker.RemoveContainer(containerID)
215+ return "", "", fmt.Errorf("failed to start container: %v", err)
216+ }
217+
218+ return containerID, containerName, nil
219+}
220+
221+// String manipulation helpers
222+func sanitizeImageFileName(imageName string) string {
223+ imageName = regexp.MustCompile(`^([a-zA-Z0-9\-_.]+\/)?`).ReplaceAllString(imageName, "")
224+ imageName = regexp.MustCompile(`(:[a-zA-Z0-9\-_.]+)?$`).ReplaceAllString(imageName, "")
225+ return imageName + ".tar"
226+}
227+
228+func normalizePort(port string) string {
229+ if !regexp.MustCompile(`\/(tcp|udp)$`).MatchString(port) {
230+ return port + "/tcp"
231+ }
232+ return port
233+}
234+
235+func normalizeTargetDomain(domain string) string {
236+ if !regexp.MustCompile(`^https?:\/\/`).MatchString(domain) {
237+ return "https://" + domain
238+ }
239+ return domain
240+}
241+
242+func generateShortID(imageID string) string {
243+ shortID := imageID[7:]
244+ if len(shortID) > 12 {
245+ shortID = shortID[:12]
246+ }
247+ return shortID
248+}
249+
250+func generateCreateContainerURL(a *server.App, shortID string) string {
251+ return fmt.Sprintf("%s://%s%s/cc/%s",
252+ a.Config.Http.Protocol(),
253+ a.Config.Http.FullDomain(),
254+ a.Config.Admin.Path,
255+ shortID)
256+}
257+
258+// Chunk management helpers
259+func isTransferComplete(transferID string) bool {
260+ chunkStore.mu.Lock()
261+ defer chunkStore.mu.Unlock()
262+
263+ metadata := chunkStore.metadata[transferID]
264+ if metadata == nil {
265+ log.Warn("No metadata found for transfer", "transferID", transferID)
266+ return false
267+ }
268+
269+ chunksReceived := len(chunkStore.chunks[transferID])
270+ isComplete := chunksReceived == metadata.TotalChunks
271+
272+ log.Debug("Transfer completion check",
273+ "transferID", transferID,
274+ "chunksReceived", chunksReceived,
275+ "totalExpected", metadata.TotalChunks,
276+ "isComplete", isComplete)
277+
278+ return isComplete
279+}
280+
281+func cleanupTransfer(transferID string) {
282+ chunkStore.mu.Lock()
283+ defer chunkStore.mu.Unlock()
284+
285+ delete(chunkStore.chunks, transferID)
286+ delete(chunkStore.metadata, transferID)
287+ delete(chunkStore.started, transferID)
288+}
289+
290+func cleanupOldTransfers() {
291+ ticker := time.NewTicker(cleanupInterval)
292+ defer ticker.Stop() // Good practice to clean up the ticker
293+
294+ for range ticker.C {
295+ now := time.Now()
296+ chunkStore.mu.Lock()
297+ for id, startTime := range chunkStore.started {
298+ if now.Sub(startTime) > transferTimeout {
299+ log.Debug("Cleaning up stale transfer",
300+ "transferID", id,
301+ "age", now.Sub(startTime))
302+ cleanupTransfer(id)
303+ }
304+ }
305+ chunkStore.mu.Unlock()
306+ }
307+}
308+
309+// Error extraction helper
310+func extractContainerID(errorMessage string) string {
311+ re := regexp.MustCompile(`by\s+([0-9a-f]+)\.`)
312+ match := re.FindStringSubmatch(errorMessage)
313+ if len(match) > 1 {
314+ return match[1]
315+ }
316+ return ""
317+}
318+
319+// File verification helper
320+func verifyFileContents(file *os.File, expectedSize int64) error {
321+ actualSize, err := file.Seek(0, 2) // Seek to end
322+ if err != nil {
323+ return fmt.Errorf("failed to verify file size: %w", err)
324+ }
325+
326+ if actualSize != expectedSize {
327+ return fmt.Errorf("final file size mismatch: got %d, expected %d", actualSize, expectedSize)
328+ }
329+
330+ _, err = file.Seek(0, 0) // Reset to beginning
331+ return err
332+}
333+
334+func storeIDMapping(shortID, fullID string) {
335+ safelyInteractWithIDMap(Update, shortID, fullID)
336+}
@@ -0,0 +1,336 @@
1+// endpoint_helpers.go
2+package handlers
3+
4+import (
5+ "errors"
6+ "fmt"
7+ "net/http"
8+ "os"
9+ "regexp"
10+ "sync"
11+ "time"
12+
13+ "github.com/bnema/gordon/internal/common"
14+ "github.com/bnema/gordon/internal/server"
15+ "github.com/bnema/gordon/internal/templating/cmdparams"
16+ "github.com/bnema/gordon/pkg/docker"
17+ "github.com/bnema/gordon/pkg/store"
18+ "github.com/charmbracelet/log"
19+ "github.com/labstack/echo/v4"
20+)
21+
22+type ChunkMetadata common.ChunkMetadata
23+
24+type ChunkStore struct {
25+ mu sync.Mutex
26+ chunks map[string]map[int][]byte
27+ metadata map[string]*ChunkMetadata
28+ started map[string]time.Time
29+}
30+
31+type InfoResponse struct {
32+ Uptime string `json:"uptime"`
33+ Version string `json:"version"`
34+}
35+
36+type DeployResponse = common.DeployResponse
37+type PushResponse = common.PushResponse
38+type StopResponse = common.StopResponse
39+type RemoveResponse = common.RemoveResponse
40+
41+var chunkStore = &ChunkStore{
42+ chunks: make(map[string]map[int][]byte),
43+ metadata: make(map[string]*ChunkMetadata),
44+ started: make(map[string]time.Time),
45+}
46+
47+const (
48+ cleanupInterval = 5 * time.Minute
49+ transferTimeout = 30 * time.Minute
50+)
51+
52+func init() {
53+ // Start a cleanup goroutine
54+ go func() {
55+ ticker := time.NewTicker(5 * time.Minute)
56+ for range ticker.C {
57+ cleanupOldTransfers()
58+ }
59+ }()
60+}
61+
62+// Populate fills the InfoResponse struct with data from the App
63+func (info *InfoResponse) Populate(a *server.App) {
64+ info.Uptime = a.GetUptime()
65+ info.Version = a.GetVersionstring()
66+}
67+
68+func sendJSONResponse(c echo.Context, statusCode int, response interface{}) error {
69+ err := c.JSON(statusCode, response)
70+ if err != nil {
71+ log.Error("Failed to send JSON response",
72+ "error", err,
73+ "statusCode", statusCode)
74+ }
75+ return err
76+}
77+
78+// GetInfos handles GET requests on /api/ping endpoint
79+func GetInfos(c echo.Context, a *server.App) error {
80+ payload := new(common.RequestPayload)
81+ if err := c.Bind(payload); err != nil {
82+ return sendJSONResponse(c, http.StatusBadRequest, "Invalid payload: "+err.Error())
83+ }
84+
85+ if payload.Type != "ping" {
86+ return sendJSONResponse(c, http.StatusBadRequest, "Invalid payload type")
87+ }
88+
89+ pingPayload, ok := payload.Payload.(common.PingPayload)
90+ if !ok {
91+ return sendJSONResponse(c, http.StatusBadRequest, "Invalid payload structure")
92+ }
93+
94+ if pingPayload.Message != "ping" {
95+ return sendJSONResponse(c, http.StatusBadRequest, "Invalid ping message")
96+ }
97+
98+ info := &InfoResponse{}
99+ info.Populate(a)
100+
101+ return sendJSONResponse(c, http.StatusOK, info)
102+}
103+
104+// Helper functions for validation and preparation
105+func validateAndPreparePayload(c echo.Context) (*common.PushPayload, error) {
106+ payload := &common.PushPayload{
107+ ImageName: c.Request().Header.Get("X-Image-Name"),
108+ }
109+
110+ if payload.ImageName == "" {
111+ return nil, errors.New("invalid image name")
112+ }
113+
114+ return payload, nil
115+}
116+
117+func validateAndPrepareDeployPayload(c echo.Context) (*common.DeployPayload, error) {
118+ payload := &common.DeployPayload{
119+ Port: c.Request().Header.Get("X-Ports"),
120+ ImageName: c.Request().Header.Get("X-Image-Name"),
121+ TargetDomain: c.Request().Header.Get("X-Target-Domain"),
122+ }
123+
124+ if payload.ImageName == "" {
125+ return nil, errors.New("invalid image name")
126+ }
127+
128+ payload.Port = normalizePort(payload.Port)
129+ payload.TargetDomain = normalizeTargetDomain(payload.TargetDomain)
130+
131+ return payload, nil
132+}
133+
134+// Helper functions for saving and importing images
135+func saveAndImportImage(c echo.Context, a *server.App, payload *common.PushPayload) error {
136+ imageReader := c.Request().Body
137+ defer imageReader.Close()
138+
139+ imageFileName := sanitizeImageFileName(payload.ImageName)
140+
141+ imagePath, err := store.SaveImageToStorage(&a.Config, imageFileName, imageReader)
142+ if err != nil {
143+ return fmt.Errorf("failed to save image: %v", err)
144+ }
145+
146+ imageID, err := docker.ImportImageToEngine(imagePath)
147+ if err != nil {
148+ store.RemoveFromStorage(imagePath)
149+ return fmt.Errorf("failed to import image: %v", err)
150+ }
151+
152+ if imageID == "" {
153+ return errors.New("failed to import image")
154+ }
155+
156+ err = store.RemoveFromStorage(imagePath)
157+ if err != nil {
158+ return fmt.Errorf("failed to remove temporary image file: %v", err)
159+ }
160+
161+ payload.ImageID = imageID
162+ return nil
163+}
164+
165+func saveAndImportDeployImage(c echo.Context, a *server.App, payload *common.DeployPayload) (string, error) {
166+ imageReader := c.Request().Body
167+ defer imageReader.Close()
168+
169+ imageFileName := sanitizeImageFileName(payload.ImageName)
170+
171+ imagePath, err := store.SaveImageToStorage(&a.Config, imageFileName, imageReader)
172+ if err != nil {
173+ return "", fmt.Errorf("failed to save image: %v", err)
174+ }
175+
176+ imageID, err := docker.ImportImageToEngine(imagePath)
177+ if err != nil {
178+ store.RemoveFromStorage(imagePath)
179+ return "", fmt.Errorf("failed to import image: %v", err)
180+ }
181+
182+ if imageID == "" {
183+ return "", errors.New("imported image ID is empty")
184+ }
185+
186+ err = store.RemoveFromStorage(imagePath)
187+ if err != nil {
188+ return "", fmt.Errorf("failed to remove temporary image file: %v", err)
189+ }
190+
191+ payload.ImageID = imageID
192+ return imagePath, nil
193+}
194+
195+// Container management helpers
196+func createAndStartContainer(a *server.App, payload *common.DeployPayload) (string, string, error) {
197+ params, err := cmdparams.FromPayloadStructToCmdParams(payload, a, payload.ImageID)
198+ if err != nil {
199+ return "", "", fmt.Errorf("failed to create command parameters: %v", err)
200+ }
201+
202+ containerID, err := docker.CreateContainer(params)
203+ if err != nil {
204+ return "", "", fmt.Errorf("failed to create container: %v", err)
205+ }
206+
207+ containerName, err := docker.GetContainerName(containerID)
208+ if err != nil {
209+ return "", "", fmt.Errorf("failed to get container name: %v", err)
210+ }
211+
212+ err = docker.StartContainer(containerID)
213+ if err != nil {
214+ docker.RemoveContainer(containerID)
215+ return "", "", fmt.Errorf("failed to start container: %v", err)
216+ }
217+
218+ return containerID, containerName, nil
219+}
220+
221+// String manipulation helpers
222+func sanitizeImageFileName(imageName string) string {
223+ imageName = regexp.MustCompile(`^([a-zA-Z0-9\-_.]+\/)?`).ReplaceAllString(imageName, "")
224+ imageName = regexp.MustCompile(`(:[a-zA-Z0-9\-_.]+)?$`).ReplaceAllString(imageName, "")
225+ return imageName + ".tar"
226+}
227+
228+func normalizePort(port string) string {
229+ if !regexp.MustCompile(`\/(tcp|udp)$`).MatchString(port) {
230+ return port + "/tcp"
231+ }
232+ return port
233+}
234+
235+func normalizeTargetDomain(domain string) string {
236+ if !regexp.MustCompile(`^https?:\/\/`).MatchString(domain) {
237+ return "https://" + domain
238+ }
239+ return domain
240+}
241+
242+func generateShortID(imageID string) string {
243+ shortID := imageID[7:]
244+ if len(shortID) > 12 {
245+ shortID = shortID[:12]
246+ }
247+ return shortID
248+}
249+
250+func generateCreateContainerURL(a *server.App, shortID string) string {
251+ return fmt.Sprintf("%s://%s%s/cc/%s",
252+ a.Config.Http.Protocol(),
253+ a.Config.Http.FullDomain(),
254+ a.Config.Admin.Path,
255+ shortID)
256+}
257+
258+// Chunk management helpers
259+func isTransferComplete(transferID string) bool {
260+ chunkStore.mu.Lock()
261+ defer chunkStore.mu.Unlock()
262+
263+ metadata := chunkStore.metadata[transferID]
264+ if metadata == nil {
265+ log.Warn("No metadata found for transfer", "transferID", transferID)
266+ return false
267+ }
268+
269+ chunksReceived := len(chunkStore.chunks[transferID])
270+ isComplete := chunksReceived == metadata.TotalChunks
271+
272+ log.Debug("Transfer completion check",
273+ "transferID", transferID,
274+ "chunksReceived", chunksReceived,
275+ "totalExpected", metadata.TotalChunks,
276+ "isComplete", isComplete)
277+
278+ return isComplete
279+}
280+
281+func cleanupTransfer(transferID string) {
282+ chunkStore.mu.Lock()
283+ defer chunkStore.mu.Unlock()
284+
285+ delete(chunkStore.chunks, transferID)
286+ delete(chunkStore.metadata, transferID)
287+ delete(chunkStore.started, transferID)
288+}
289+
290+func cleanupOldTransfers() {
291+ ticker := time.NewTicker(cleanupInterval)
292+ defer ticker.Stop() // Good practice to clean up the ticker
293+
294+ for range ticker.C {
295+ now := time.Now()
296+ chunkStore.mu.Lock()
297+ for id, startTime := range chunkStore.started {
298+ if now.Sub(startTime) > transferTimeout {
299+ log.Debug("Cleaning up stale transfer",
300+ "transferID", id,
301+ "age", now.Sub(startTime))
302+ cleanupTransfer(id)
303+ }
304+ }
305+ chunkStore.mu.Unlock()
306+ }
307+}
308+
309+// Error extraction helper
310+func extractContainerID(errorMessage string) string {
311+ re := regexp.MustCompile(`by\s+([0-9a-f]+)\.`)
312+ match := re.FindStringSubmatch(errorMessage)
313+ if len(match) > 1 {
314+ return match[1]
315+ }
316+ return ""
317+}
318+
319+// File verification helper
320+func verifyFileContents(file *os.File, expectedSize int64) error {
321+ actualSize, err := file.Seek(0, 2) // Seek to end
322+ if err != nil {
323+ return fmt.Errorf("failed to verify file size: %w", err)
324+ }
325+
326+ if actualSize != expectedSize {
327+ return fmt.Errorf("final file size mismatch: got %d, expected %d", actualSize, expectedSize)
328+ }
329+
330+ _, err = file.Seek(0, 0) // Reset to beginning
331+ return err
332+}
333+
334+func storeIDMapping(shortID, fullID string) {
335+ safelyInteractWithIDMap(Update, shortID, fullID)
336+}
A internal/httpserve/handlers/endpoint_push.go +199 -0
@@ -0,0 +1,199 @@
1+// endpoint_push.go
2+package handlers
3+
4+import (
5+ "bytes"
6+ "encoding/json"
7+ "fmt"
8+ "io"
9+ "net/http"
10+ "os"
11+
12+ "github.com/bnema/gordon/internal/common"
13+ "github.com/bnema/gordon/internal/server"
14+ "github.com/bnema/gordon/pkg/docker"
15+ "github.com/bnema/gordon/pkg/store"
16+ "github.com/charmbracelet/log"
17+ "github.com/labstack/echo/v4"
18+)
19+
20+// PostPush handles the image push request
21+func PostPush(c echo.Context, a *server.App) error {
22+
23+ requestID := c.Response().Header().Get(echo.HeaderXRequestID)
24+ log.Debug("PostPush started",
25+ "requestID", requestID,
26+ "path", c.Request().URL.Path)
27+
28+ chunkMetadataStr := c.Request().Header.Get("X-Chunk-Metadata")
29+ if chunkMetadataStr != "" {
30+ return handleChunkedPush(c, a, chunkMetadataStr)
31+ }
32+
33+ return handleRegularPush(c, a)
34+}
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 {
38+ return sendJSONResponse(c, http.StatusBadRequest, PushResponse{
39+ Success: false,
40+ Message: "Invalid chunk metadata",
41+ })
42+ }
43+
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+ }
53+ }
54+
55+ // Open file in append mode
56+ f, err := os.OpenFile(transferPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
57+ if err != nil {
58+ return sendJSONResponse(c, http.StatusInternalServerError, PushResponse{
59+ 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),
70+ })
71+ }
72+
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)
93+
94+ return sendJSONResponse(c, http.StatusOK, PushResponse{
95+ Success: true,
96+ Message: "Image pushed and imported successfully",
97+ CreateContainerURL: createContainerURL,
98+ ImageID: imageID,
99+ })
100+ }
101+
102+ // For intermediate chunks, just acknowledge receipt
103+ return sendJSONResponse(c, http.StatusOK, PushResponse{
104+ Success: true,
105+ Message: fmt.Sprintf("Chunk %d/%d received", metadata.ChunkNumber+1, metadata.TotalChunks),
106+ })
107+}
108+
109+func handleRegularPush(c echo.Context, a *server.App) error {
110+ payload, err := validateAndPreparePayload(c)
111+ if err != nil {
112+ return sendJSONResponse(c, http.StatusBadRequest, PushResponse{
113+ Success: false,
114+ Message: err.Error(),
115+ })
116+ }
117+
118+ err = saveAndImportImage(c, a, payload)
119+ if err != nil {
120+ return sendJSONResponse(c, http.StatusInternalServerError, PushResponse{
121+ Success: false,
122+ Message: err.Error(),
123+ })
124+ }
125+
126+ shortID := generateShortID(payload.ImageID)
127+ storeIDMapping(shortID, payload.ImageID)
128+ createContainerURL := generateCreateContainerURL(a, shortID)
129+
130+ return sendJSONResponse(c, http.StatusOK, PushResponse{
131+ Success: true,
132+ Message: "Image pushed and imported successfully",
133+ CreateContainerURL: createContainerURL,
134+ })
135+}
136+
137+func processCompletePushTransfer(c echo.Context, a *server.App, transferID string) error {
138+ chunkStore.mu.Lock()
139+ metadata := chunkStore.metadata[transferID]
140+ chunks := chunkStore.chunks[transferID]
141+ chunkStore.mu.Unlock()
142+
143+ if metadata == nil {
144+ return sendJSONResponse(c, http.StatusBadRequest, PushResponse{
145+ Success: false,
146+ Message: "Transfer metadata not found",
147+ })
148+ }
149+
150+ // Combine chunks
151+ combinedData := bytes.NewBuffer(nil)
152+ for i := 0; i < metadata.TotalChunks; i++ {
153+ chunk, exists := chunks[i]
154+ if !exists {
155+ return sendJSONResponse(c, http.StatusBadRequest, PushResponse{
156+ Success: false,
157+ Message: fmt.Sprintf("Missing chunk %d", i),
158+ })
159+ }
160+ combinedData.Write(chunk)
161+ }
162+
163+ // Clean up the transfer data early
164+ defer cleanupTransfer(transferID)
165+
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+ })
174+ }
175+
176+ imageID, err := docker.ImportImageToEngine(imagePath)
177+ 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+ })
183+ }
184+
185+ if err := store.RemoveFromStorage(imagePath); err != nil {
186+ log.Warn("Failed to remove temporary image file", "error", err)
187+ }
188+
189+ shortID := generateShortID(imageID)
190+ storeIDMapping(shortID, imageID)
191+ createContainerURL := generateCreateContainerURL(a, shortID)
192+
193+ return sendJSONResponse(c, http.StatusOK, PushResponse{
194+ Success: true,
195+ Message: "Image pushed and imported successfully",
196+ CreateContainerURL: createContainerURL,
197+ ImageID: imageID,
198+ })
199+}
@@ -0,0 +1,199 @@
1+// endpoint_push.go
2+package handlers
3+
4+import (
5+ "bytes"
6+ "encoding/json"
7+ "fmt"
8+ "io"
9+ "net/http"
10+ "os"
11+
12+ "github.com/bnema/gordon/internal/common"
13+ "github.com/bnema/gordon/internal/server"
14+ "github.com/bnema/gordon/pkg/docker"
15+ "github.com/bnema/gordon/pkg/store"
16+ "github.com/charmbracelet/log"
17+ "github.com/labstack/echo/v4"
18+)
19+
20+// PostPush handles the image push request
21+func PostPush(c echo.Context, a *server.App) error {
22+
23+ requestID := c.Response().Header().Get(echo.HeaderXRequestID)
24+ log.Debug("PostPush started",
25+ "requestID", requestID,
26+ "path", c.Request().URL.Path)
27+
28+ chunkMetadataStr := c.Request().Header.Get("X-Chunk-Metadata")
29+ if chunkMetadataStr != "" {
30+ return handleChunkedPush(c, a, chunkMetadataStr)
31+ }
32+
33+ return handleRegularPush(c, a)
34+}
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 {
38+ return sendJSONResponse(c, http.StatusBadRequest, PushResponse{
39+ Success: false,
40+ Message: "Invalid chunk metadata",
41+ })
42+ }
43+
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+ }
53+ }
54+
55+ // Open file in append mode
56+ f, err := os.OpenFile(transferPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
57+ if err != nil {
58+ return sendJSONResponse(c, http.StatusInternalServerError, PushResponse{
59+ 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),
70+ })
71+ }
72+
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)
93+
94+ return sendJSONResponse(c, http.StatusOK, PushResponse{
95+ Success: true,
96+ Message: "Image pushed and imported successfully",
97+ CreateContainerURL: createContainerURL,
98+ ImageID: imageID,
99+ })
100+ }
101+
102+ // For intermediate chunks, just acknowledge receipt
103+ return sendJSONResponse(c, http.StatusOK, PushResponse{
104+ Success: true,
105+ Message: fmt.Sprintf("Chunk %d/%d received", metadata.ChunkNumber+1, metadata.TotalChunks),
106+ })
107+}
108+
109+func handleRegularPush(c echo.Context, a *server.App) error {
110+ payload, err := validateAndPreparePayload(c)
111+ if err != nil {
112+ return sendJSONResponse(c, http.StatusBadRequest, PushResponse{
113+ Success: false,
114+ Message: err.Error(),
115+ })
116+ }
117+
118+ err = saveAndImportImage(c, a, payload)
119+ if err != nil {
120+ return sendJSONResponse(c, http.StatusInternalServerError, PushResponse{
121+ Success: false,
122+ Message: err.Error(),
123+ })
124+ }
125+
126+ shortID := generateShortID(payload.ImageID)
127+ storeIDMapping(shortID, payload.ImageID)
128+ createContainerURL := generateCreateContainerURL(a, shortID)
129+
130+ return sendJSONResponse(c, http.StatusOK, PushResponse{
131+ Success: true,
132+ Message: "Image pushed and imported successfully",
133+ CreateContainerURL: createContainerURL,
134+ })
135+}
136+
137+func processCompletePushTransfer(c echo.Context, a *server.App, transferID string) error {
138+ chunkStore.mu.Lock()
139+ metadata := chunkStore.metadata[transferID]
140+ chunks := chunkStore.chunks[transferID]
141+ chunkStore.mu.Unlock()
142+
143+ if metadata == nil {
144+ return sendJSONResponse(c, http.StatusBadRequest, PushResponse{
145+ Success: false,
146+ Message: "Transfer metadata not found",
147+ })
148+ }
149+
150+ // Combine chunks
151+ combinedData := bytes.NewBuffer(nil)
152+ for i := 0; i < metadata.TotalChunks; i++ {
153+ chunk, exists := chunks[i]
154+ if !exists {
155+ return sendJSONResponse(c, http.StatusBadRequest, PushResponse{
156+ Success: false,
157+ Message: fmt.Sprintf("Missing chunk %d", i),
158+ })
159+ }
160+ combinedData.Write(chunk)
161+ }
162+
163+ // Clean up the transfer data early
164+ defer cleanupTransfer(transferID)
165+
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+ })
174+ }
175+
176+ imageID, err := docker.ImportImageToEngine(imagePath)
177+ 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+ })
183+ }
184+
185+ if err := store.RemoveFromStorage(imagePath); err != nil {
186+ log.Warn("Failed to remove temporary image file", "error", err)
187+ }
188+
189+ shortID := generateShortID(imageID)
190+ storeIDMapping(shortID, imageID)
191+ createContainerURL := generateCreateContainerURL(a, shortID)
192+
193+ return sendJSONResponse(c, http.StatusOK, PushResponse{
194+ Success: true,
195+ Message: "Image pushed and imported successfully",
196+ CreateContainerURL: createContainerURL,
197+ ImageID: imageID,
198+ })
199+}
A internal/httpserve/handlers/endpoint_remove.go +44 -0
@@ -0,0 +1,44 @@
1+package handlers
2+
3+import (
4+ "net/http"
5+
6+ "github.com/bnema/gordon/internal/common"
7+ "github.com/bnema/gordon/internal/server"
8+ "github.com/bnema/gordon/pkg/docker"
9+ "github.com/labstack/echo/v4"
10+)
11+
12+// PostContainerRemove handles the container remove request
13+func PostContainerRemove(c echo.Context, a *server.App) error {
14+ payload := new(common.RequestPayload)
15+ if err := c.Bind(payload); err != nil {
16+ return sendJSONResponse(c, http.StatusBadRequest, "Invalid payload: "+err.Error())
17+ }
18+
19+ if payload.Type != "remove" {
20+ return sendJSONResponse(c, http.StatusBadRequest, "Invalid payload type")
21+ }
22+
23+ removePayload, ok := payload.Payload.(common.RemovePayload)
24+ if !ok {
25+ return sendJSONResponse(c, http.StatusBadRequest, "Invalid payload structure")
26+ }
27+
28+ if removePayload.ContainerID == "" {
29+ return sendJSONResponse(c, http.StatusBadRequest, "Invalid container ID")
30+ }
31+
32+ err := docker.RemoveContainer(removePayload.ContainerID)
33+ if err != nil {
34+ return sendJSONResponse(c, http.StatusInternalServerError, RemoveResponse{
35+ Success: false,
36+ Message: err.Error(),
37+ })
38+ }
39+
40+ return sendJSONResponse(c, http.StatusOK, RemoveResponse{
41+ Success: true,
42+ Message: "Container removed successfully",
43+ })
44+}
@@ -0,0 +1,44 @@
1+package handlers
2+
3+import (
4+ "net/http"
5+
6+ "github.com/bnema/gordon/internal/common"
7+ "github.com/bnema/gordon/internal/server"
8+ "github.com/bnema/gordon/pkg/docker"
9+ "github.com/labstack/echo/v4"
10+)
11+
12+// PostContainerRemove handles the container remove request
13+func PostContainerRemove(c echo.Context, a *server.App) error {
14+ payload := new(common.RequestPayload)
15+ if err := c.Bind(payload); err != nil {
16+ return sendJSONResponse(c, http.StatusBadRequest, "Invalid payload: "+err.Error())
17+ }
18+
19+ if payload.Type != "remove" {
20+ return sendJSONResponse(c, http.StatusBadRequest, "Invalid payload type")
21+ }
22+
23+ removePayload, ok := payload.Payload.(common.RemovePayload)
24+ if !ok {
25+ return sendJSONResponse(c, http.StatusBadRequest, "Invalid payload structure")
26+ }
27+
28+ if removePayload.ContainerID == "" {
29+ return sendJSONResponse(c, http.StatusBadRequest, "Invalid container ID")
30+ }
31+
32+ err := docker.RemoveContainer(removePayload.ContainerID)
33+ if err != nil {
34+ return sendJSONResponse(c, http.StatusInternalServerError, RemoveResponse{
35+ Success: false,
36+ Message: err.Error(),
37+ })
38+ }
39+
40+ return sendJSONResponse(c, http.StatusOK, RemoveResponse{
41+ Success: true,
42+ Message: "Container removed successfully",
43+ })
44+}
A internal/httpserve/handlers/endpoint_stop.go +44 -0
@@ -0,0 +1,44 @@
1+package handlers
2+
3+import (
4+ "net/http"
5+
6+ "github.com/bnema/gordon/internal/common"
7+ "github.com/bnema/gordon/internal/server"
8+ "github.com/bnema/gordon/pkg/docker"
9+ "github.com/labstack/echo/v4"
10+)
11+
12+// PostContainerStop handles the container stop request
13+func PostContainerStop(c echo.Context, a *server.App) error {
14+ payload := new(common.RequestPayload)
15+ if err := c.Bind(payload); err != nil {
16+ return sendJSONResponse(c, http.StatusBadRequest, "Invalid payload: "+err.Error())
17+ }
18+
19+ if payload.Type != "stop" {
20+ return sendJSONResponse(c, http.StatusBadRequest, "Invalid payload type")
21+ }
22+
23+ stopPayload, ok := payload.Payload.(common.StopPayload)
24+ if !ok {
25+ return sendJSONResponse(c, http.StatusBadRequest, "Invalid payload structure")
26+ }
27+
28+ if stopPayload.ContainerID == "" {
29+ return sendJSONResponse(c, http.StatusBadRequest, "Invalid container ID")
30+ }
31+
32+ err := docker.StopContainer(stopPayload.ContainerID)
33+ if err != nil {
34+ return sendJSONResponse(c, http.StatusInternalServerError, StopResponse{
35+ Success: false,
36+ Message: err.Error(),
37+ })
38+ }
39+
40+ return sendJSONResponse(c, http.StatusOK, StopResponse{
41+ Success: true,
42+ Message: "Container stopped successfully",
43+ })
44+}
@@ -0,0 +1,44 @@
1+package handlers
2+
3+import (
4+ "net/http"
5+
6+ "github.com/bnema/gordon/internal/common"
7+ "github.com/bnema/gordon/internal/server"
8+ "github.com/bnema/gordon/pkg/docker"
9+ "github.com/labstack/echo/v4"
10+)
11+
12+// PostContainerStop handles the container stop request
13+func PostContainerStop(c echo.Context, a *server.App) error {
14+ payload := new(common.RequestPayload)
15+ if err := c.Bind(payload); err != nil {
16+ return sendJSONResponse(c, http.StatusBadRequest, "Invalid payload: "+err.Error())
17+ }
18+
19+ if payload.Type != "stop" {
20+ return sendJSONResponse(c, http.StatusBadRequest, "Invalid payload type")
21+ }
22+
23+ stopPayload, ok := payload.Payload.(common.StopPayload)
24+ if !ok {
25+ return sendJSONResponse(c, http.StatusBadRequest, "Invalid payload structure")
26+ }
27+
28+ if stopPayload.ContainerID == "" {
29+ return sendJSONResponse(c, http.StatusBadRequest, "Invalid container ID")
30+ }
31+
32+ err := docker.StopContainer(stopPayload.ContainerID)
33+ if err != nil {
34+ return sendJSONResponse(c, http.StatusInternalServerError, StopResponse{
35+ Success: false,
36+ Message: err.Error(),
37+ })
38+ }
39+
40+ return sendJSONResponse(c, http.StatusOK, StopResponse{
41+ Success: true,
42+ Message: "Container stopped successfully",
43+ })
44+}
M internal/httpserve/handlers/login.go +5 -5
@@ -163,17 +163,17 @@ func OAuthCallback(c echo.Context, a *server.App) error {
163163 }
164164
165165 browserInfo := c.Request().UserAgent()
166- userInfo, err := githubGetUserDetails(c, a)
166+ userInfo, err := githubGetUserDetails(c)
167167 if err != nil {
168168 return err
169169 }
170170
171- user, err := handleUser(c, a, accessToken, browserInfo, userInfo)
171+ _, err = handleUser(c, a, accessToken, browserInfo, userInfo)
172172 if err != nil {
173173 return err
174174 }
175175
176- err = setSessionValues(c, sess, user, a.DBTables.Account.ID, a.DBTables.Sessions.Expires, a.DBTables.Sessions.ID)
176+ err = setSessionValues(c, sess, a.DBTables.Account.ID, a.DBTables.Sessions.Expires, a.DBTables.Sessions.ID)
177177 if err != nil {
178178 return err
179179 }
@@ -263,7 +263,7 @@ func updateUser(a *server.App, accessToken, browserInfo string, userInfo *querie
263263 }
264264
265265 // setSessionValues sets the session values for the user
266-func setSessionValues(c echo.Context, sess *sessions.Session, user *db.User, accountID string, expires string, sessionID string) error {
266+func setSessionValues(c echo.Context, sess *sessions.Session, accountID string, expires string, sessionID string) error {
267267 sess.Values["authenticated"] = true
268268 sess.Values["accountID"] = accountID
269269 sess.Values["expires"] = expires
@@ -308,7 +308,7 @@ func fetchGithubAPI(url string, authHeader string, result interface{}) error {
308308 }
309309
310310 // githubGetUserDetails gets the user details from the Github API using the access token
311-func githubGetUserDetails(c echo.Context, a *server.App) (userInfo *queries.GithubUserInfo, err error) {
311+func githubGetUserDetails(c echo.Context) (userInfo *queries.GithubUserInfo, err error) {
312312 accessToken := c.QueryParam("access_token")
313313
314314 // Fetch user info
@@ -163,17 +163,17 @@ func OAuthCallback(c echo.Context, a *server.App) error {
163 }163 }
164 164
165 browserInfo := c.Request().UserAgent()165 browserInfo := c.Request().UserAgent()
166- userInfo, err := githubGetUserDetails(c, a)166+ userInfo, err := githubGetUserDetails(c)
167 if err != nil {167 if err != nil {
168 return err168 return err
169 }169 }
170 170
171- user, err := handleUser(c, a, accessToken, browserInfo, userInfo)171+ _, err = handleUser(c, a, accessToken, browserInfo, userInfo)
172 if err != nil {172 if err != nil {
173 return err173 return err
174 }174 }
175 175
176- err = setSessionValues(c, sess, user, a.DBTables.Account.ID, a.DBTables.Sessions.Expires, a.DBTables.Sessions.ID)176+ err = setSessionValues(c, sess, a.DBTables.Account.ID, a.DBTables.Sessions.Expires, a.DBTables.Sessions.ID)
177 if err != nil {177 if err != nil {
178 return err178 return err
179 }179 }
@@ -263,7 +263,7 @@ func updateUser(a *server.App, accessToken, browserInfo string, userInfo *querie
263 }263 }
264 264
265 // setSessionValues sets the session values for the user265 // setSessionValues sets the session values for the user
266-func setSessionValues(c echo.Context, sess *sessions.Session, user *db.User, accountID string, expires string, sessionID string) error {266+func setSessionValues(c echo.Context, sess *sessions.Session, accountID string, expires string, sessionID string) error {
267 sess.Values["authenticated"] = true267 sess.Values["authenticated"] = true
268 sess.Values["accountID"] = accountID268 sess.Values["accountID"] = accountID
269 sess.Values["expires"] = expires269 sess.Values["expires"] = expires
@@ -308,7 +308,7 @@ func fetchGithubAPI(url string, authHeader string, result interface{}) error {
308 }308 }
309 309
310 // githubGetUserDetails gets the user details from the Github API using the access token310 // githubGetUserDetails gets the user details from the Github API using the access token
311-func githubGetUserDetails(c echo.Context, a *server.App) (userInfo *queries.GithubUserInfo, err error) {311+func githubGetUserDetails(c echo.Context) (userInfo *queries.GithubUserInfo, err error) {
312 accessToken := c.QueryParam("access_token")312 accessToken := c.QueryParam("access_token")
313 313
314 // Fetch user info314 // Fetch user info
M internal/httpserve/middleware/error.go +7 -9
@@ -1,9 +1,7 @@
11 package middleware
22
33 import (
4- "log"
5- "net/http"
6-
4+ "github.com/charmbracelet/log"
75 "github.com/labstack/echo/v4"
86 )
97
@@ -11,12 +9,12 @@ func ErrorHandler(next echo.HandlerFunc) echo.HandlerFunc {
119 return func(c echo.Context) error {
1210 err := next(c)
1311 if err != nil {
14- log.Println("Error encountered:", err)
15- if he, ok := err.(*echo.HTTPError); ok {
16- return c.String(he.Code, he.Message.(string))
17- }
18- return c.String(http.StatusInternalServerError, "Internal Server Error")
12+ log.Error("ErrorHandler caught error",
13+ "path", c.Request().URL.Path,
14+ "method", c.Request().Method,
15+ "error", err,
16+ "status", c.Response().Status)
1917 }
20- return nil
18+ return err
2119 }
2220 }
@@ -1,9 +1,7 @@
1 package middleware1 package middleware
2 2
3 import (3 import (
4- "log"4+ "github.com/charmbracelet/log"
5- "net/http"
6-
7 "github.com/labstack/echo/v4"5 "github.com/labstack/echo/v4"
8 )6 )
9 7
@@ -11,12 +9,12 @@ func ErrorHandler(next echo.HandlerFunc) echo.HandlerFunc {
11 return func(c echo.Context) error {9 return func(c echo.Context) error {
12 err := next(c)10 err := next(c)
13 if err != nil {11 if err != nil {
14- log.Println("Error encountered:", err)12+ log.Error("ErrorHandler caught error",
15- if he, ok := err.(*echo.HTTPError); ok {13+ "path", c.Request().URL.Path,
16- return c.String(he.Code, he.Message.(string))14+ "method", c.Request().Method,
17- }15+ "error", err,
18- return c.String(http.StatusInternalServerError, "Internal Server Error")16+ "status", c.Response().Status)
19 }17 }
20- return nil18+ return err
21 }19 }
22 }20 }
M internal/httpserve/middleware/requiretoken.go +83 -46
@@ -6,74 +6,89 @@ import (
66 "io"
77 "net/http"
88 "strings"
9+ "sync"
10+ "time"
911
12+ "github.com/bnema/gordon/internal/cli/auth"
1013 "github.com/bnema/gordon/internal/db/queries"
1114 "github.com/bnema/gordon/internal/server"
1215 "github.com/charmbracelet/log"
1316 "github.com/labstack/echo/v4"
1417 )
1518
16-// GithubUserInfo is an alias for the type defined in the queries package
1719 type GithubUserInfo = queries.GithubUserInfo
1820
19-// RequireToken is a middleware that checks for a valid GitHub token in the request
21+// middleware/require_token.go
2022 func RequireToken(a *server.App) echo.MiddlewareFunc {
23+ tokenCache := auth.GetTokenCache()
24+
2125 return func(next echo.HandlerFunc) echo.HandlerFunc {
2226 return func(c echo.Context) error {
23- log.Debug("Starting RequireToken middleware")
24-
25- githubUser, err := validateGitHubToken(c)
26- if err != nil {
27- log.Error("Token validation failed", "error", err)
28- return c.JSON(http.StatusUnauthorized, map[string]string{"error": "Invalid or missing token"})
27+ token := strings.TrimPrefix(c.Request().Header.Get("Authorization"), "Bearer ")
28+ if token == "" {
29+ return c.JSON(http.StatusUnauthorized, map[string]string{
30+ "error": "Token is missing",
31+ })
2932 }
30- log.Debug("GitHub token validated successfully", "user", githubUser.Login)
3133
32- // Check if the user exists and matches the GitHub user
33- isValid, err := queries.CheckDBUserIsGood(a, githubUser)
34- if err != nil {
35- log.Error("Error checking user authorization", "error", err)
36- return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Error checking user authorization"})
34+ // Check cache first
35+ if _, cachedUser, found := tokenCache.GetWithUser(token); found {
36+ c.Set("user", cachedUser)
37+ return next(c)
3738 }
3839
39- if !isValid {
40- log.Warn("User not authorized", "user", githubUser.Login)
41- return c.JSON(http.StatusForbidden, map[string]string{"error": "User not authorized"})
42- }
43- log.Debug("User authorization check passed", "user", githubUser.Login)
44-
45- // Update or create session
46- err = queries.CreateOrUpdateDBSession(a, c.Request().Header.Get("Authorization"), c.Request().UserAgent())
40+ // Validate with GitHub only for new tokens
41+ githubUser, err := validateGitHubToken(c)
4742 if err != nil {
48- log.Error("Error updating session", "error", err)
49- return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Error updating session"})
43+ return c.JSON(http.StatusUnauthorized, map[string]string{
44+ "error": "Invalid token",
45+ })
5046 }
51- log.Debug("Session created or updated successfully", "user", githubUser.Login)
5247
53- // Store the user information in the context for later use
48+ tokenCache.SetWithUser(token, token, githubUser, 15*time.Minute)
5449 c.Set("user", githubUser)
55- log.Debug("User information stored in context", "user", githubUser.Login)
56-
5750 return next(c)
5851 }
5952 }
6053 }
6154
55+var sessionUpdateCache sync.Map
56+
57+func shouldUpdateSession(token string) bool {
58+ key := token
59+ now := time.Now()
60+
61+ if lastUpdate, exists := sessionUpdateCache.Load(key); exists {
62+ if now.Sub(lastUpdate.(time.Time)) < 5*time.Minute {
63+ log.Debug("skipping session update", "reason", "too_recent")
64+ return false
65+ }
66+ }
67+
68+ sessionUpdateCache.Store(key, now)
69+ log.Debug("session update required")
70+ return true
71+}
72+
6273 func validateGitHubToken(c echo.Context) (*GithubUserInfo, error) {
74+ requestID := c.Response().Header().Get(echo.HeaderXRequestID)
6375 token := c.Request().Header.Get("Authorization")
6476 if token == "" {
65- log.Warn("Token is missing in the request")
77+ log.Warn("missing token in validation request",
78+ "request_id", requestID)
6679 return nil, echo.NewHTTPError(http.StatusUnauthorized, "Token is missing")
6780 }
6881
6982 token = strings.TrimPrefix(token, "Bearer ")
70- log.Debug("Validating GitHub token")
83+ log.Debug("validating GitHub token",
84+ "request_id", requestID)
7185
72- // Validate the token against GitHub API
7386 client := &http.Client{}
7487 req, err := http.NewRequest("GET", "https://api.github.com/user", nil)
7588 if err != nil {
76- log.Error("Failed to create request", "error", err)
89+ log.Error("failed to create GitHub API request",
90+ "request_id", requestID,
91+ "error", err)
7792 return nil, fmt.Errorf("failed to create request: %w", err)
7893 }
7994
@@ -82,48 +97,66 @@ func validateGitHubToken(c echo.Context) (*GithubUserInfo, error) {
8297
8398 resp, err := client.Do(req)
8499 if err != nil {
85- log.Error("Failed to send request to GitHub API", "error", err)
100+ log.Error("GitHub API request failed",
101+ "request_id", requestID,
102+ "error", err)
86103 return nil, fmt.Errorf("failed to send request: %w", err)
87104 }
88105 defer resp.Body.Close()
89106
90107 if resp.StatusCode != http.StatusOK {
91- log.Warn("Invalid token", "status", resp.Status)
108+ log.Warn("invalid GitHub token",
109+ "request_id", requestID,
110+ "status", resp.Status)
92111 return nil, echo.NewHTTPError(http.StatusUnauthorized, "Invalid token")
93112 }
94113
95114 body, err := io.ReadAll(resp.Body)
96115 if err != nil {
97- log.Error("Failed to read response body", "error", err)
116+ log.Error("failed to read GitHub response",
117+ "request_id", requestID,
118+ "error", err)
98119 return nil, fmt.Errorf("failed to read response body: %w", err)
99120 }
100121
101122 var githubUser GithubUserInfo
102123 if err := json.Unmarshal(body, &githubUser); err != nil {
103- log.Error("Failed to parse user data", "error", err)
124+ log.Error("failed to parse GitHub user data",
125+ "request_id", requestID,
126+ "error", err)
104127 return nil, fmt.Errorf("failed to parse user data: %w", err)
105128 }
106129
107- log.Debug("GitHub user info fetched successfully", "user", githubUser.Login)
130+ log.Debug("GitHub user info fetched",
131+ "request_id", requestID,
132+ "user", githubUser.Login)
108133
109- // Fetch user's email
110134 emails, err := fetchGitHubUserEmails(token)
111135 if err != nil {
112- log.Error("Failed to fetch user emails", "error", err)
136+ log.Error("failed to fetch GitHub user emails",
137+ "request_id", requestID,
138+ "user", githubUser.Login,
139+ "error", err)
113140 return nil, fmt.Errorf("failed to fetch user emails: %w", err)
114141 }
115142 githubUser.Emails = emails
116- log.Debug("GitHub user emails fetched successfully", "user", githubUser.Login, "emailCount", len(emails))
143+
144+ log.Debug("completed GitHub user validation",
145+ "request_id", requestID,
146+ "user", githubUser.Login,
147+ "email_count", len(emails))
117148
118149 return &githubUser, nil
119150 }
120151
121152 func fetchGitHubUserEmails(token string) ([]string, error) {
122- log.Debug("Fetching GitHub user emails")
153+ log.Debug("fetching GitHub user emails")
154+
123155 client := &http.Client{}
124156 req, err := http.NewRequest("GET", "https://api.github.com/user/emails", nil)
125157 if err != nil {
126- log.Error("Failed to create request for emails", "error", err)
158+ log.Error("failed to create email request",
159+ "error", err)
127160 return nil, fmt.Errorf("failed to create request: %w", err)
128161 }
129162
@@ -132,13 +165,15 @@ func fetchGitHubUserEmails(token string) ([]string, error) {
132165
133166 resp, err := client.Do(req)
134167 if err != nil {
135- log.Error("Failed to send request for emails", "error", err)
168+ log.Error("failed to send email request",
169+ "error", err)
136170 return nil, fmt.Errorf("failed to send request: %w", err)
137171 }
138172 defer resp.Body.Close()
139173
140174 if resp.StatusCode != http.StatusOK {
141- log.Warn("Failed to fetch user emails", "status", resp.Status)
175+ log.Warn("failed to fetch emails",
176+ "status", resp.Status)
142177 return nil, fmt.Errorf("failed to fetch user emails: %s", resp.Status)
143178 }
144179
@@ -146,7 +181,8 @@ func fetchGitHubUserEmails(token string) ([]string, error) {
146181 Email string `json:"email"`
147182 }
148183 if err := json.NewDecoder(resp.Body).Decode(&emailsResp); err != nil {
149- log.Error("Failed to parse email response", "error", err)
184+ log.Error("failed to parse email response",
185+ "error", err)
150186 return nil, fmt.Errorf("failed to parse email response: %w", err)
151187 }
152188
@@ -155,6 +191,7 @@ func fetchGitHubUserEmails(token string) ([]string, error) {
155191 emails[i] = e.Email
156192 }
157193
158- log.Debug("GitHub user emails fetched successfully", "emailCount", len(emails))
194+ log.Debug("fetched GitHub emails successfully",
195+ "email_count", len(emails))
159196 return emails, nil
160197 }
@@ -6,74 +6,89 @@ import (
6 "io"6 "io"
7 "net/http"7 "net/http"
8 "strings"8 "strings"
9+ "sync"
10+ "time"
9 11
12+ "github.com/bnema/gordon/internal/cli/auth"
10 "github.com/bnema/gordon/internal/db/queries"13 "github.com/bnema/gordon/internal/db/queries"
11 "github.com/bnema/gordon/internal/server"14 "github.com/bnema/gordon/internal/server"
12 "github.com/charmbracelet/log"15 "github.com/charmbracelet/log"
13 "github.com/labstack/echo/v4"16 "github.com/labstack/echo/v4"
14 )17 )
15 18
16-// GithubUserInfo is an alias for the type defined in the queries package
17 type GithubUserInfo = queries.GithubUserInfo19 type GithubUserInfo = queries.GithubUserInfo
18 20
19-// RequireToken is a middleware that checks for a valid GitHub token in the request21+// middleware/require_token.go
20 func RequireToken(a *server.App) echo.MiddlewareFunc {22 func RequireToken(a *server.App) echo.MiddlewareFunc {
23+ tokenCache := auth.GetTokenCache()
24+
21 return func(next echo.HandlerFunc) echo.HandlerFunc {25 return func(next echo.HandlerFunc) echo.HandlerFunc {
22 return func(c echo.Context) error {26 return func(c echo.Context) error {
23- log.Debug("Starting RequireToken middleware")27+ token := strings.TrimPrefix(c.Request().Header.Get("Authorization"), "Bearer ")
24-28+ if token == "" {
25- githubUser, err := validateGitHubToken(c)29+ return c.JSON(http.StatusUnauthorized, map[string]string{
26- if err != nil {30+ "error": "Token is missing",
27- log.Error("Token validation failed", "error", err)31+ })
28- return c.JSON(http.StatusUnauthorized, map[string]string{"error": "Invalid or missing token"})
29 }32 }
30- log.Debug("GitHub token validated successfully", "user", githubUser.Login)
31 33
32- // Check if the user exists and matches the GitHub user34+ // Check cache first
33- isValid, err := queries.CheckDBUserIsGood(a, githubUser)35+ if _, cachedUser, found := tokenCache.GetWithUser(token); found {
34- if err != nil {36+ c.Set("user", cachedUser)
35- log.Error("Error checking user authorization", "error", err)37+ return next(c)
36- return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Error checking user authorization"})
37 }38 }
38 39
39- if !isValid {40+ // Validate with GitHub only for new tokens
40- log.Warn("User not authorized", "user", githubUser.Login)41+ githubUser, err := validateGitHubToken(c)
41- return c.JSON(http.StatusForbidden, map[string]string{"error": "User not authorized"})
42- }
43- log.Debug("User authorization check passed", "user", githubUser.Login)
44-
45- // Update or create session
46- err = queries.CreateOrUpdateDBSession(a, c.Request().Header.Get("Authorization"), c.Request().UserAgent())
47 if err != nil {42 if err != nil {
48- log.Error("Error updating session", "error", err)43+ return c.JSON(http.StatusUnauthorized, map[string]string{
49- return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Error updating session"})44+ "error": "Invalid token",
45+ })
50 }46 }
51- log.Debug("Session created or updated successfully", "user", githubUser.Login)
52 47
53- // Store the user information in the context for later use48+ tokenCache.SetWithUser(token, token, githubUser, 15*time.Minute)
54 c.Set("user", githubUser)49 c.Set("user", githubUser)
55- log.Debug("User information stored in context", "user", githubUser.Login)
56-
57 return next(c)50 return next(c)
58 }51 }
59 }52 }
60 }53 }
61 54
55+var sessionUpdateCache sync.Map
56+
57+func shouldUpdateSession(token string) bool {
58+ key := token
59+ now := time.Now()
60+
61+ if lastUpdate, exists := sessionUpdateCache.Load(key); exists {
62+ if now.Sub(lastUpdate.(time.Time)) < 5*time.Minute {
63+ log.Debug("skipping session update", "reason", "too_recent")
64+ return false
65+ }
66+ }
67+
68+ sessionUpdateCache.Store(key, now)
69+ log.Debug("session update required")
70+ return true
71+}
72+
62 func validateGitHubToken(c echo.Context) (*GithubUserInfo, error) {73 func validateGitHubToken(c echo.Context) (*GithubUserInfo, error) {
74+ requestID := c.Response().Header().Get(echo.HeaderXRequestID)
63 token := c.Request().Header.Get("Authorization")75 token := c.Request().Header.Get("Authorization")
64 if token == "" {76 if token == "" {
65- log.Warn("Token is missing in the request")77+ log.Warn("missing token in validation request",
78+ "request_id", requestID)
66 return nil, echo.NewHTTPError(http.StatusUnauthorized, "Token is missing")79 return nil, echo.NewHTTPError(http.StatusUnauthorized, "Token is missing")
67 }80 }
68 81
69 token = strings.TrimPrefix(token, "Bearer ")82 token = strings.TrimPrefix(token, "Bearer ")
70- log.Debug("Validating GitHub token")83+ log.Debug("validating GitHub token",
84+ "request_id", requestID)
71 85
72- // Validate the token against GitHub API
73 client := &http.Client{}86 client := &http.Client{}
74 req, err := http.NewRequest("GET", "https://api.github.com/user", nil)87 req, err := http.NewRequest("GET", "https://api.github.com/user", nil)
75 if err != nil {88 if err != nil {
76- log.Error("Failed to create request", "error", err)89+ log.Error("failed to create GitHub API request",
90+ "request_id", requestID,
91+ "error", err)
77 return nil, fmt.Errorf("failed to create request: %w", err)92 return nil, fmt.Errorf("failed to create request: %w", err)
78 }93 }
79 94
@@ -82,48 +97,66 @@ func validateGitHubToken(c echo.Context) (*GithubUserInfo, error) {
82 97
83 resp, err := client.Do(req)98 resp, err := client.Do(req)
84 if err != nil {99 if err != nil {
85- log.Error("Failed to send request to GitHub API", "error", err)100+ log.Error("GitHub API request failed",
101+ "request_id", requestID,
102+ "error", err)
86 return nil, fmt.Errorf("failed to send request: %w", err)103 return nil, fmt.Errorf("failed to send request: %w", err)
87 }104 }
88 defer resp.Body.Close()105 defer resp.Body.Close()
89 106
90 if resp.StatusCode != http.StatusOK {107 if resp.StatusCode != http.StatusOK {
91- log.Warn("Invalid token", "status", resp.Status)108+ log.Warn("invalid GitHub token",
109+ "request_id", requestID,
110+ "status", resp.Status)
92 return nil, echo.NewHTTPError(http.StatusUnauthorized, "Invalid token")111 return nil, echo.NewHTTPError(http.StatusUnauthorized, "Invalid token")
93 }112 }
94 113
95 body, err := io.ReadAll(resp.Body)114 body, err := io.ReadAll(resp.Body)
96 if err != nil {115 if err != nil {
97- log.Error("Failed to read response body", "error", err)116+ log.Error("failed to read GitHub response",
117+ "request_id", requestID,
118+ "error", err)
98 return nil, fmt.Errorf("failed to read response body: %w", err)119 return nil, fmt.Errorf("failed to read response body: %w", err)
99 }120 }
100 121
101 var githubUser GithubUserInfo122 var githubUser GithubUserInfo
102 if err := json.Unmarshal(body, &githubUser); err != nil {123 if err := json.Unmarshal(body, &githubUser); err != nil {
103- log.Error("Failed to parse user data", "error", err)124+ log.Error("failed to parse GitHub user data",
125+ "request_id", requestID,
126+ "error", err)
104 return nil, fmt.Errorf("failed to parse user data: %w", err)127 return nil, fmt.Errorf("failed to parse user data: %w", err)
105 }128 }
106 129
107- log.Debug("GitHub user info fetched successfully", "user", githubUser.Login)130+ log.Debug("GitHub user info fetched",
131+ "request_id", requestID,
132+ "user", githubUser.Login)
108 133
109- // Fetch user's email
110 emails, err := fetchGitHubUserEmails(token)134 emails, err := fetchGitHubUserEmails(token)
111 if err != nil {135 if err != nil {
112- log.Error("Failed to fetch user emails", "error", err)136+ log.Error("failed to fetch GitHub user emails",
137+ "request_id", requestID,
138+ "user", githubUser.Login,
139+ "error", err)
113 return nil, fmt.Errorf("failed to fetch user emails: %w", err)140 return nil, fmt.Errorf("failed to fetch user emails: %w", err)
114 }141 }
115 githubUser.Emails = emails142 githubUser.Emails = emails
116- log.Debug("GitHub user emails fetched successfully", "user", githubUser.Login, "emailCount", len(emails))143+
144+ log.Debug("completed GitHub user validation",
145+ "request_id", requestID,
146+ "user", githubUser.Login,
147+ "email_count", len(emails))
117 148
118 return &githubUser, nil149 return &githubUser, nil
119 }150 }
120 151
121 func fetchGitHubUserEmails(token string) ([]string, error) {152 func fetchGitHubUserEmails(token string) ([]string, error) {
122- log.Debug("Fetching GitHub user emails")153+ log.Debug("fetching GitHub user emails")
154+
123 client := &http.Client{}155 client := &http.Client{}
124 req, err := http.NewRequest("GET", "https://api.github.com/user/emails", nil)156 req, err := http.NewRequest("GET", "https://api.github.com/user/emails", nil)
125 if err != nil {157 if err != nil {
126- log.Error("Failed to create request for emails", "error", err)158+ log.Error("failed to create email request",
159+ "error", err)
127 return nil, fmt.Errorf("failed to create request: %w", err)160 return nil, fmt.Errorf("failed to create request: %w", err)
128 }161 }
129 162
@@ -132,13 +165,15 @@ func fetchGitHubUserEmails(token string) ([]string, error) {
132 165
133 resp, err := client.Do(req)166 resp, err := client.Do(req)
134 if err != nil {167 if err != nil {
135- log.Error("Failed to send request for emails", "error", err)168+ log.Error("failed to send email request",
169+ "error", err)
136 return nil, fmt.Errorf("failed to send request: %w", err)170 return nil, fmt.Errorf("failed to send request: %w", err)
137 }171 }
138 defer resp.Body.Close()172 defer resp.Body.Close()
139 173
140 if resp.StatusCode != http.StatusOK {174 if resp.StatusCode != http.StatusOK {
141- log.Warn("Failed to fetch user emails", "status", resp.Status)175+ log.Warn("failed to fetch emails",
176+ "status", resp.Status)
142 return nil, fmt.Errorf("failed to fetch user emails: %s", resp.Status)177 return nil, fmt.Errorf("failed to fetch user emails: %s", resp.Status)
143 }178 }
144 179
@@ -146,7 +181,8 @@ func fetchGitHubUserEmails(token string) ([]string, error) {
146 Email string `json:"email"`181 Email string `json:"email"`
147 }182 }
148 if err := json.NewDecoder(resp.Body).Decode(&emailsResp); err != nil {183 if err := json.NewDecoder(resp.Body).Decode(&emailsResp); err != nil {
149- log.Error("Failed to parse email response", "error", err)184+ log.Error("failed to parse email response",
185+ "error", err)
150 return nil, fmt.Errorf("failed to parse email response: %w", err)186 return nil, fmt.Errorf("failed to parse email response: %w", err)
151 }187 }
152 188
@@ -155,6 +191,7 @@ func fetchGitHubUserEmails(token string) ([]string, error) {
155 emails[i] = e.Email191 emails[i] = e.Email
156 }192 }
157 193
158- log.Debug("GitHub user emails fetched successfully", "emailCount", len(emails))194+ log.Debug("fetched GitHub emails successfully",
195+ "email_count", len(emails))
159 return emails, nil196 return emails, nil
160 }197 }
M pkg/docker/containers.go +17 -0
@@ -398,3 +398,20 @@ func CheckContainerStatus(containerName string) (bool, bool, error) {
398398
399399 return false, false, nil
400400 }
401+
402+func GetContainerIDByName(containerName string) string {
403+ containers, err := ListRunningContainers()
404+ if err != nil {
405+ return ""
406+ }
407+
408+ for _, container := range containers {
409+ for _, name := range container.Names {
410+ if strings.TrimLeft(name, "/") == containerName {
411+ return container.ID
412+ }
413+ }
414+ }
415+
416+ return ""
417+}
@@ -398,3 +398,20 @@ func CheckContainerStatus(containerName string) (bool, bool, error) {
398 398
399 return false, false, nil399 return false, false, nil
400 }400 }
401+
402+func GetContainerIDByName(containerName string) string {
403+ containers, err := ListRunningContainers()
404+ if err != nil {
405+ return ""
406+ }
407+
408+ for _, container := range containers {
409+ for _, name := range container.Names {
410+ if strings.TrimLeft(name, "/") == containerName {
411+ return container.ID
412+ }
413+ }
414+ }
415+
416+ return ""
417+}

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