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 | |||
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | 6 | "bytes" | |
| 7 | + | "context" | |
| 7 | 8 | "encoding/json" | |
| 8 | 9 | "errors" | |
| 9 | 10 | "fmt" | |
| @@ -16,7 +17,6 @@ import ( | |||
| 16 | 17 | "github.com/bnema/gordon/internal/cli" | |
| 17 | 18 | "github.com/bnema/gordon/internal/cli/handler" | |
| 18 | 19 | "github.com/bnema/gordon/internal/common" | |
| 19 | - | "github.com/bnema/gordon/pkg/docker" | |
| 20 | 20 | "github.com/charmbracelet/log" | |
| 21 | 21 | "github.com/spf13/cobra" | |
| 22 | 22 | ) | |
| @@ -49,7 +49,7 @@ func NewDeployCommand(a *cli.App) *cobra.Command { | |||
| 49 | 49 | return | |
| 50 | 50 | } | |
| 51 | 51 | ||
| 52 | - | reader, actualSize, err := exportDockerImage(imageName) | |
| 52 | + | reader, actualSize, err := handler.ExportDockerImage(imageName) | |
| 53 | 53 | if err != nil { | |
| 54 | 54 | log.Error("Error exporting image", "error", err) | |
| 55 | 55 | return | |
| @@ -72,123 +72,95 @@ func NewDeployCommand(a *cli.App) *cobra.Command { | |||
| 72 | 72 | return deployCmd | |
| 73 | 73 | } | |
| 74 | 74 | ||
| 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 | |
| 117 | 76 | ||
| 118 | 77 | 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) | |
| 121 | 81 | if err != nil { | |
| 122 | 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 | 89 | log.Info("Attempting to deploy...", | |
| 126 | 90 | "image", imageName, | |
| 91 | + | "size", fmt.Sprintf("%.2fMB", sizeInMB), | |
| 127 | 92 | "port", port, | |
| 128 | 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 | 106 | 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() | |
| 142 | 109 | ||
| 143 | - | resp, err := handler.SendHTTPRequest(a, &reqPayload, "POST", "/deploy") | |
| 110 | + | resp, err := chunkedClient.SendFile(ctx, "/deploy", headers, imageReader, size, imageName) | |
| 144 | 111 | if err != nil { | |
| 145 | 112 | var deployErr *common.DeploymentError | |
| 146 | - | if errors.As(err, &deployErr) { | |
| 113 | + | if errors.As(err, &deployErr) && deployErr.StatusCode == http.StatusConflict { | |
| 147 | 114 | var deployResponse common.DeployResponse | |
| 148 | 115 | if jsonErr := json.Unmarshal([]byte(deployErr.RawResponse), &deployResponse); jsonErr == nil { | |
| 149 | - | if deployErr.StatusCode == http.StatusConflict && deployResponse.ContainerID != "" { | |
| 116 | + | if deployResponse.ContainerID != "" { | |
| 150 | 117 | if err := handler.HandleExistingContainer(a, &deployResponse); err != nil { | |
| 151 | 118 | 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") | |
| 154 | 120 | } | |
| 155 | - | log.Error("Failed to handle existing container", "error", err) | |
| 156 | - | return err | |
| 121 | + | return fmt.Errorf("failed to handle existing container: %w", err) | |
| 157 | 122 | } | |
| 158 | - | log.Info("Retrying deployment...") | |
| 123 | + | // Reset the reader for retry | |
| 124 | + | imageReader.Seek(0, 0) | |
| 159 | 125 | continue // Retry deployment | |
| 160 | 126 | } | |
| 161 | 127 | } | |
| 162 | 128 | } | |
| 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) | |
| 170 | 130 | } | |
| 171 | 131 | ||
| 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 | |
| 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 | 150 | func waitForDeployment(domain string, containerID string) error { | |
| 185 | 151 | client := &http.Client{Timeout: 10 * time.Second} | |
| 186 | 152 | maxRetries := 20 | |
| 187 | 153 | 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 | 164 | for i := 0; i < maxRetries; i++ { | |
| 193 | 165 | resp, err := client.Get(domain) | |
| 194 | 166 | if err == nil { | |
| @@ -199,15 +171,51 @@ func waitForDeployment(domain string, containerID string) error { | |||
| 199 | 171 | "container_id", shortContainerID) | |
| 200 | 172 | return nil | |
| 201 | 173 | } | |
| 202 | - | // Check for error messages in the response body | |
| 203 | 174 | body, _ := io.ReadAll(resp.Body) | |
| 204 | 175 | if strings.Contains(string(body), "failed to create container:") { | |
| 205 | 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 | 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 | } | |
| @@ -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 | return | 49 | 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 | return | 55 | return | ||
| @@ -72,123 +72,95 @@ func NewDeployCommand(a *cli.App) *cobra.Command { | |||||
| 72 | return deployCmd | 72 | 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 reuse | 78 | + | // 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 attempt | 107 | + | 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.DeploymentError | 112 | var deployErr *common.DeploymentError | ||
| 146 | - | if errors.As(err, &deployErr) { | 113 | + | if errors.As(err, &deployErr) && deployErr.StatusCode == http.StatusConflict { |
| 147 | var deployResponse common.DeployResponse | 114 | 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 deployment | 125 | continue // Retry deployment | ||
| 160 | } | 126 | } | ||
| 161 | } | 127 | } | ||
| 162 | } | 128 | } | ||
| 163 | - | return err | 129 | + | 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 nil | 137 | + | } |
| 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 := 20 | 152 | maxRetries := 20 | ||
| 187 | retryInterval := time.Second | 153 | 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 nil | 172 | 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 @@ | |||
| 1 | 1 | package cmd | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | - | "encoding/json" | |
| 4 | + | "context" | |
| 5 | + | "errors" | |
| 5 | 6 | "fmt" | |
| 7 | + | "net/http" | |
| 6 | 8 | "os" | |
| 7 | - | "strings" | |
| 9 | + | "time" | |
| 8 | 10 | ||
| 9 | 11 | "github.com/bnema/gordon/internal/cli" | |
| 10 | 12 | "github.com/bnema/gordon/internal/cli/handler" | |
| @@ -54,49 +56,39 @@ func pushImage(a *cli.App, imageName string) error { | |||
| 54 | 56 | ||
| 55 | 57 | handler.EnsureImageTag(&imageName) | |
| 56 | 58 | ||
| 57 | - | reader, actualSize, err := exportDockerImage(imageName) | |
| 59 | + | reader, actualSize, err := handler.ExportDockerImage(imageName) | |
| 58 | 60 | if err != nil { | |
| 59 | 61 | return fmt.Errorf("error exporting image: %w", err) | |
| 60 | 62 | } | |
| 61 | 63 | defer reader.Close() | |
| 62 | 64 | ||
| 63 | 65 | 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)) | |
| 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"}, | |
| 73 | 73 | } | |
| 74 | 74 | ||
| 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) | |
| 80 | 76 | ||
| 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() | |
| 86 | 79 | ||
| 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) | |
| 90 | 87 | } | |
| 91 | 88 | ||
| 92 | - | //Remove the double quotes from the message | |
| 93 | - | message := strings.Trim(pushResponse.Message, "\"") | |
| 89 | + | fmt.Println("Push response:", resp) | |
| 94 | 90 | ||
| 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) | |
| 100 | 92 | ||
| 101 | 93 | return nil | |
| 102 | 94 | } | |
| @@ -1,10 +1,12 @@ | |||||
| 1 | package cmd | 1 | 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 / 1024 | 65 | 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.PushResponse | 77 | + | 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 message | 89 | + | 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 nil | 93 | 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 { | |||
| 31 | 31 | Success bool `json:"success"` | |
| 32 | 32 | Message string `json:"message"` | |
| 33 | 33 | CreateContainerURL string `json:"create_container_url,omitempty"` | |
| 34 | + | ImageID string `json:"image_id,omitempty"` | |
| 34 | 35 | } | |
| 35 | 36 | ||
| 36 | 37 | type StopResponse struct { | |
| @@ -73,7 +74,16 @@ type DeployPayload struct { | |||
| 73 | 74 | type PushPayload struct { | |
| 74 | 75 | ImageName string `json:"imagename"` | |
| 75 | 76 | 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"` | |
| 77 | 87 | } | |
| 78 | 88 | ||
| 79 | 89 | // 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.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"` | |||
| 77 | } | 87 | } | ||
| 78 | 88 | ||||
| 79 | // UnmarshalJSON custom unmarshaler for RequestPayload | 89 | // 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 { | |||
| 41 | 41 | } | |
| 42 | 42 | ||
| 43 | 43 | data["AdminPath"] = a.Config.Admin.Path | |
| 44 | - | // DEBUG show the admin path here | |
| 45 | - | fmt.Println("AdminPath:", a.Config.Admin.Path) | |
| 46 | 44 | ||
| 47 | 45 | rendererData, err := render.GetHTMLRenderer(mainPath, templateName, a.TemplateFS, a, fragmentsPath) | |
| 48 | 46 | 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.Path | 43 | 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 { | |||
| 163 | 163 | } | |
| 164 | 164 | ||
| 165 | 165 | browserInfo := c.Request().UserAgent() | |
| 166 | - | userInfo, err := githubGetUserDetails(c, a) | |
| 166 | + | userInfo, err := githubGetUserDetails(c) | |
| 167 | 167 | if err != nil { | |
| 168 | 168 | return err | |
| 169 | 169 | } | |
| 170 | 170 | ||
| 171 | - | user, err := handleUser(c, a, accessToken, browserInfo, userInfo) | |
| 171 | + | _, err = handleUser(c, a, accessToken, browserInfo, userInfo) | |
| 172 | 172 | if err != nil { | |
| 173 | 173 | 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 | 177 | if err != nil { | |
| 178 | 178 | return err | |
| 179 | 179 | } | |
| @@ -263,7 +263,7 @@ func updateUser(a *server.App, accessToken, browserInfo string, userInfo *querie | |||
| 263 | 263 | } | |
| 264 | 264 | ||
| 265 | 265 | // 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 | 267 | sess.Values["authenticated"] = true | |
| 268 | 268 | sess.Values["accountID"] = accountID | |
| 269 | 269 | sess.Values["expires"] = expires | |
| @@ -308,7 +308,7 @@ func fetchGithubAPI(url string, authHeader string, result interface{}) error { | |||
| 308 | 308 | } | |
| 309 | 309 | ||
| 310 | 310 | // 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 | 312 | accessToken := c.QueryParam("access_token") | |
| 313 | 313 | ||
| 314 | 314 | // 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 err | 168 | 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 err | 173 | 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 err | 178 | 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 user | 265 | // 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"] = true | 267 | sess.Values["authenticated"] = true | ||
| 268 | sess.Values["accountID"] = accountID | 268 | sess.Values["accountID"] = accountID | ||
| 269 | sess.Values["expires"] = expires | 269 | 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 token | 310 | // 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 info | 314 | // Fetch user info | ||
M internal/httpserve/middleware/error.go +7 -9
| @@ -1,9 +1,7 @@ | |||
| 1 | 1 | package middleware | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | - | "log" | |
| 5 | - | "net/http" | |
| 6 | - | ||
| 4 | + | "github.com/charmbracelet/log" | |
| 7 | 5 | "github.com/labstack/echo/v4" | |
| 8 | 6 | ) | |
| 9 | 7 | ||
| @@ -11,12 +9,12 @@ func ErrorHandler(next echo.HandlerFunc) echo.HandlerFunc { | |||
| 11 | 9 | return func(c echo.Context) error { | |
| 12 | 10 | err := next(c) | |
| 13 | 11 | 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) | |
| 19 | 17 | } | |
| 20 | - | return nil | |
| 18 | + | return err | |
| 21 | 19 | } | |
| 22 | 20 | } | |
| @@ -1,9 +1,7 @@ | |||||
| 1 | package middleware | 1 | 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 nil | 18 | + | return err |
| 21 | } | 19 | } | ||
| 22 | } | 20 | } | ||
M internal/httpserve/middleware/requiretoken.go +83 -46
| @@ -6,74 +6,89 @@ import ( | |||
| 6 | 6 | "io" | |
| 7 | 7 | "net/http" | |
| 8 | 8 | "strings" | |
| 9 | + | "sync" | |
| 10 | + | "time" | |
| 9 | 11 | ||
| 12 | + | "github.com/bnema/gordon/internal/cli/auth" | |
| 10 | 13 | "github.com/bnema/gordon/internal/db/queries" | |
| 11 | 14 | "github.com/bnema/gordon/internal/server" | |
| 12 | 15 | "github.com/charmbracelet/log" | |
| 13 | 16 | "github.com/labstack/echo/v4" | |
| 14 | 17 | ) | |
| 15 | 18 | ||
| 16 | - | // GithubUserInfo is an alias for the type defined in the queries package | |
| 17 | 19 | type GithubUserInfo = queries.GithubUserInfo | |
| 18 | 20 | ||
| 19 | - | // RequireToken is a middleware that checks for a valid GitHub token in the request | |
| 21 | + | // middleware/require_token.go | |
| 20 | 22 | func RequireToken(a *server.App) echo.MiddlewareFunc { | |
| 23 | + | tokenCache := auth.GetTokenCache() | |
| 24 | + | ||
| 21 | 25 | return func(next echo.HandlerFunc) echo.HandlerFunc { | |
| 22 | 26 | 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 | + | }) | |
| 29 | 32 | } | |
| 30 | - | log.Debug("GitHub token validated successfully", "user", githubUser.Login) | |
| 31 | 33 | ||
| 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) | |
| 37 | 38 | } | |
| 38 | 39 | ||
| 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) | |
| 47 | 42 | 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 | + | }) | |
| 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 use | |
| 48 | + | tokenCache.SetWithUser(token, token, githubUser, 15*time.Minute) | |
| 54 | 49 | c.Set("user", githubUser) | |
| 55 | - | log.Debug("User information stored in context", "user", githubUser.Login) | |
| 56 | - | ||
| 57 | 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 | 73 | func validateGitHubToken(c echo.Context) (*GithubUserInfo, error) { | |
| 74 | + | requestID := c.Response().Header().Get(echo.HeaderXRequestID) | |
| 63 | 75 | token := c.Request().Header.Get("Authorization") | |
| 64 | 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 | 79 | return nil, echo.NewHTTPError(http.StatusUnauthorized, "Token is missing") | |
| 67 | 80 | } | |
| 68 | 81 | ||
| 69 | 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 | 86 | client := &http.Client{} | |
| 74 | 87 | req, err := http.NewRequest("GET", "https://api.github.com/user", nil) | |
| 75 | 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 | 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 | 98 | resp, err := client.Do(req) | |
| 84 | 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 | 103 | return nil, fmt.Errorf("failed to send request: %w", err) | |
| 87 | 104 | } | |
| 88 | 105 | defer resp.Body.Close() | |
| 89 | 106 | ||
| 90 | 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 | 111 | return nil, echo.NewHTTPError(http.StatusUnauthorized, "Invalid token") | |
| 93 | 112 | } | |
| 94 | 113 | ||
| 95 | 114 | body, err := io.ReadAll(resp.Body) | |
| 96 | 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 | 119 | return nil, fmt.Errorf("failed to read response body: %w", err) | |
| 99 | 120 | } | |
| 100 | 121 | ||
| 101 | 122 | var githubUser GithubUserInfo | |
| 102 | 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 | 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 | 134 | emails, err := fetchGitHubUserEmails(token) | |
| 111 | 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 | 140 | return nil, fmt.Errorf("failed to fetch user emails: %w", err) | |
| 114 | 141 | } | |
| 115 | 142 | 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 | 149 | return &githubUser, nil | |
| 119 | 150 | } | |
| 120 | 151 | ||
| 121 | 152 | func fetchGitHubUserEmails(token string) ([]string, error) { | |
| 122 | - | log.Debug("Fetching GitHub user emails") | |
| 153 | + | log.Debug("fetching GitHub user emails") | |
| 154 | + | ||
| 123 | 155 | client := &http.Client{} | |
| 124 | 156 | req, err := http.NewRequest("GET", "https://api.github.com/user/emails", nil) | |
| 125 | 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 | 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 | 166 | resp, err := client.Do(req) | |
| 134 | 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 | 170 | return nil, fmt.Errorf("failed to send request: %w", err) | |
| 137 | 171 | } | |
| 138 | 172 | defer resp.Body.Close() | |
| 139 | 173 | ||
| 140 | 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 | 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 | 181 | Email string `json:"email"` | |
| 147 | 182 | } | |
| 148 | 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 | 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 | 191 | 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 | 196 | return emails, nil | |
| 160 | 197 | } | |
| @@ -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.GithubUserInfo | 19 | type GithubUserInfo = queries.GithubUserInfo | ||
| 18 | 20 | ||||
| 19 | - | // RequireToken is a middleware that checks for a valid GitHub token in the request | 21 | + | // 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 user | 34 | + | // 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 use | 48 | + | 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 GithubUserInfo | 122 | 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 = emails | 142 | 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, nil | 149 | 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.Email | 191 | 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, nil | 196 | return emails, nil | ||
| 160 | } | 197 | } | ||
M pkg/docker/containers.go +17 -0
| @@ -398,3 +398,20 @@ func CheckContainerStatus(containerName string) (bool, bool, error) { | |||
| 398 | 398 | ||
| 399 | 399 | 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 | + | } | |
| @@ -398,3 +398,20 @@ func CheckContainerStatus(containerName string) (bool, bool, error) { | |||||
| 398 | 398 | ||||
| 399 | return false, false, nil | 399 | 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 | + | } | |||