Enhance CLI with new bubbletea package, new spinner feature and bug
fixes Integrate new terminal styling and interactivity libraries Implement spinner for async operations feedback Remove the outdated 'push' command for deployment Update user messaging and version check flow in CLI commands
- commit
- c9911a025fbc7e6d80617c3292a06aeae339d694
- author
- bnema · · 2023-11-07 21:32 UTC
- parent
- 22697be
- signature
- unsigned
14 files · +627 -197
A assets/vhs/demo.gif +0 -0 binary file: no text diff
binary file: no text diff
A assets/vhs/demo.tape +91 -0
| @@ -0,0 +1,91 @@ | |||
| 1 | + | # VHS documentation | |
| 2 | + | # | |
| 3 | + | # Output: | |
| 4 | + | # Output <path>.gif Create a GIF output at the given <path> | |
| 5 | + | # Output <path>.mp4 Create an MP4 output at the given <path> | |
| 6 | + | # Output <path>.webm Create a WebM output at the given <path> | |
| 7 | + | # | |
| 8 | + | # Require: | |
| 9 | + | # Require <string> Ensure a program is on the $PATH to proceed | |
| 10 | + | # | |
| 11 | + | # Settings: | |
| 12 | + | # Set FontSize <number> Set the font size of the terminal | |
| 13 | + | # Set FontFamily <string> Set the font family of the terminal | |
| 14 | + | # Set Height <number> Set the height of the terminal | |
| 15 | + | # Set Width <number> Set the width of the terminal | |
| 16 | + | # Set LetterSpacing <float> Set the font letter spacing (tracking) | |
| 17 | + | # Set LineHeight <float> Set the font line height | |
| 18 | + | # Set LoopOffset <float>% Set the starting frame offset for the GIF loop | |
| 19 | + | # Set Theme <json|string> Set the theme of the terminal | |
| 20 | + | # Set Padding <number> Set the padding of the terminal | |
| 21 | + | # Set Framerate <number> Set the framerate of the recording | |
| 22 | + | # Set PlaybackSpeed <float> Set the playback speed of the recording | |
| 23 | + | # Set MarginFill <file|#000000> Set the file or color the margin will be filled with. | |
| 24 | + | # Set Margin <number> Set the size of the margin. Has no effect if MarginFill isn't set. | |
| 25 | + | # Set BorderRadius <number> Set terminal border radius, in pixels. | |
| 26 | + | # Set WindowBar <string> Set window bar type. (one of: Rings, RingsRight, Colorful, ColorfulRight) | |
| 27 | + | # Set WindowBarSize <number> Set window bar size, in pixels. Default is 40. | |
| 28 | + | # Set TypingSpeed <time> Set the typing speed of the terminal. Default is 50ms. | |
| 29 | + | # | |
| 30 | + | # Sleep: | |
| 31 | + | # Sleep <time> Sleep for a set amount of <time> in seconds | |
| 32 | + | # | |
| 33 | + | # Type: | |
| 34 | + | # Type[@<time>] "<characters>" Type <characters> into the terminal with a | |
| 35 | + | # <time> delay between each character | |
| 36 | + | # | |
| 37 | + | # Keys: | |
| 38 | + | # Backspace[@<time>] [number] Press the Backspace key | |
| 39 | + | # Down[@<time>] [number] Press the Down key | |
| 40 | + | # Enter[@<time>] [number] Press the Enter key | |
| 41 | + | # Space[@<time>] [number] Press the Space key | |
| 42 | + | # Tab[@<time>] [number] Press the Tab key | |
| 43 | + | # Left[@<time>] [number] Press the Left Arrow key | |
| 44 | + | # Right[@<time>] [number] Press the Right Arrow key | |
| 45 | + | # Up[@<time>] [number] Press the Up Arrow key | |
| 46 | + | # Down[@<time>] [number] Press the Down Arrow key | |
| 47 | + | # PageUp[@<time>] [number] Press the Page Up key | |
| 48 | + | # PageDown[@<time>] [number] Press the Page Down key | |
| 49 | + | # Ctrl+<key> Press the Control key + <key> (e.g. Ctrl+C) | |
| 50 | + | # | |
| 51 | + | # Display: | |
| 52 | + | # Hide Hide the subsequent commands from the output | |
| 53 | + | # Show Show the subsequent commands in the output | |
| 54 | + | ||
| 55 | + | Output demo.gif | |
| 56 | + | ||
| 57 | + | Require echo | |
| 58 | + | Require gordon | |
| 59 | + | ||
| 60 | + | Set TypingSpeed 75ms | |
| 61 | + | Set Shell "bash" | |
| 62 | + | Set FontSize 20 | |
| 63 | + | Set FontFamily "FiraCode Nerd Font" | |
| 64 | + | Set Width 1200 | |
| 65 | + | Set Height 600 | |
| 66 | + | ||
| 67 | + | Type "gordon" | |
| 68 | + | Sleep 1 | |
| 69 | + | Space | |
| 70 | + | Type "deploy" | |
| 71 | + | Sleep 1 | |
| 72 | + | Space | |
| 73 | + | Type "bnema/go-helloworld-http:latest" | |
| 74 | + | Sleep 1 | |
| 75 | + | Space | |
| 76 | + | Type "-p 8888:8080" | |
| 77 | + | Sleep 1 | |
| 78 | + | Space | |
| 79 | + | Type "-t hello.bamen.dev" | |
| 80 | + | Sleep 1 | |
| 81 | + | Enter | |
| 82 | + | ||
| 83 | + | Sleep 10 | |
| 84 | + | ||
| 85 | + | Type "curl" | |
| 86 | + | Sleep 1 | |
| 87 | + | Space | |
| 88 | + | Type "https://hello.bamen.dev" | |
| 89 | + | Enter | |
| 90 | + | ||
| 91 | + | Sleep 10 | |
| @@ -0,0 +1,91 @@ | |||||
| 1 | + | # VHS documentation | |||
| 2 | + | # | |||
| 3 | + | # Output: | |||
| 4 | + | # Output <path>.gif Create a GIF output at the given <path> | |||
| 5 | + | # Output <path>.mp4 Create an MP4 output at the given <path> | |||
| 6 | + | # Output <path>.webm Create a WebM output at the given <path> | |||
| 7 | + | # | |||
| 8 | + | # Require: | |||
| 9 | + | # Require <string> Ensure a program is on the $PATH to proceed | |||
| 10 | + | # | |||
| 11 | + | # Settings: | |||
| 12 | + | # Set FontSize <number> Set the font size of the terminal | |||
| 13 | + | # Set FontFamily <string> Set the font family of the terminal | |||
| 14 | + | # Set Height <number> Set the height of the terminal | |||
| 15 | + | # Set Width <number> Set the width of the terminal | |||
| 16 | + | # Set LetterSpacing <float> Set the font letter spacing (tracking) | |||
| 17 | + | # Set LineHeight <float> Set the font line height | |||
| 18 | + | # Set LoopOffset <float>% Set the starting frame offset for the GIF loop | |||
| 19 | + | # Set Theme <json|string> Set the theme of the terminal | |||
| 20 | + | # Set Padding <number> Set the padding of the terminal | |||
| 21 | + | # Set Framerate <number> Set the framerate of the recording | |||
| 22 | + | # Set PlaybackSpeed <float> Set the playback speed of the recording | |||
| 23 | + | # Set MarginFill <file|#000000> Set the file or color the margin will be filled with. | |||
| 24 | + | # Set Margin <number> Set the size of the margin. Has no effect if MarginFill isn't set. | |||
| 25 | + | # Set BorderRadius <number> Set terminal border radius, in pixels. | |||
| 26 | + | # Set WindowBar <string> Set window bar type. (one of: Rings, RingsRight, Colorful, ColorfulRight) | |||
| 27 | + | # Set WindowBarSize <number> Set window bar size, in pixels. Default is 40. | |||
| 28 | + | # Set TypingSpeed <time> Set the typing speed of the terminal. Default is 50ms. | |||
| 29 | + | # | |||
| 30 | + | # Sleep: | |||
| 31 | + | # Sleep <time> Sleep for a set amount of <time> in seconds | |||
| 32 | + | # | |||
| 33 | + | # Type: | |||
| 34 | + | # Type[@<time>] "<characters>" Type <characters> into the terminal with a | |||
| 35 | + | # <time> delay between each character | |||
| 36 | + | # | |||
| 37 | + | # Keys: | |||
| 38 | + | # Backspace[@<time>] [number] Press the Backspace key | |||
| 39 | + | # Down[@<time>] [number] Press the Down key | |||
| 40 | + | # Enter[@<time>] [number] Press the Enter key | |||
| 41 | + | # Space[@<time>] [number] Press the Space key | |||
| 42 | + | # Tab[@<time>] [number] Press the Tab key | |||
| 43 | + | # Left[@<time>] [number] Press the Left Arrow key | |||
| 44 | + | # Right[@<time>] [number] Press the Right Arrow key | |||
| 45 | + | # Up[@<time>] [number] Press the Up Arrow key | |||
| 46 | + | # Down[@<time>] [number] Press the Down Arrow key | |||
| 47 | + | # PageUp[@<time>] [number] Press the Page Up key | |||
| 48 | + | # PageDown[@<time>] [number] Press the Page Down key | |||
| 49 | + | # Ctrl+<key> Press the Control key + <key> (e.g. Ctrl+C) | |||
| 50 | + | # | |||
| 51 | + | # Display: | |||
| 52 | + | # Hide Hide the subsequent commands from the output | |||
| 53 | + | # Show Show the subsequent commands in the output | |||
| 54 | + | ||||
| 55 | + | Output demo.gif | |||
| 56 | + | ||||
| 57 | + | Require echo | |||
| 58 | + | Require gordon | |||
| 59 | + | ||||
| 60 | + | Set TypingSpeed 75ms | |||
| 61 | + | Set Shell "bash" | |||
| 62 | + | Set FontSize 20 | |||
| 63 | + | Set FontFamily "FiraCode Nerd Font" | |||
| 64 | + | Set Width 1200 | |||
| 65 | + | Set Height 600 | |||
| 66 | + | ||||
| 67 | + | Type "gordon" | |||
| 68 | + | Sleep 1 | |||
| 69 | + | Space | |||
| 70 | + | Type "deploy" | |||
| 71 | + | Sleep 1 | |||
| 72 | + | Space | |||
| 73 | + | Type "bnema/go-helloworld-http:latest" | |||
| 74 | + | Sleep 1 | |||
| 75 | + | Space | |||
| 76 | + | Type "-p 8888:8080" | |||
| 77 | + | Sleep 1 | |||
| 78 | + | Space | |||
| 79 | + | Type "-t hello.bamen.dev" | |||
| 80 | + | Sleep 1 | |||
| 81 | + | Enter | |||
| 82 | + | ||||
| 83 | + | Sleep 10 | |||
| 84 | + | ||||
| 85 | + | Type "curl" | |||
| 86 | + | Sleep 1 | |||
| 87 | + | Space | |||
| 88 | + | Type "https://hello.bamen.dev" | |||
| 89 | + | Enter | |||
| 90 | + | ||||
| 91 | + | Sleep 10 | |||
M go.mod +13 -0
| @@ -5,6 +5,8 @@ go 1.21.3 | |||
| 5 | 5 | require ( | |
| 6 | 6 | github.com/AlecAivazis/survey/v2 v2.3.7 | |
| 7 | 7 | github.com/PuerkitoBio/goquery v1.8.1 | |
| 8 | + | github.com/charmbracelet/bubbles v0.16.1 | |
| 9 | + | github.com/charmbracelet/lipgloss v0.7.1 | |
| 8 | 10 | github.com/cheggaaa/pb v1.0.29 | |
| 9 | 11 | github.com/docker/docker v24.0.6+incompatible | |
| 10 | 12 | github.com/docker/go-connections v0.4.0 | |
| @@ -21,16 +23,27 @@ require ( | |||
| 21 | 23 | ) | |
| 22 | 24 | ||
| 23 | 25 | require ( | |
| 26 | + | github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect | |
| 27 | + | github.com/charmbracelet/bubbletea v0.24.1 // indirect | |
| 28 | + | github.com/charmbracelet/harmonica v0.2.0 // indirect | |
| 29 | + | github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 // indirect | |
| 24 | 30 | github.com/creack/pty v1.1.18 // indirect | |
| 25 | 31 | github.com/dustin/go-humanize v1.0.1 // indirect | |
| 26 | 32 | github.com/inconshreveable/mousetrap v1.1.0 // indirect | |
| 27 | 33 | github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect | |
| 28 | 34 | github.com/kr/text v0.2.0 // indirect | |
| 35 | + | github.com/lucasb-eyer/go-colorful v1.2.0 // indirect | |
| 36 | + | github.com/mattn/go-localereader v0.0.1 // indirect | |
| 29 | 37 | github.com/mattn/go-runewidth v0.0.15 // indirect | |
| 30 | 38 | github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d // indirect | |
| 39 | + | github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b // indirect | |
| 40 | + | github.com/muesli/cancelreader v0.2.2 // indirect | |
| 41 | + | github.com/muesli/reflow v0.3.0 // indirect | |
| 42 | + | github.com/muesli/termenv v0.15.1 // indirect | |
| 31 | 43 | github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect | |
| 32 | 44 | github.com/rivo/uniseg v0.4.4 // indirect | |
| 33 | 45 | github.com/spf13/pflag v1.0.5 // indirect | |
| 46 | + | golang.org/x/sync v0.4.0 // indirect | |
| 34 | 47 | golang.org/x/term v0.13.0 // indirect | |
| 35 | 48 | lukechampine.com/uint128 v1.3.0 // indirect | |
| 36 | 49 | modernc.org/cc/v3 v3.41.0 // indirect | |
| @@ -5,6 +5,8 @@ go 1.21.3 | |||||
| 5 | require ( | 5 | require ( | ||
| 6 | github.com/AlecAivazis/survey/v2 v2.3.7 | 6 | github.com/AlecAivazis/survey/v2 v2.3.7 | ||
| 7 | github.com/PuerkitoBio/goquery v1.8.1 | 7 | github.com/PuerkitoBio/goquery v1.8.1 | ||
| 8 | + | github.com/charmbracelet/bubbles v0.16.1 | |||
| 9 | + | github.com/charmbracelet/lipgloss v0.7.1 | |||
| 8 | github.com/cheggaaa/pb v1.0.29 | 10 | github.com/cheggaaa/pb v1.0.29 | ||
| 9 | github.com/docker/docker v24.0.6+incompatible | 11 | github.com/docker/docker v24.0.6+incompatible | ||
| 10 | github.com/docker/go-connections v0.4.0 | 12 | github.com/docker/go-connections v0.4.0 | ||
| @@ -21,16 +23,27 @@ require ( | |||||
| 21 | ) | 23 | ) | ||
| 22 | 24 | ||||
| 23 | require ( | 25 | require ( | ||
| 26 | + | github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect | |||
| 27 | + | github.com/charmbracelet/bubbletea v0.24.1 // indirect | |||
| 28 | + | github.com/charmbracelet/harmonica v0.2.0 // indirect | |||
| 29 | + | github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 // indirect | |||
| 24 | github.com/creack/pty v1.1.18 // indirect | 30 | github.com/creack/pty v1.1.18 // indirect | ||
| 25 | github.com/dustin/go-humanize v1.0.1 // indirect | 31 | github.com/dustin/go-humanize v1.0.1 // indirect | ||
| 26 | github.com/inconshreveable/mousetrap v1.1.0 // indirect | 32 | github.com/inconshreveable/mousetrap v1.1.0 // indirect | ||
| 27 | github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect | 33 | github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect | ||
| 28 | github.com/kr/text v0.2.0 // indirect | 34 | github.com/kr/text v0.2.0 // indirect | ||
| 35 | + | github.com/lucasb-eyer/go-colorful v1.2.0 // indirect | |||
| 36 | + | github.com/mattn/go-localereader v0.0.1 // indirect | |||
| 29 | github.com/mattn/go-runewidth v0.0.15 // indirect | 37 | github.com/mattn/go-runewidth v0.0.15 // indirect | ||
| 30 | github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d // indirect | 38 | github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d // indirect | ||
| 39 | + | github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b // indirect | |||
| 40 | + | github.com/muesli/cancelreader v0.2.2 // indirect | |||
| 41 | + | github.com/muesli/reflow v0.3.0 // indirect | |||
| 42 | + | github.com/muesli/termenv v0.15.1 // indirect | |||
| 31 | github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect | 43 | github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect | ||
| 32 | github.com/rivo/uniseg v0.4.4 // indirect | 44 | github.com/rivo/uniseg v0.4.4 // indirect | ||
| 33 | github.com/spf13/pflag v1.0.5 // indirect | 45 | github.com/spf13/pflag v1.0.5 // indirect | ||
| 46 | + | golang.org/x/sync v0.4.0 // indirect | |||
| 34 | golang.org/x/term v0.13.0 // indirect | 47 | golang.org/x/term v0.13.0 // indirect | ||
| 35 | lukechampine.com/uint128 v1.3.0 // indirect | 48 | lukechampine.com/uint128 v1.3.0 // indirect | ||
| 36 | modernc.org/cc/v3 v3.41.0 // indirect | 49 | modernc.org/cc/v3 v3.41.0 // indirect | ||
M go.sum +27 -0 generated file: collapsed
A internal/cli/cmd/deploy.go +148 -0
| @@ -0,0 +1,148 @@ | |||
| 1 | + | package cmd | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "crypto/tls" | |
| 5 | + | "fmt" | |
| 6 | + | "io" | |
| 7 | + | "net/http" | |
| 8 | + | "os" | |
| 9 | + | "strings" | |
| 10 | + | "time" | |
| 11 | + | ||
| 12 | + | "github.com/bnema/gordon/internal/cli" | |
| 13 | + | "github.com/bnema/gordon/internal/cli/handler" | |
| 14 | + | "github.com/bnema/gordon/internal/cli/model" | |
| 15 | + | "github.com/bnema/gordon/internal/common" | |
| 16 | + | "github.com/bnema/gordon/pkg/docker" | |
| 17 | + | "github.com/fatih/color" | |
| 18 | + | "github.com/spf13/cobra" | |
| 19 | + | ) | |
| 20 | + | ||
| 21 | + | func NewDeployCommand(a *cli.App) *cobra.Command { | |
| 22 | + | var port string | |
| 23 | + | var targetDomain string | |
| 24 | + | ||
| 25 | + | deployCmd := &cobra.Command{ | |
| 26 | + | Use: "deploy [image:tag]", | |
| 27 | + | Short: "Deploy an image to your remote Gordon instance", | |
| 28 | + | Args: cobra.ExactArgs(1), | |
| 29 | + | PreRun: func(cmd *cobra.Command, args []string) { | |
| 30 | + | if err := handler.FieldCheck(a); err != nil { | |
| 31 | + | fmt.Println("Field check failed:", err) | |
| 32 | + | os.Exit(1) | |
| 33 | + | } | |
| 34 | + | }, | |
| 35 | + | Run: func(cmd *cobra.Command, args []string) { | |
| 36 | + | imageName := args[0] | |
| 37 | + | fmt.Println("Exporting image:", imageName) | |
| 38 | + | ||
| 39 | + | // Validate the image name | |
| 40 | + | if err := handler.ValidateImageName(imageName); err != nil { | |
| 41 | + | fmt.Println(err) | |
| 42 | + | return | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | // Ensure the image has a tag | |
| 46 | + | handler.EnsureImageTag(&imageName) | |
| 47 | + | ||
| 48 | + | // Validate the port mapping | |
| 49 | + | if err := handler.ValidatePortMapping(port); err != nil { | |
| 50 | + | fmt.Println(err) | |
| 51 | + | return | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | // Validate the target domain | |
| 55 | + | if err := handler.ValidateTargetDomain(targetDomain); err != nil { | |
| 56 | + | fmt.Println(err) | |
| 57 | + | return | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | reader, actualSize, err := exportDockerImage(imageName) | |
| 61 | + | if err != nil { | |
| 62 | + | fmt.Println("Error exporting image:", err) | |
| 63 | + | return | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | fmt.Print(actualSize) | |
| 67 | + | ||
| 68 | + | // Create a RequestPayload and populate it | |
| 69 | + | reqPayload := common.RequestPayload{ | |
| 70 | + | Type: "deploy", | |
| 71 | + | Payload: common.DeployPayload{ | |
| 72 | + | Ports: port, | |
| 73 | + | TargetDomain: targetDomain, | |
| 74 | + | ImageName: imageName, | |
| 75 | + | Data: reader, | |
| 76 | + | }, | |
| 77 | + | } | |
| 78 | + | // Send the request to the backend | |
| 79 | + | resp, err := handler.SendHTTPRequest(a, &reqPayload, "POST", "/push") | |
| 80 | + | if err != nil { | |
| 81 | + | fmt.Println("Error sending HTTP request:", err) | |
| 82 | + | return | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | // Check the response | |
| 86 | + | targetDomain := string(resp.Body) | |
| 87 | + | targetDomain = strings.TrimSpace(targetDomain) // Remove leading and trailing whitespace | |
| 88 | + | targetDomain = strings.Trim(targetDomain, "\"") // Remove leading and trailing quotes | |
| 89 | + | ||
| 90 | + | // Determine if the target is HTTPS or HTTP | |
| 91 | + | isHTTPS := strings.HasPrefix(targetDomain, "https://") | |
| 92 | + | ||
| 93 | + | // Initialize HTTP client | |
| 94 | + | client := &http.Client{ | |
| 95 | + | Timeout: 5 * time.Second, | |
| 96 | + | } | |
| 97 | + | ||
| 98 | + | // Only set TLS config if target is HTTPS | |
| 99 | + | if isHTTPS { | |
| 100 | + | client.Transport = &http.Transport{ | |
| 101 | + | TLSClientConfig: &tls.Config{InsecureSkipVerify: false}, // set false to ensure certificate is validated | |
| 102 | + | } | |
| 103 | + | } | |
| 104 | + | ||
| 105 | + | // Run the TUI program | |
| 106 | + | finalModel, err := model.RunDeploymentTUI(client, imageName, targetDomain, port) | |
| 107 | + | if err != nil { | |
| 108 | + | fmt.Println("Error running deployment TUI:", err) | |
| 109 | + | return | |
| 110 | + | } | |
| 111 | + | ||
| 112 | + | // Check if the deployment was successful | |
| 113 | + | if !finalModel.DeploymentDone { | |
| 114 | + | fmt.Println("Deployment failed.") | |
| 115 | + | return | |
| 116 | + | } | |
| 117 | + | ||
| 118 | + | // Print the final message | |
| 119 | + | color.Green("Deployment successful!") | |
| 120 | + | fmt.Println("Your application is now available at:", targetDomain) | |
| 121 | + | }, | |
| 122 | + | } | |
| 123 | + | ||
| 124 | + | // Add flags | |
| 125 | + | deployCmd.Flags().StringVarP(&port, "port", "p", "", "Port mapping for the container") | |
| 126 | + | deployCmd.Flags().StringVarP(&targetDomain, "target", "t", "", "Target domain for Traefik") | |
| 127 | + | ||
| 128 | + | return deployCmd | |
| 129 | + | } | |
| 130 | + | ||
| 131 | + | // export the docker image to a reader and return its true size | |
| 132 | + | func exportDockerImage(imageName string) (io.ReadCloser, int64, error) { | |
| 133 | + | imageID, err := docker.GetImageID(imageName) | |
| 134 | + | if err != nil { | |
| 135 | + | return nil, 0, fmt.Errorf("error getting image ID: %w", err) | |
| 136 | + | } | |
| 137 | + | ||
| 138 | + | actualSize, err := docker.GetImageSizeFromReader(imageID) | |
| 139 | + | if err != nil { | |
| 140 | + | return nil, 0, fmt.Errorf("error getting image size: %w", err) | |
| 141 | + | } | |
| 142 | + | ||
| 143 | + | reader, err := docker.ExportImageFromEngine(imageID) | |
| 144 | + | if err != nil { | |
| 145 | + | return nil, 0, fmt.Errorf("error exporting image: %w", err) | |
| 146 | + | } | |
| 147 | + | return reader, actualSize, nil | |
| 148 | + | } | |
| @@ -0,0 +1,148 @@ | |||||
| 1 | + | package cmd | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "crypto/tls" | |||
| 5 | + | "fmt" | |||
| 6 | + | "io" | |||
| 7 | + | "net/http" | |||
| 8 | + | "os" | |||
| 9 | + | "strings" | |||
| 10 | + | "time" | |||
| 11 | + | ||||
| 12 | + | "github.com/bnema/gordon/internal/cli" | |||
| 13 | + | "github.com/bnema/gordon/internal/cli/handler" | |||
| 14 | + | "github.com/bnema/gordon/internal/cli/model" | |||
| 15 | + | "github.com/bnema/gordon/internal/common" | |||
| 16 | + | "github.com/bnema/gordon/pkg/docker" | |||
| 17 | + | "github.com/fatih/color" | |||
| 18 | + | "github.com/spf13/cobra" | |||
| 19 | + | ) | |||
| 20 | + | ||||
| 21 | + | func NewDeployCommand(a *cli.App) *cobra.Command { | |||
| 22 | + | var port string | |||
| 23 | + | var targetDomain string | |||
| 24 | + | ||||
| 25 | + | deployCmd := &cobra.Command{ | |||
| 26 | + | Use: "deploy [image:tag]", | |||
| 27 | + | Short: "Deploy an image to your remote Gordon instance", | |||
| 28 | + | Args: cobra.ExactArgs(1), | |||
| 29 | + | PreRun: func(cmd *cobra.Command, args []string) { | |||
| 30 | + | if err := handler.FieldCheck(a); err != nil { | |||
| 31 | + | fmt.Println("Field check failed:", err) | |||
| 32 | + | os.Exit(1) | |||
| 33 | + | } | |||
| 34 | + | }, | |||
| 35 | + | Run: func(cmd *cobra.Command, args []string) { | |||
| 36 | + | imageName := args[0] | |||
| 37 | + | fmt.Println("Exporting image:", imageName) | |||
| 38 | + | ||||
| 39 | + | // Validate the image name | |||
| 40 | + | if err := handler.ValidateImageName(imageName); err != nil { | |||
| 41 | + | fmt.Println(err) | |||
| 42 | + | return | |||
| 43 | + | } | |||
| 44 | + | ||||
| 45 | + | // Ensure the image has a tag | |||
| 46 | + | handler.EnsureImageTag(&imageName) | |||
| 47 | + | ||||
| 48 | + | // Validate the port mapping | |||
| 49 | + | if err := handler.ValidatePortMapping(port); err != nil { | |||
| 50 | + | fmt.Println(err) | |||
| 51 | + | return | |||
| 52 | + | } | |||
| 53 | + | ||||
| 54 | + | // Validate the target domain | |||
| 55 | + | if err := handler.ValidateTargetDomain(targetDomain); err != nil { | |||
| 56 | + | fmt.Println(err) | |||
| 57 | + | return | |||
| 58 | + | } | |||
| 59 | + | ||||
| 60 | + | reader, actualSize, err := exportDockerImage(imageName) | |||
| 61 | + | if err != nil { | |||
| 62 | + | fmt.Println("Error exporting image:", err) | |||
| 63 | + | return | |||
| 64 | + | } | |||
| 65 | + | ||||
| 66 | + | fmt.Print(actualSize) | |||
| 67 | + | ||||
| 68 | + | // Create a RequestPayload and populate it | |||
| 69 | + | reqPayload := common.RequestPayload{ | |||
| 70 | + | Type: "deploy", | |||
| 71 | + | Payload: common.DeployPayload{ | |||
| 72 | + | Ports: port, | |||
| 73 | + | TargetDomain: targetDomain, | |||
| 74 | + | ImageName: imageName, | |||
| 75 | + | Data: reader, | |||
| 76 | + | }, | |||
| 77 | + | } | |||
| 78 | + | // Send the request to the backend | |||
| 79 | + | resp, err := handler.SendHTTPRequest(a, &reqPayload, "POST", "/push") | |||
| 80 | + | if err != nil { | |||
| 81 | + | fmt.Println("Error sending HTTP request:", err) | |||
| 82 | + | return | |||
| 83 | + | } | |||
| 84 | + | ||||
| 85 | + | // Check the response | |||
| 86 | + | targetDomain := string(resp.Body) | |||
| 87 | + | targetDomain = strings.TrimSpace(targetDomain) // Remove leading and trailing whitespace | |||
| 88 | + | targetDomain = strings.Trim(targetDomain, "\"") // Remove leading and trailing quotes | |||
| 89 | + | ||||
| 90 | + | // Determine if the target is HTTPS or HTTP | |||
| 91 | + | isHTTPS := strings.HasPrefix(targetDomain, "https://") | |||
| 92 | + | ||||
| 93 | + | // Initialize HTTP client | |||
| 94 | + | client := &http.Client{ | |||
| 95 | + | Timeout: 5 * time.Second, | |||
| 96 | + | } | |||
| 97 | + | ||||
| 98 | + | // Only set TLS config if target is HTTPS | |||
| 99 | + | if isHTTPS { | |||
| 100 | + | client.Transport = &http.Transport{ | |||
| 101 | + | TLSClientConfig: &tls.Config{InsecureSkipVerify: false}, // set false to ensure certificate is validated | |||
| 102 | + | } | |||
| 103 | + | } | |||
| 104 | + | ||||
| 105 | + | // Run the TUI program | |||
| 106 | + | finalModel, err := model.RunDeploymentTUI(client, imageName, targetDomain, port) | |||
| 107 | + | if err != nil { | |||
| 108 | + | fmt.Println("Error running deployment TUI:", err) | |||
| 109 | + | return | |||
| 110 | + | } | |||
| 111 | + | ||||
| 112 | + | // Check if the deployment was successful | |||
| 113 | + | if !finalModel.DeploymentDone { | |||
| 114 | + | fmt.Println("Deployment failed.") | |||
| 115 | + | return | |||
| 116 | + | } | |||
| 117 | + | ||||
| 118 | + | // Print the final message | |||
| 119 | + | color.Green("Deployment successful!") | |||
| 120 | + | fmt.Println("Your application is now available at:", targetDomain) | |||
| 121 | + | }, | |||
| 122 | + | } | |||
| 123 | + | ||||
| 124 | + | // Add flags | |||
| 125 | + | deployCmd.Flags().StringVarP(&port, "port", "p", "", "Port mapping for the container") | |||
| 126 | + | deployCmd.Flags().StringVarP(&targetDomain, "target", "t", "", "Target domain for Traefik") | |||
| 127 | + | ||||
| 128 | + | return deployCmd | |||
| 129 | + | } | |||
| 130 | + | ||||
| 131 | + | // export the docker image to a reader and return its true size | |||
| 132 | + | func exportDockerImage(imageName string) (io.ReadCloser, int64, error) { | |||
| 133 | + | imageID, err := docker.GetImageID(imageName) | |||
| 134 | + | if err != nil { | |||
| 135 | + | return nil, 0, fmt.Errorf("error getting image ID: %w", err) | |||
| 136 | + | } | |||
| 137 | + | ||||
| 138 | + | actualSize, err := docker.GetImageSizeFromReader(imageID) | |||
| 139 | + | if err != nil { | |||
| 140 | + | return nil, 0, fmt.Errorf("error getting image size: %w", err) | |||
| 141 | + | } | |||
| 142 | + | ||||
| 143 | + | reader, err := docker.ExportImageFromEngine(imageID) | |||
| 144 | + | if err != nil { | |||
| 145 | + | return nil, 0, fmt.Errorf("error exporting image: %w", err) | |||
| 146 | + | } | |||
| 147 | + | return reader, actualSize, nil | |||
| 148 | + | } | |||
M internal/cli/cmd/ping.go +1 -1
| @@ -16,7 +16,7 @@ func NewPingCommand(a *cli.App) *cobra.Command { | |||
| 16 | 16 | // handler.FieldCheck(a) | |
| 17 | 17 | return &cobra.Command{ | |
| 18 | 18 | Use: "ping", | |
| 19 | - | Short: "Send a ping request to the backend", | |
| 19 | + | Short: "Send a ping request to your remote Gordon instance", | |
| 20 | 20 | PreRun: func(cmd *cobra.Command, args []string) { | |
| 21 | 21 | if err := handler.FieldCheck(a); err != nil { | |
| 22 | 22 | fmt.Println("Field check failed:", err) | |
| @@ -16,7 +16,7 @@ func NewPingCommand(a *cli.App) *cobra.Command { | |||||
| 16 | // handler.FieldCheck(a) | 16 | // handler.FieldCheck(a) | ||
| 17 | return &cobra.Command{ | 17 | return &cobra.Command{ | ||
| 18 | Use: "ping", | 18 | Use: "ping", | ||
| 19 | - | Short: "Send a ping request to the backend", | 19 | + | Short: "Send a ping request to your remote Gordon instance", |
| 20 | PreRun: func(cmd *cobra.Command, args []string) { | 20 | PreRun: func(cmd *cobra.Command, args []string) { | ||
| 21 | if err := handler.FieldCheck(a); err != nil { | 21 | if err := handler.FieldCheck(a); err != nil { | ||
| 22 | fmt.Println("Field check failed:", err) | 22 | fmt.Println("Field check failed:", err) | ||
D internal/cli/cmd/push.go +0 -178
| @@ -1,178 +0,0 @@ | |||
| 1 | - | package cmd | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "crypto/tls" | |
| 5 | - | "fmt" | |
| 6 | - | "net/http" | |
| 7 | - | "os" | |
| 8 | - | "regexp" | |
| 9 | - | "strings" | |
| 10 | - | "time" | |
| 11 | - | ||
| 12 | - | "github.com/bnema/gordon/internal/cli" | |
| 13 | - | "github.com/bnema/gordon/internal/cli/handler" | |
| 14 | - | "github.com/bnema/gordon/internal/common" | |
| 15 | - | "github.com/bnema/gordon/pkg/docker" | |
| 16 | - | "github.com/cheggaaa/pb" | |
| 17 | - | "github.com/spf13/cobra" | |
| 18 | - | ) | |
| 19 | - | ||
| 20 | - | func NewDeployCommand(a *cli.App) *cobra.Command { | |
| 21 | - | var port string | |
| 22 | - | var targetDomain string | |
| 23 | - | ||
| 24 | - | deployCmd := &cobra.Command{ | |
| 25 | - | Use: "deploy [image:tag]", | |
| 26 | - | Short: "Deploy an image to the server, if no tag is specified, latest is used", | |
| 27 | - | Args: cobra.ExactArgs(1), | |
| 28 | - | PreRun: func(cmd *cobra.Command, args []string) { | |
| 29 | - | if err := handler.FieldCheck(a); err != nil { | |
| 30 | - | fmt.Println("Field check failed:", err) | |
| 31 | - | os.Exit(1) | |
| 32 | - | } | |
| 33 | - | }, | |
| 34 | - | Run: func(cmd *cobra.Command, args []string) { | |
| 35 | - | imageName := args[0] | |
| 36 | - | fmt.Println("Exporting image:", imageName) | |
| 37 | - | ||
| 38 | - | // Check if the image name is valid | |
| 39 | - | match, _ := regexp.MatchString("^([a-zA-Z0-9_\\-\\.]+\\/)*[a-zA-Z0-9_\\-\\.]+(:[a-zA-Z0-9_\\-\\.]+)?$", imageName) | |
| 40 | - | if !match { | |
| 41 | - | fmt.Println("You must specify a valid image name in the form (registry/)image:tag, check your container engine image list") | |
| 42 | - | return | |
| 43 | - | } | |
| 44 | - | ||
| 45 | - | // If there is no :tag at the end of the image name, we append :latest | |
| 46 | - | if !strings.Contains(imageName, ":") { | |
| 47 | - | imageName += ":latest" | |
| 48 | - | } | |
| 49 | - | ||
| 50 | - | // Check the ports struct port:port (proto is optional) | |
| 51 | - | match, _ = regexp.MatchString("^[0-9]+:[0-9]+(\\/(tcp|udp))?$", port) | |
| 52 | - | if !match { | |
| 53 | - | fmt.Println("You must specify a port mapping in the form port:port/proto, if no protocol is specified, TCP is used") | |
| 54 | - | return | |
| 55 | - | } | |
| 56 | - | ||
| 57 | - | // Check the target domain struct http(s)://domain.tld (proto is optional) | |
| 58 | - | match, _ = regexp.MatchString("^(https?:\\/\\/)?([a-zA-Z0-9\\-_\\.]+\\.)+[a-zA-Z0-9\\-_\\.]+(:[0-9]+)?$", targetDomain) | |
| 59 | - | if !match { | |
| 60 | - | fmt.Println("You must specify a valid target domain in the form http(s)://domain.tld, if no protocol is specified, HTTPS is used") | |
| 61 | - | return | |
| 62 | - | } | |
| 63 | - | ||
| 64 | - | // Get the image ID | |
| 65 | - | imageID, err := docker.GetImageID(imageName) | |
| 66 | - | if err != nil { | |
| 67 | - | fmt.Println("Error getting image ID:", err) | |
| 68 | - | return | |
| 69 | - | } | |
| 70 | - | ||
| 71 | - | // Get the image size | |
| 72 | - | totalSize, err := docker.GetImageSize(imageID) | |
| 73 | - | if err != nil { | |
| 74 | - | fmt.Println("Error estimating image size:", err) | |
| 75 | - | return | |
| 76 | - | } | |
| 77 | - | ||
| 78 | - | // Create a new progress bar | |
| 79 | - | bar := pb.New64(totalSize).SetUnits(pb.U_BYTES) | |
| 80 | - | reader, err := docker.ExportImageFromEngine(imageID) | |
| 81 | - | if err != nil { | |
| 82 | - | fmt.Println("Error exporting image:", err) | |
| 83 | - | return | |
| 84 | - | } | |
| 85 | - | // Wrap the original reader with progress bar reader | |
| 86 | - | progressReader := bar.NewProxyReader(reader) | |
| 87 | - | ||
| 88 | - | // Create a RequestPayload and populate it | |
| 89 | - | reqPayload := common.RequestPayload{ | |
| 90 | - | Type: "deploy", | |
| 91 | - | Payload: common.DeployPayload{ | |
| 92 | - | Ports: port, | |
| 93 | - | TargetDomain: targetDomain, | |
| 94 | - | ImageName: imageName, | |
| 95 | - | Data: progressReader, | |
| 96 | - | }, | |
| 97 | - | } | |
| 98 | - | ||
| 99 | - | // Start the progress bar | |
| 100 | - | bar.Start() | |
| 101 | - | // Send the request to the backend | |
| 102 | - | resp, err := handler.SendHTTPRequest(a, &reqPayload, "POST", "/push") | |
| 103 | - | if err != nil { | |
| 104 | - | fmt.Println("Error sending HTTP request:", err) | |
| 105 | - | return | |
| 106 | - | } | |
| 107 | - | // Stop the progress bar | |
| 108 | - | bar.Finish() | |
| 109 | - | ||
| 110 | - | // Check the response | |
| 111 | - | targetDomain := string(resp.Body) | |
| 112 | - | targetDomain = strings.TrimSpace(targetDomain) // Remove leading and trailing whitespace | |
| 113 | - | targetDomain = strings.Trim(targetDomain, "\"") // Remove leading and trailing quotes | |
| 114 | - | ||
| 115 | - | // Notify user | |
| 116 | - | fmt.Println("Container is running !") | |
| 117 | - | // Initialize counter for retries | |
| 118 | - | retryCount := 0 | |
| 119 | - | maxRetries := 40 | |
| 120 | - | progressIndicator := "" | |
| 121 | - | ||
| 122 | - | // Determine if the target is HTTPS or HTTP | |
| 123 | - | isHTTPS := strings.HasPrefix(targetDomain, "https://") | |
| 124 | - | ||
| 125 | - | // Initialize HTTP client | |
| 126 | - | client := &http.Client{ | |
| 127 | - | Timeout: 5 * time.Second, | |
| 128 | - | } | |
| 129 | - | ||
| 130 | - | // Only set TLS config if target is HTTPS | |
| 131 | - | if isHTTPS { | |
| 132 | - | client.Transport = &http.Transport{ | |
| 133 | - | TLSClientConfig: &tls.Config{InsecureSkipVerify: false}, // set false to ensure certificate is validated | |
| 134 | - | } | |
| 135 | - | } | |
| 136 | - | ||
| 137 | - | // Check URL availability | |
| 138 | - | for { | |
| 139 | - | // Making GET request to the target domain | |
| 140 | - | _, err := client.Get(targetDomain) | |
| 141 | - | if err != nil { | |
| 142 | - | retryCount++ | |
| 143 | - | if retryCount >= maxRetries { | |
| 144 | - | fmt.Println("\nImpossible to access the domain. Are you sure it is correct and that Traefik recognizes it?") | |
| 145 | - | break | |
| 146 | - | } | |
| 147 | - | } else { | |
| 148 | - | fmt.Println("\nDomain is available at:", targetDomain) | |
| 149 | - | break | |
| 150 | - | } | |
| 151 | - | ||
| 152 | - | // Update the progress indicator | |
| 153 | - | progressIndicator += "." | |
| 154 | - | ||
| 155 | - | // Limit the progress indicator to 5 dots | |
| 156 | - | if len(progressIndicator) > 5 { | |
| 157 | - | flush := "\r" | |
| 158 | - | for i := 0; i < len(progressIndicator); i++ { | |
| 159 | - | flush += " " | |
| 160 | - | } | |
| 161 | - | } | |
| 162 | - | ||
| 163 | - | // Use \r to move the cursor to the beginning of the line | |
| 164 | - | fmt.Printf("\r%s%s", "Wait while Traefik is setting up the domain and certificate", progressIndicator) | |
| 165 | - | ||
| 166 | - | // Wait for 1 second before the next check | |
| 167 | - | time.Sleep(time.Second) | |
| 168 | - | } | |
| 169 | - | ||
| 170 | - | }, | |
| 171 | - | } | |
| 172 | - | ||
| 173 | - | // Add flags | |
| 174 | - | deployCmd.Flags().StringVarP(&port, "port", "p", "", "Port mapping for the container") | |
| 175 | - | deployCmd.Flags().StringVarP(&targetDomain, "target", "t", "", "Target domain for Traefik") | |
| 176 | - | ||
| 177 | - | return deployCmd | |
| 178 | - | } | |
| @@ -1,178 +0,0 @@ | |||||
| 1 | - | package cmd | |||
| 2 | - | ||||
| 3 | - | import ( | |||
| 4 | - | "crypto/tls" | |||
| 5 | - | "fmt" | |||
| 6 | - | "net/http" | |||
| 7 | - | "os" | |||
| 8 | - | "regexp" | |||
| 9 | - | "strings" | |||
| 10 | - | "time" | |||
| 11 | - | ||||
| 12 | - | "github.com/bnema/gordon/internal/cli" | |||
| 13 | - | "github.com/bnema/gordon/internal/cli/handler" | |||
| 14 | - | "github.com/bnema/gordon/internal/common" | |||
| 15 | - | "github.com/bnema/gordon/pkg/docker" | |||
| 16 | - | "github.com/cheggaaa/pb" | |||
| 17 | - | "github.com/spf13/cobra" | |||
| 18 | - | ) | |||
| 19 | - | ||||
| 20 | - | func NewDeployCommand(a *cli.App) *cobra.Command { | |||
| 21 | - | var port string | |||
| 22 | - | var targetDomain string | |||
| 23 | - | ||||
| 24 | - | deployCmd := &cobra.Command{ | |||
| 25 | - | Use: "deploy [image:tag]", | |||
| 26 | - | Short: "Deploy an image to the server, if no tag is specified, latest is used", | |||
| 27 | - | Args: cobra.ExactArgs(1), | |||
| 28 | - | PreRun: func(cmd *cobra.Command, args []string) { | |||
| 29 | - | if err := handler.FieldCheck(a); err != nil { | |||
| 30 | - | fmt.Println("Field check failed:", err) | |||
| 31 | - | os.Exit(1) | |||
| 32 | - | } | |||
| 33 | - | }, | |||
| 34 | - | Run: func(cmd *cobra.Command, args []string) { | |||
| 35 | - | imageName := args[0] | |||
| 36 | - | fmt.Println("Exporting image:", imageName) | |||
| 37 | - | ||||
| 38 | - | // Check if the image name is valid | |||
| 39 | - | match, _ := regexp.MatchString("^([a-zA-Z0-9_\\-\\.]+\\/)*[a-zA-Z0-9_\\-\\.]+(:[a-zA-Z0-9_\\-\\.]+)?$", imageName) | |||
| 40 | - | if !match { | |||
| 41 | - | fmt.Println("You must specify a valid image name in the form (registry/)image:tag, check your container engine image list") | |||
| 42 | - | return | |||
| 43 | - | } | |||
| 44 | - | ||||
| 45 | - | // If there is no :tag at the end of the image name, we append :latest | |||
| 46 | - | if !strings.Contains(imageName, ":") { | |||
| 47 | - | imageName += ":latest" | |||
| 48 | - | } | |||
| 49 | - | ||||
| 50 | - | // Check the ports struct port:port (proto is optional) | |||
| 51 | - | match, _ = regexp.MatchString("^[0-9]+:[0-9]+(\\/(tcp|udp))?$", port) | |||
| 52 | - | if !match { | |||
| 53 | - | fmt.Println("You must specify a port mapping in the form port:port/proto, if no protocol is specified, TCP is used") | |||
| 54 | - | return | |||
| 55 | - | } | |||
| 56 | - | ||||
| 57 | - | // Check the target domain struct http(s)://domain.tld (proto is optional) | |||
| 58 | - | match, _ = regexp.MatchString("^(https?:\\/\\/)?([a-zA-Z0-9\\-_\\.]+\\.)+[a-zA-Z0-9\\-_\\.]+(:[0-9]+)?$", targetDomain) | |||
| 59 | - | if !match { | |||
| 60 | - | fmt.Println("You must specify a valid target domain in the form http(s)://domain.tld, if no protocol is specified, HTTPS is used") | |||
| 61 | - | return | |||
| 62 | - | } | |||
| 63 | - | ||||
| 64 | - | // Get the image ID | |||
| 65 | - | imageID, err := docker.GetImageID(imageName) | |||
| 66 | - | if err != nil { | |||
| 67 | - | fmt.Println("Error getting image ID:", err) | |||
| 68 | - | return | |||
| 69 | - | } | |||
| 70 | - | ||||
| 71 | - | // Get the image size | |||
| 72 | - | totalSize, err := docker.GetImageSize(imageID) | |||
| 73 | - | if err != nil { | |||
| 74 | - | fmt.Println("Error estimating image size:", err) | |||
| 75 | - | return | |||
| 76 | - | } | |||
| 77 | - | ||||
| 78 | - | // Create a new progress bar | |||
| 79 | - | bar := pb.New64(totalSize).SetUnits(pb.U_BYTES) | |||
| 80 | - | reader, err := docker.ExportImageFromEngine(imageID) | |||
| 81 | - | if err != nil { | |||
| 82 | - | fmt.Println("Error exporting image:", err) | |||
| 83 | - | return | |||
| 84 | - | } | |||
| 85 | - | // Wrap the original reader with progress bar reader | |||
| 86 | - | progressReader := bar.NewProxyReader(reader) | |||
| 87 | - | ||||
| 88 | - | // Create a RequestPayload and populate it | |||
| 89 | - | reqPayload := common.RequestPayload{ | |||
| 90 | - | Type: "deploy", | |||
| 91 | - | Payload: common.DeployPayload{ | |||
| 92 | - | Ports: port, | |||
| 93 | - | TargetDomain: targetDomain, | |||
| 94 | - | ImageName: imageName, | |||
| 95 | - | Data: progressReader, | |||
| 96 | - | }, | |||
| 97 | - | } | |||
| 98 | - | ||||
| 99 | - | // Start the progress bar | |||
| 100 | - | bar.Start() | |||
| 101 | - | // Send the request to the backend | |||
| 102 | - | resp, err := handler.SendHTTPRequest(a, &reqPayload, "POST", "/push") | |||
| 103 | - | if err != nil { | |||
| 104 | - | fmt.Println("Error sending HTTP request:", err) | |||
| 105 | - | return | |||
| 106 | - | } | |||
| 107 | - | // Stop the progress bar | |||
| 108 | - | bar.Finish() | |||
| 109 | - | ||||
| 110 | - | // Check the response | |||
| 111 | - | targetDomain := string(resp.Body) | |||
| 112 | - | targetDomain = strings.TrimSpace(targetDomain) // Remove leading and trailing whitespace | |||
| 113 | - | targetDomain = strings.Trim(targetDomain, "\"") // Remove leading and trailing quotes | |||
| 114 | - | ||||
| 115 | - | // Notify user | |||
| 116 | - | fmt.Println("Container is running !") | |||
| 117 | - | // Initialize counter for retries | |||
| 118 | - | retryCount := 0 | |||
| 119 | - | maxRetries := 40 | |||
| 120 | - | progressIndicator := "" | |||
| 121 | - | ||||
| 122 | - | // Determine if the target is HTTPS or HTTP | |||
| 123 | - | isHTTPS := strings.HasPrefix(targetDomain, "https://") | |||
| 124 | - | ||||
| 125 | - | // Initialize HTTP client | |||
| 126 | - | client := &http.Client{ | |||
| 127 | - | Timeout: 5 * time.Second, | |||
| 128 | - | } | |||
| 129 | - | ||||
| 130 | - | // Only set TLS config if target is HTTPS | |||
| 131 | - | if isHTTPS { | |||
| 132 | - | client.Transport = &http.Transport{ | |||
| 133 | - | TLSClientConfig: &tls.Config{InsecureSkipVerify: false}, // set false to ensure certificate is validated | |||
| 134 | - | } | |||
| 135 | - | } | |||
| 136 | - | ||||
| 137 | - | // Check URL availability | |||
| 138 | - | for { | |||
| 139 | - | // Making GET request to the target domain | |||
| 140 | - | _, err := client.Get(targetDomain) | |||
| 141 | - | if err != nil { | |||
| 142 | - | retryCount++ | |||
| 143 | - | if retryCount >= maxRetries { | |||
| 144 | - | fmt.Println("\nImpossible to access the domain. Are you sure it is correct and that Traefik recognizes it?") | |||
| 145 | - | break | |||
| 146 | - | } | |||
| 147 | - | } else { | |||
| 148 | - | fmt.Println("\nDomain is available at:", targetDomain) | |||
| 149 | - | break | |||
| 150 | - | } | |||
| 151 | - | ||||
| 152 | - | // Update the progress indicator | |||
| 153 | - | progressIndicator += "." | |||
| 154 | - | ||||
| 155 | - | // Limit the progress indicator to 5 dots | |||
| 156 | - | if len(progressIndicator) > 5 { | |||
| 157 | - | flush := "\r" | |||
| 158 | - | for i := 0; i < len(progressIndicator); i++ { | |||
| 159 | - | flush += " " | |||
| 160 | - | } | |||
| 161 | - | } | |||
| 162 | - | ||||
| 163 | - | // Use \r to move the cursor to the beginning of the line | |||
| 164 | - | fmt.Printf("\r%s%s", "Wait while Traefik is setting up the domain and certificate", progressIndicator) | |||
| 165 | - | ||||
| 166 | - | // Wait for 1 second before the next check | |||
| 167 | - | time.Sleep(time.Second) | |||
| 168 | - | } | |||
| 169 | - | ||||
| 170 | - | }, | |||
| 171 | - | } | |||
| 172 | - | ||||
| 173 | - | // Add flags | |||
| 174 | - | deployCmd.Flags().StringVarP(&port, "port", "p", "", "Port mapping for the container") | |||
| 175 | - | deployCmd.Flags().StringVarP(&targetDomain, "target", "t", "", "Target domain for Traefik") | |||
| 176 | - | ||||
| 177 | - | return deployCmd | |||
| 178 | - | } | |||
M internal/cli/cmd/root.go +12 -17
| @@ -12,33 +12,28 @@ import ( | |||
| 12 | 12 | ||
| 13 | 13 | // NewRootCommand creates a new root command | |
| 14 | 14 | func NewRootCommand(a *cli.App) *cobra.Command { | |
| 15 | - | cmd := &cobra.Command{ | |
| 16 | - | Use: "gordon", | |
| 17 | - | Short: "Gordon is a CLI for the Gordon project", | |
| 15 | + | return &cobra.Command{ | |
| 18 | 16 | Run: func(cmd *cobra.Command, args []string) { | |
| 19 | - | if !cmd.HasSubCommands() || cmd.CalledAs() == "" { | |
| 20 | - | color.Green("Gordon %s", a.Config.GetVersion()) | |
| 21 | 17 | ||
| 22 | - | backendVersion, err := getBackendVersion(a) | |
| 23 | - | if err != nil { | |
| 24 | - | fmt.Println("Error getting backend version:", err) | |
| 25 | - | return | |
| 26 | - | } | |
| 18 | + | backendVersion := getBackendVersion(a) | |
| 19 | + | color.Green("Gordon %s", a.Config.GetVersion()) | |
| 20 | + | color.Blue("Gordon Backend %s", backendVersion) | |
| 27 | 21 | ||
| 28 | - | // Using color.Blue function to print in blue | |
| 29 | - | color.Blue("Gordon Backend %s", backendVersion) | |
| 22 | + | if backendVersion != a.Config.GetVersion() { | |
| 23 | + | color.Yellow("A new version of Gordon is available") | |
| 30 | 24 | } | |
| 25 | + | ||
| 26 | + | fmt.Println() | |
| 27 | + | fmt.Println("Use \"gordon --help\" for more information about a command.") | |
| 31 | 28 | }, | |
| 32 | 29 | } | |
| 33 | - | ||
| 34 | - | return cmd | |
| 35 | 30 | } | |
| 36 | 31 | ||
| 37 | - | func getBackendVersion(a *cli.App) (string, error) { | |
| 32 | + | func getBackendVersion(a *cli.App) string { | |
| 38 | 33 | pingResp, err := handler.PerformPingRequest(a) | |
| 39 | 34 | if err != nil { | |
| 40 | - | return "", err | |
| 35 | + | return fmt.Sprintf("Error: %s", err) | |
| 41 | 36 | } | |
| 42 | 37 | ||
| 43 | - | return pingResp.Version, nil | |
| 38 | + | return pingResp.Version | |
| 44 | 39 | } | |
| @@ -12,33 +12,28 @@ import ( | |||||
| 12 | 12 | ||||
| 13 | // NewRootCommand creates a new root command | 13 | // NewRootCommand creates a new root command | ||
| 14 | func NewRootCommand(a *cli.App) *cobra.Command { | 14 | func NewRootCommand(a *cli.App) *cobra.Command { | ||
| 15 | - | cmd := &cobra.Command{ | 15 | + | return &cobra.Command{ |
| 16 | - | Use: "gordon", | |||
| 17 | - | Short: "Gordon is a CLI for the Gordon project", | |||
| 18 | Run: func(cmd *cobra.Command, args []string) { | 16 | Run: func(cmd *cobra.Command, args []string) { | ||
| 19 | - | if !cmd.HasSubCommands() || cmd.CalledAs() == "" { | |||
| 20 | - | color.Green("Gordon %s", a.Config.GetVersion()) | |||
| 21 | 17 | ||||
| 22 | - | backendVersion, err := getBackendVersion(a) | 18 | + | backendVersion := getBackendVersion(a) |
| 23 | - | if err != nil { | 19 | + | color.Green("Gordon %s", a.Config.GetVersion()) |
| 24 | - | fmt.Println("Error getting backend version:", err) | 20 | + | color.Blue("Gordon Backend %s", backendVersion) |
| 25 | - | return | |||
| 26 | - | } | |||
| 27 | 21 | ||||
| 28 | - | // Using color.Blue function to print in blue | 22 | + | if backendVersion != a.Config.GetVersion() { |
| 29 | - | color.Blue("Gordon Backend %s", backendVersion) | 23 | + | color.Yellow("A new version of Gordon is available") |
| 30 | } | 24 | } | ||
| 25 | + | ||||
| 26 | + | fmt.Println() | |||
| 27 | + | fmt.Println("Use \"gordon --help\" for more information about a command.") | |||
| 31 | }, | 28 | }, | ||
| 32 | } | 29 | } | ||
| 33 | - | ||||
| 34 | - | return cmd | |||
| 35 | } | 30 | } | ||
| 36 | 31 | ||||
| 37 | - | func getBackendVersion(a *cli.App) (string, error) { | 32 | + | func getBackendVersion(a *cli.App) string { |
| 38 | pingResp, err := handler.PerformPingRequest(a) | 33 | pingResp, err := handler.PerformPingRequest(a) | ||
| 39 | if err != nil { | 34 | if err != nil { | ||
| 40 | - | return "", err | 35 | + | return fmt.Sprintf("Error: %s", err) |
| 41 | } | 36 | } | ||
| 42 | 37 | ||||
| 43 | - | return pingResp.Version, nil | 38 | + | return pingResp.Version |
| 44 | } | 39 | } | ||
M internal/cli/cmd/serve.go +1 -1
| @@ -22,7 +22,7 @@ func NewServeCommand(a *server.App) *cobra.Command { | |||
| 22 | 22 | // if no flags -p or --port are specified, the default port is used | |
| 23 | 23 | serveCmd := &cobra.Command{ | |
| 24 | 24 | Use: "serve", | |
| 25 | - | Short: "Start the Gordon server", | |
| 25 | + | Short: "Start a new Gordon server instance", | |
| 26 | 26 | Args: cobra.NoArgs, | |
| 27 | 27 | Run: func(cmd *cobra.Command, args []string) { | |
| 28 | 28 | // handler.StartServer will use the value of port, which will be the default | |
| @@ -22,7 +22,7 @@ func NewServeCommand(a *server.App) *cobra.Command { | |||||
| 22 | // if no flags -p or --port are specified, the default port is used | 22 | // if no flags -p or --port are specified, the default port is used | ||
| 23 | serveCmd := &cobra.Command{ | 23 | serveCmd := &cobra.Command{ | ||
| 24 | Use: "serve", | 24 | Use: "serve", | ||
| 25 | - | Short: "Start the Gordon server", | 25 | + | Short: "Start a new Gordon server instance", |
| 26 | Args: cobra.NoArgs, | 26 | Args: cobra.NoArgs, | ||
| 27 | Run: func(cmd *cobra.Command, args []string) { | 27 | Run: func(cmd *cobra.Command, args []string) { | ||
| 28 | // handler.StartServer will use the value of port, which will be the default | 28 | // handler.StartServer will use the value of port, which will be the default | ||
A internal/cli/handler/regexcheck.go +37 -0
| @@ -0,0 +1,37 @@ | |||
| 1 | + | package handler | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "fmt" | |
| 5 | + | "regexp" | |
| 6 | + | "strings" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | func ValidateImageName(imageName string) error { | |
| 10 | + | match, _ := regexp.MatchString("^([a-zA-Z0-9_\\-\\.]+\\/)*[a-zA-Z0-9_\\-\\.]+(:[a-zA-Z0-9_\\-\\.]+)?$", imageName) | |
| 11 | + | if !match { | |
| 12 | + | return fmt.Errorf("invalid image name format") | |
| 13 | + | } | |
| 14 | + | return nil | |
| 15 | + | } | |
| 16 | + | ||
| 17 | + | func EnsureImageTag(imageName *string) { | |
| 18 | + | if !strings.Contains(*imageName, ":") { | |
| 19 | + | *imageName += ":latest" | |
| 20 | + | } | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | func ValidatePortMapping(port string) error { | |
| 24 | + | match, _ := regexp.MatchString("^[0-9]+:[0-9]+(\\/(tcp|udp))?$", port) | |
| 25 | + | if !match { | |
| 26 | + | return fmt.Errorf("invalid port mapping format") | |
| 27 | + | } | |
| 28 | + | return nil | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | func ValidateTargetDomain(targetDomain string) error { | |
| 32 | + | match, _ := regexp.MatchString("^(https?:\\/\\/)?([a-zA-Z0-9\\-_\\.]+\\.)+[a-zA-Z0-9\\-_\\.]+(:[0-9]+)?$", targetDomain) | |
| 33 | + | if !match { | |
| 34 | + | return fmt.Errorf("invalid target domain format") | |
| 35 | + | } | |
| 36 | + | return nil | |
| 37 | + | } | |
| @@ -0,0 +1,37 @@ | |||||
| 1 | + | package handler | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "fmt" | |||
| 5 | + | "regexp" | |||
| 6 | + | "strings" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | func ValidateImageName(imageName string) error { | |||
| 10 | + | match, _ := regexp.MatchString("^([a-zA-Z0-9_\\-\\.]+\\/)*[a-zA-Z0-9_\\-\\.]+(:[a-zA-Z0-9_\\-\\.]+)?$", imageName) | |||
| 11 | + | if !match { | |||
| 12 | + | return fmt.Errorf("invalid image name format") | |||
| 13 | + | } | |||
| 14 | + | return nil | |||
| 15 | + | } | |||
| 16 | + | ||||
| 17 | + | func EnsureImageTag(imageName *string) { | |||
| 18 | + | if !strings.Contains(*imageName, ":") { | |||
| 19 | + | *imageName += ":latest" | |||
| 20 | + | } | |||
| 21 | + | } | |||
| 22 | + | ||||
| 23 | + | func ValidatePortMapping(port string) error { | |||
| 24 | + | match, _ := regexp.MatchString("^[0-9]+:[0-9]+(\\/(tcp|udp))?$", port) | |||
| 25 | + | if !match { | |||
| 26 | + | return fmt.Errorf("invalid port mapping format") | |||
| 27 | + | } | |||
| 28 | + | return nil | |||
| 29 | + | } | |||
| 30 | + | ||||
| 31 | + | func ValidateTargetDomain(targetDomain string) error { | |||
| 32 | + | match, _ := regexp.MatchString("^(https?:\\/\\/)?([a-zA-Z0-9\\-_\\.]+\\.)+[a-zA-Z0-9\\-_\\.]+(:[0-9]+)?$", targetDomain) | |||
| 33 | + | if !match { | |||
| 34 | + | return fmt.Errorf("invalid target domain format") | |||
| 35 | + | } | |||
| 36 | + | return nil | |||
| 37 | + | } | |||
A internal/cli/model/_progressbar.go +109 -0
| @@ -0,0 +1,109 @@ | |||
| 1 | + | package model | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "io" | |
| 5 | + | "log" | |
| 6 | + | "time" | |
| 7 | + | ||
| 8 | + | "github.com/charmbracelet/bubbles/progress" | |
| 9 | + | tea "github.com/charmbracelet/bubbletea" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | const ( | |
| 13 | + | padding = 2 | |
| 14 | + | maxWidth = 80 | |
| 15 | + | ) | |
| 16 | + | ||
| 17 | + | type progressMsg float64 | |
| 18 | + | ||
| 19 | + | type ProgressBarModel struct { | |
| 20 | + | Width int | |
| 21 | + | progress progress.Model | |
| 22 | + | progressChan chan float64 | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | func NewProgressBarModel() ProgressBarModel { | |
| 26 | + | progressBar := progress.New(progress.WithScaledGradient("#FF7CCB", "#FDFF8C")) | |
| 27 | + | progressChan := make(chan float64) | |
| 28 | + | return ProgressBarModel{ | |
| 29 | + | progress: progressBar, | |
| 30 | + | progressChan: progressChan, | |
| 31 | + | } | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | func (m ProgressBarModel) Init() tea.Cmd { | |
| 35 | + | return tea.Batch(m.progress.Tick, m.tick) | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | func updateProgressCmd(progress float64) tea.Cmd { | |
| 39 | + | return func() tea.Msg { | |
| 40 | + | return progressMsg(progress) | |
| 41 | + | } | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | type tickMsg time.Time | |
| 45 | + | ||
| 46 | + | type ProgressReader struct { | |
| 47 | + | reader io.Reader | |
| 48 | + | totalSize int64 | |
| 49 | + | readBytes int64 | |
| 50 | + | progressFn func(readBytes int64) | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | // NewProgressReader creates a new ProgressReader. | |
| 54 | + | func NewProgressReader(reader io.Reader, totalSize int64, progressFn func(readBytes int64)) *ProgressReader { | |
| 55 | + | return &ProgressReader{ | |
| 56 | + | reader: reader, | |
| 57 | + | totalSize: totalSize, | |
| 58 | + | progressFn: progressFn, | |
| 59 | + | } | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | // Read implements the io.Reader interface. | |
| 63 | + | func (pr *ProgressReader) Read(p []byte) (int, error) { | |
| 64 | + | n, err := pr.reader.Read(p) | |
| 65 | + | pr.readBytes += int64(n) | |
| 66 | + | if pr.progressFn != nil { | |
| 67 | + | pr.progressFn(pr.readBytes) | |
| 68 | + | // Debug log to check progress | |
| 69 | + | log.Printf("ProgressReader: Read %d bytes, total %d bytes", n, pr.readBytes) | |
| 70 | + | } | |
| 71 | + | if err != nil { | |
| 72 | + | // Log error if not nil | |
| 73 | + | log.Printf("ProgressReader encountered an error: %v", err) | |
| 74 | + | } | |
| 75 | + | return n, err | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | // Close implements the io.Closer interface. | |
| 79 | + | func (pr *ProgressReader) Close() error { | |
| 80 | + | if closer, ok := pr.reader.(io.Closer); ok { | |
| 81 | + | return closer.Close() | |
| 82 | + | } | |
| 83 | + | // If the underlying reader does not implement io.Closer, return nil | |
| 84 | + | return nil | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | func runProgressBarTUI(progressChan <-chan float64) { | |
| 88 | + | m := NewProgressBarModel() | |
| 89 | + | ||
| 90 | + | // Update model to handle progress updates | |
| 91 | + | m.Update = func(msg tea.Msg) (tea.Model, tea.Cmd) { | |
| 92 | + | switch msg := msg.(type) { | |
| 93 | + | case float64: | |
| 94 | + | m.progress.SetValue(msg) // Update the progress bar's value | |
| 95 | + | return m, tea.Batch(m.progress.Animate(), time.After(time.Millisecond*100)) | |
| 96 | + | case tea.KeyMsg: | |
| 97 | + | if msg.String() == "q" { | |
| 98 | + | return m, tea.Quit | |
| 99 | + | } | |
| 100 | + | } | |
| 101 | + | return m, nil | |
| 102 | + | } | |
| 103 | + | ||
| 104 | + | // Start the TUI | |
| 105 | + | p := tea.NewProgram(m) | |
| 106 | + | if err := p.Start(); err != nil { | |
| 107 | + | log.Fatal(err) | |
| 108 | + | } | |
| 109 | + | } | |
| @@ -0,0 +1,109 @@ | |||||
| 1 | + | package model | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "io" | |||
| 5 | + | "log" | |||
| 6 | + | "time" | |||
| 7 | + | ||||
| 8 | + | "github.com/charmbracelet/bubbles/progress" | |||
| 9 | + | tea "github.com/charmbracelet/bubbletea" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | const ( | |||
| 13 | + | padding = 2 | |||
| 14 | + | maxWidth = 80 | |||
| 15 | + | ) | |||
| 16 | + | ||||
| 17 | + | type progressMsg float64 | |||
| 18 | + | ||||
| 19 | + | type ProgressBarModel struct { | |||
| 20 | + | Width int | |||
| 21 | + | progress progress.Model | |||
| 22 | + | progressChan chan float64 | |||
| 23 | + | } | |||
| 24 | + | ||||
| 25 | + | func NewProgressBarModel() ProgressBarModel { | |||
| 26 | + | progressBar := progress.New(progress.WithScaledGradient("#FF7CCB", "#FDFF8C")) | |||
| 27 | + | progressChan := make(chan float64) | |||
| 28 | + | return ProgressBarModel{ | |||
| 29 | + | progress: progressBar, | |||
| 30 | + | progressChan: progressChan, | |||
| 31 | + | } | |||
| 32 | + | } | |||
| 33 | + | ||||
| 34 | + | func (m ProgressBarModel) Init() tea.Cmd { | |||
| 35 | + | return tea.Batch(m.progress.Tick, m.tick) | |||
| 36 | + | } | |||
| 37 | + | ||||
| 38 | + | func updateProgressCmd(progress float64) tea.Cmd { | |||
| 39 | + | return func() tea.Msg { | |||
| 40 | + | return progressMsg(progress) | |||
| 41 | + | } | |||
| 42 | + | } | |||
| 43 | + | ||||
| 44 | + | type tickMsg time.Time | |||
| 45 | + | ||||
| 46 | + | type ProgressReader struct { | |||
| 47 | + | reader io.Reader | |||
| 48 | + | totalSize int64 | |||
| 49 | + | readBytes int64 | |||
| 50 | + | progressFn func(readBytes int64) | |||
| 51 | + | } | |||
| 52 | + | ||||
| 53 | + | // NewProgressReader creates a new ProgressReader. | |||
| 54 | + | func NewProgressReader(reader io.Reader, totalSize int64, progressFn func(readBytes int64)) *ProgressReader { | |||
| 55 | + | return &ProgressReader{ | |||
| 56 | + | reader: reader, | |||
| 57 | + | totalSize: totalSize, | |||
| 58 | + | progressFn: progressFn, | |||
| 59 | + | } | |||
| 60 | + | } | |||
| 61 | + | ||||
| 62 | + | // Read implements the io.Reader interface. | |||
| 63 | + | func (pr *ProgressReader) Read(p []byte) (int, error) { | |||
| 64 | + | n, err := pr.reader.Read(p) | |||
| 65 | + | pr.readBytes += int64(n) | |||
| 66 | + | if pr.progressFn != nil { | |||
| 67 | + | pr.progressFn(pr.readBytes) | |||
| 68 | + | // Debug log to check progress | |||
| 69 | + | log.Printf("ProgressReader: Read %d bytes, total %d bytes", n, pr.readBytes) | |||
| 70 | + | } | |||
| 71 | + | if err != nil { | |||
| 72 | + | // Log error if not nil | |||
| 73 | + | log.Printf("ProgressReader encountered an error: %v", err) | |||
| 74 | + | } | |||
| 75 | + | return n, err | |||
| 76 | + | } | |||
| 77 | + | ||||
| 78 | + | // Close implements the io.Closer interface. | |||
| 79 | + | func (pr *ProgressReader) Close() error { | |||
| 80 | + | if closer, ok := pr.reader.(io.Closer); ok { | |||
| 81 | + | return closer.Close() | |||
| 82 | + | } | |||
| 83 | + | // If the underlying reader does not implement io.Closer, return nil | |||
| 84 | + | return nil | |||
| 85 | + | } | |||
| 86 | + | ||||
| 87 | + | func runProgressBarTUI(progressChan <-chan float64) { | |||
| 88 | + | m := NewProgressBarModel() | |||
| 89 | + | ||||
| 90 | + | // Update model to handle progress updates | |||
| 91 | + | m.Update = func(msg tea.Msg) (tea.Model, tea.Cmd) { | |||
| 92 | + | switch msg := msg.(type) { | |||
| 93 | + | case float64: | |||
| 94 | + | m.progress.SetValue(msg) // Update the progress bar's value | |||
| 95 | + | return m, tea.Batch(m.progress.Animate(), time.After(time.Millisecond*100)) | |||
| 96 | + | case tea.KeyMsg: | |||
| 97 | + | if msg.String() == "q" { | |||
| 98 | + | return m, tea.Quit | |||
| 99 | + | } | |||
| 100 | + | } | |||
| 101 | + | return m, nil | |||
| 102 | + | } | |||
| 103 | + | ||||
| 104 | + | // Start the TUI | |||
| 105 | + | p := tea.NewProgram(m) | |||
| 106 | + | if err := p.Start(); err != nil { | |||
| 107 | + | log.Fatal(err) | |||
| 108 | + | } | |||
| 109 | + | } | |||
A internal/cli/model/spinner.go +158 -0
| @@ -0,0 +1,158 @@ | |||
| 1 | + | package model | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "crypto/tls" | |
| 5 | + | "fmt" | |
| 6 | + | "net/http" | |
| 7 | + | "time" | |
| 8 | + | ||
| 9 | + | "github.com/charmbracelet/bubbles/spinner" | |
| 10 | + | tea "github.com/charmbracelet/bubbletea" | |
| 11 | + | "github.com/charmbracelet/lipgloss" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | type SpinnerModel struct { | |
| 15 | + | spinner spinner.Model | |
| 16 | + | quitting bool | |
| 17 | + | err error | |
| 18 | + | imageName string | |
| 19 | + | targetDomain string | |
| 20 | + | port string | |
| 21 | + | client *http.Client | |
| 22 | + | retryCount int | |
| 23 | + | maxRetries int | |
| 24 | + | DeploymentDone bool | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | type urlCheckedMsg struct { | |
| 28 | + | success bool | |
| 29 | + | err error | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | func NewSpinnerModel() SpinnerModel { | |
| 33 | + | s := spinner.New() | |
| 34 | + | s.Spinner = spinner.Dot | |
| 35 | + | spinnerStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("205")) | |
| 36 | + | s.Style = spinnerStyle | |
| 37 | + | ||
| 38 | + | return SpinnerModel{ | |
| 39 | + | spinner: s, | |
| 40 | + | maxRetries: 20, | |
| 41 | + | } | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | func (m SpinnerModel) Init() tea.Cmd { | |
| 45 | + | return tea.Batch(m.spinner.Tick, checkURL(m)) | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | func (m SpinnerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { | |
| 49 | + | switch msg := msg.(type) { | |
| 50 | + | case tea.KeyMsg: | |
| 51 | + | switch msg.String() { | |
| 52 | + | case "q", "esc", "ctrl+c": | |
| 53 | + | m.quitting = true | |
| 54 | + | return m, tea.Quit | |
| 55 | + | default: | |
| 56 | + | return m, nil | |
| 57 | + | } | |
| 58 | + | case urlCheckedMsg: | |
| 59 | + | if msg.success { | |
| 60 | + | m.DeploymentDone = true | |
| 61 | + | return m, tea.Quit | |
| 62 | + | } else { | |
| 63 | + | m.err = msg.err | |
| 64 | + | return m, nil | |
| 65 | + | } | |
| 66 | + | case spinner.TickMsg: | |
| 67 | + | var cmd tea.Cmd | |
| 68 | + | m.spinner, cmd = m.spinner.Update(msg) | |
| 69 | + | return m, cmd | |
| 70 | + | ||
| 71 | + | default: | |
| 72 | + | return m, nil | |
| 73 | + | } | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | func (m SpinnerModel) View() string { | |
| 77 | + | if m.quitting { | |
| 78 | + | if m.err != nil { | |
| 79 | + | return fmt.Sprintf("Error: %v\n", m.err) | |
| 80 | + | } | |
| 81 | + | return "Deployment cancelled.\n" | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | // Use the textStyle to format the entire string | |
| 85 | + | return fmt.Sprintf("\n\n %s Wait while Traefik is setting up the domain and certificates...\n\n", m.spinner.View()) | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | func checkURL(m SpinnerModel) tea.Cmd { | |
| 89 | + | return func() tea.Msg { | |
| 90 | + | ch := make(chan urlCheckedMsg) | |
| 91 | + | ||
| 92 | + | go func() { | |
| 93 | + | defer close(ch) // Ensure the channel is closed when the goroutine exits | |
| 94 | + | // Initialize the HTTP client if not already done | |
| 95 | + | if m.client == nil { | |
| 96 | + | m.client = &http.Client{ | |
| 97 | + | Timeout: time.Second * 10, // Set a timeout for the request | |
| 98 | + | Transport: &http.Transport{ | |
| 99 | + | TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, // Skip TLS verification, not recommended for production | |
| 100 | + | }, | |
| 101 | + | } | |
| 102 | + | } | |
| 103 | + | ||
| 104 | + | // Try to check the URL with retries | |
| 105 | + | for i := 0; i < m.maxRetries; i++ { | |
| 106 | + | resp, err := m.client.Get(m.targetDomain) // Send the request | |
| 107 | + | if err != nil { | |
| 108 | + | return | |
| 109 | + | } else { | |
| 110 | + | // Print the response status code | |
| 111 | + | resp.Body.Close() // Close the body to avoid resource leaks | |
| 112 | + | if resp.StatusCode == http.StatusOK { | |
| 113 | + | ch <- urlCheckedMsg{success: true, err: nil} | |
| 114 | + | return | |
| 115 | + | } | |
| 116 | + | } | |
| 117 | + | ||
| 118 | + | m.retryCount++ | |
| 119 | + | time.Sleep(time.Second * 1) // Wait for some time before retrying | |
| 120 | + | } | |
| 121 | + | ||
| 122 | + | // If the code reaches here (maxRetries exceeded) then send a message to the channel | |
| 123 | + | ch <- urlCheckedMsg{ | |
| 124 | + | success: false, | |
| 125 | + | err: fmt.Errorf("the URL %s could not be reached after %d attempts"+m.targetDomain, m.retryCount), | |
| 126 | + | } | |
| 127 | + | }() | |
| 128 | + | ||
| 129 | + | return waitForURLCheckedMsg(ch) | |
| 130 | + | } | |
| 131 | + | } | |
| 132 | + | ||
| 133 | + | func waitForURLCheckedMsg(ch chan urlCheckedMsg) tea.Msg { | |
| 134 | + | msg := <-ch // Wait for a message from the channel | |
| 135 | + | return msg // Return the message to the model's update function | |
| 136 | + | } | |
| 137 | + | ||
| 138 | + | // Extracted function to run the TUI program and handle the final model | |
| 139 | + | func RunDeploymentTUI(client *http.Client, imageName, targetDomain, port string) (SpinnerModel, error) { | |
| 140 | + | m := NewSpinnerModel() | |
| 141 | + | m.imageName = imageName | |
| 142 | + | m.targetDomain = targetDomain | |
| 143 | + | m.port = port | |
| 144 | + | m.client = client | |
| 145 | + | ||
| 146 | + | p := tea.NewProgram(m) | |
| 147 | + | modelInterface, err := p.Run() | |
| 148 | + | if err != nil { | |
| 149 | + | return SpinnerModel{}, fmt.Errorf("error running TUI program: %w", err) | |
| 150 | + | } | |
| 151 | + | ||
| 152 | + | finalModel, ok := modelInterface.(SpinnerModel) | |
| 153 | + | if !ok { | |
| 154 | + | return SpinnerModel{}, fmt.Errorf("could not type assert tea model to concrete type") | |
| 155 | + | } | |
| 156 | + | ||
| 157 | + | return finalModel, nil | |
| 158 | + | } | |
| @@ -0,0 +1,158 @@ | |||||
| 1 | + | package model | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "crypto/tls" | |||
| 5 | + | "fmt" | |||
| 6 | + | "net/http" | |||
| 7 | + | "time" | |||
| 8 | + | ||||
| 9 | + | "github.com/charmbracelet/bubbles/spinner" | |||
| 10 | + | tea "github.com/charmbracelet/bubbletea" | |||
| 11 | + | "github.com/charmbracelet/lipgloss" | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | type SpinnerModel struct { | |||
| 15 | + | spinner spinner.Model | |||
| 16 | + | quitting bool | |||
| 17 | + | err error | |||
| 18 | + | imageName string | |||
| 19 | + | targetDomain string | |||
| 20 | + | port string | |||
| 21 | + | client *http.Client | |||
| 22 | + | retryCount int | |||
| 23 | + | maxRetries int | |||
| 24 | + | DeploymentDone bool | |||
| 25 | + | } | |||
| 26 | + | ||||
| 27 | + | type urlCheckedMsg struct { | |||
| 28 | + | success bool | |||
| 29 | + | err error | |||
| 30 | + | } | |||
| 31 | + | ||||
| 32 | + | func NewSpinnerModel() SpinnerModel { | |||
| 33 | + | s := spinner.New() | |||
| 34 | + | s.Spinner = spinner.Dot | |||
| 35 | + | spinnerStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("205")) | |||
| 36 | + | s.Style = spinnerStyle | |||
| 37 | + | ||||
| 38 | + | return SpinnerModel{ | |||
| 39 | + | spinner: s, | |||
| 40 | + | maxRetries: 20, | |||
| 41 | + | } | |||
| 42 | + | } | |||
| 43 | + | ||||
| 44 | + | func (m SpinnerModel) Init() tea.Cmd { | |||
| 45 | + | return tea.Batch(m.spinner.Tick, checkURL(m)) | |||
| 46 | + | } | |||
| 47 | + | ||||
| 48 | + | func (m SpinnerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { | |||
| 49 | + | switch msg := msg.(type) { | |||
| 50 | + | case tea.KeyMsg: | |||
| 51 | + | switch msg.String() { | |||
| 52 | + | case "q", "esc", "ctrl+c": | |||
| 53 | + | m.quitting = true | |||
| 54 | + | return m, tea.Quit | |||
| 55 | + | default: | |||
| 56 | + | return m, nil | |||
| 57 | + | } | |||
| 58 | + | case urlCheckedMsg: | |||
| 59 | + | if msg.success { | |||
| 60 | + | m.DeploymentDone = true | |||
| 61 | + | return m, tea.Quit | |||
| 62 | + | } else { | |||
| 63 | + | m.err = msg.err | |||
| 64 | + | return m, nil | |||
| 65 | + | } | |||
| 66 | + | case spinner.TickMsg: | |||
| 67 | + | var cmd tea.Cmd | |||
| 68 | + | m.spinner, cmd = m.spinner.Update(msg) | |||
| 69 | + | return m, cmd | |||
| 70 | + | ||||
| 71 | + | default: | |||
| 72 | + | return m, nil | |||
| 73 | + | } | |||
| 74 | + | } | |||
| 75 | + | ||||
| 76 | + | func (m SpinnerModel) View() string { | |||
| 77 | + | if m.quitting { | |||
| 78 | + | if m.err != nil { | |||
| 79 | + | return fmt.Sprintf("Error: %v\n", m.err) | |||
| 80 | + | } | |||
| 81 | + | return "Deployment cancelled.\n" | |||
| 82 | + | } | |||
| 83 | + | ||||
| 84 | + | // Use the textStyle to format the entire string | |||
| 85 | + | return fmt.Sprintf("\n\n %s Wait while Traefik is setting up the domain and certificates...\n\n", m.spinner.View()) | |||
| 86 | + | } | |||
| 87 | + | ||||
| 88 | + | func checkURL(m SpinnerModel) tea.Cmd { | |||
| 89 | + | return func() tea.Msg { | |||
| 90 | + | ch := make(chan urlCheckedMsg) | |||
| 91 | + | ||||
| 92 | + | go func() { | |||
| 93 | + | defer close(ch) // Ensure the channel is closed when the goroutine exits | |||
| 94 | + | // Initialize the HTTP client if not already done | |||
| 95 | + | if m.client == nil { | |||
| 96 | + | m.client = &http.Client{ | |||
| 97 | + | Timeout: time.Second * 10, // Set a timeout for the request | |||
| 98 | + | Transport: &http.Transport{ | |||
| 99 | + | TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, // Skip TLS verification, not recommended for production | |||
| 100 | + | }, | |||
| 101 | + | } | |||
| 102 | + | } | |||
| 103 | + | ||||
| 104 | + | // Try to check the URL with retries | |||
| 105 | + | for i := 0; i < m.maxRetries; i++ { | |||
| 106 | + | resp, err := m.client.Get(m.targetDomain) // Send the request | |||
| 107 | + | if err != nil { | |||
| 108 | + | return | |||
| 109 | + | } else { | |||
| 110 | + | // Print the response status code | |||
| 111 | + | resp.Body.Close() // Close the body to avoid resource leaks | |||
| 112 | + | if resp.StatusCode == http.StatusOK { | |||
| 113 | + | ch <- urlCheckedMsg{success: true, err: nil} | |||
| 114 | + | return | |||
| 115 | + | } | |||
| 116 | + | } | |||
| 117 | + | ||||
| 118 | + | m.retryCount++ | |||
| 119 | + | time.Sleep(time.Second * 1) // Wait for some time before retrying | |||
| 120 | + | } | |||
| 121 | + | ||||
| 122 | + | // If the code reaches here (maxRetries exceeded) then send a message to the channel | |||
| 123 | + | ch <- urlCheckedMsg{ | |||
| 124 | + | success: false, | |||
| 125 | + | err: fmt.Errorf("the URL %s could not be reached after %d attempts"+m.targetDomain, m.retryCount), | |||
| 126 | + | } | |||
| 127 | + | }() | |||
| 128 | + | ||||
| 129 | + | return waitForURLCheckedMsg(ch) | |||
| 130 | + | } | |||
| 131 | + | } | |||
| 132 | + | ||||
| 133 | + | func waitForURLCheckedMsg(ch chan urlCheckedMsg) tea.Msg { | |||
| 134 | + | msg := <-ch // Wait for a message from the channel | |||
| 135 | + | return msg // Return the message to the model's update function | |||
| 136 | + | } | |||
| 137 | + | ||||
| 138 | + | // Extracted function to run the TUI program and handle the final model | |||
| 139 | + | func RunDeploymentTUI(client *http.Client, imageName, targetDomain, port string) (SpinnerModel, error) { | |||
| 140 | + | m := NewSpinnerModel() | |||
| 141 | + | m.imageName = imageName | |||
| 142 | + | m.targetDomain = targetDomain | |||
| 143 | + | m.port = port | |||
| 144 | + | m.client = client | |||
| 145 | + | ||||
| 146 | + | p := tea.NewProgram(m) | |||
| 147 | + | modelInterface, err := p.Run() | |||
| 148 | + | if err != nil { | |||
| 149 | + | return SpinnerModel{}, fmt.Errorf("error running TUI program: %w", err) | |||
| 150 | + | } | |||
| 151 | + | ||||
| 152 | + | finalModel, ok := modelInterface.(SpinnerModel) | |||
| 153 | + | if !ok { | |||
| 154 | + | return SpinnerModel{}, fmt.Errorf("could not type assert tea model to concrete type") | |||
| 155 | + | } | |||
| 156 | + | ||||
| 157 | + | return finalModel, nil | |||
| 158 | + | } | |||
M internal/common/version.go +6 -0
| @@ -70,6 +70,11 @@ func checkAndUpdateVersion(c *Config) (string, error) { | |||
| 70 | 70 | remoteVersion = remoteVersion[:len(remoteVersion)-len(getArch())-1] | |
| 71 | 71 | message := CheckForNewVersion(localVersion.Version, remoteVersion) | |
| 72 | 72 | ||
| 73 | + | // if local version is empty that means dev mode is on so we don't need to check for updates | |
| 74 | + | if localVersion.Version == "" { | |
| 75 | + | return "", nil | |
| 76 | + | } | |
| 77 | + | ||
| 73 | 78 | if message != "" { | |
| 74 | 79 | // Create a new color attribute | |
| 75 | 80 | green := color.New(color.FgGreen).SprintFunc() | |
| @@ -77,6 +82,7 @@ func checkAndUpdateVersion(c *Config) (string, error) { | |||
| 77 | 82 | coloredMessage := green(fmt.Sprintf("New version %s is available", remoteVersion)) | |
| 78 | 83 | return coloredMessage, nil | |
| 79 | 84 | } | |
| 85 | + | ||
| 80 | 86 | return "", nil | |
| 81 | 87 | ||
| 82 | 88 | } | |
| @@ -70,6 +70,11 @@ func checkAndUpdateVersion(c *Config) (string, error) { | |||||
| 70 | remoteVersion = remoteVersion[:len(remoteVersion)-len(getArch())-1] | 70 | remoteVersion = remoteVersion[:len(remoteVersion)-len(getArch())-1] | ||
| 71 | message := CheckForNewVersion(localVersion.Version, remoteVersion) | 71 | message := CheckForNewVersion(localVersion.Version, remoteVersion) | ||
| 72 | 72 | ||||
| 73 | + | // if local version is empty that means dev mode is on so we don't need to check for updates | |||
| 74 | + | if localVersion.Version == "" { | |||
| 75 | + | return "", nil | |||
| 76 | + | } | |||
| 77 | + | ||||
| 73 | if message != "" { | 78 | if message != "" { | ||
| 74 | // Create a new color attribute | 79 | // Create a new color attribute | ||
| 75 | green := color.New(color.FgGreen).SprintFunc() | 80 | green := color.New(color.FgGreen).SprintFunc() | ||
| @@ -77,6 +82,7 @@ func checkAndUpdateVersion(c *Config) (string, error) { | |||||
| 77 | coloredMessage := green(fmt.Sprintf("New version %s is available", remoteVersion)) | 82 | coloredMessage := green(fmt.Sprintf("New version %s is available", remoteVersion)) | ||
| 78 | return coloredMessage, nil | 83 | return coloredMessage, nil | ||
| 79 | } | 84 | } | ||
| 85 | + | ||||
| 80 | return "", nil | 86 | return "", nil | ||
| 81 | 87 | ||||
| 82 | } | 88 | } | ||
M pkg/docker/images.go +24 -0
| @@ -154,3 +154,27 @@ func WhoAmI() (string, error) { | |||
| 154 | 154 | ||
| 155 | 155 | return imageInfo.ID, nil | |
| 156 | 156 | } | |
| 157 | + | ||
| 158 | + | func GetImageSizeFromReader(imageID string) (int64, error) { | |
| 159 | + | // Export the image using the Docker client | |
| 160 | + | imageReader, err := dockerCli.ImageSave(context.Background(), []string{imageID}) | |
| 161 | + | if err != nil { | |
| 162 | + | return 0, fmt.Errorf("failed to export image: %w", err) | |
| 163 | + | } | |
| 164 | + | ||
| 165 | + | // Read the entire stream to get the actual size | |
| 166 | + | actualSize := int64(0) | |
| 167 | + | buf := make([]byte, 1024) // A buffer for reading the stream | |
| 168 | + | for { | |
| 169 | + | n, err := imageReader.Read(buf) | |
| 170 | + | actualSize += int64(n) | |
| 171 | + | if err != nil { | |
| 172 | + | if err == io.EOF { | |
| 173 | + | break // End of file is reached | |
| 174 | + | } | |
| 175 | + | return 0, fmt.Errorf("failed to read image: %w", err) | |
| 176 | + | } | |
| 177 | + | } | |
| 178 | + | ||
| 179 | + | return actualSize, nil | |
| 180 | + | } | |
| @@ -154,3 +154,27 @@ func WhoAmI() (string, error) { | |||||
| 154 | 154 | ||||
| 155 | return imageInfo.ID, nil | 155 | return imageInfo.ID, nil | ||
| 156 | } | 156 | } | ||
| 157 | + | ||||
| 158 | + | func GetImageSizeFromReader(imageID string) (int64, error) { | |||
| 159 | + | // Export the image using the Docker client | |||
| 160 | + | imageReader, err := dockerCli.ImageSave(context.Background(), []string{imageID}) | |||
| 161 | + | if err != nil { | |||
| 162 | + | return 0, fmt.Errorf("failed to export image: %w", err) | |||
| 163 | + | } | |||
| 164 | + | ||||
| 165 | + | // Read the entire stream to get the actual size | |||
| 166 | + | actualSize := int64(0) | |||
| 167 | + | buf := make([]byte, 1024) // A buffer for reading the stream | |||
| 168 | + | for { | |||
| 169 | + | n, err := imageReader.Read(buf) | |||
| 170 | + | actualSize += int64(n) | |||
| 171 | + | if err != nil { | |||
| 172 | + | if err == io.EOF { | |||
| 173 | + | break // End of file is reached | |||
| 174 | + | } | |||
| 175 | + | return 0, fmt.Errorf("failed to read image: %w", err) | |||
| 176 | + | } | |||
| 177 | + | } | |||
| 178 | + | ||||
| 179 | + | return actualSize, nil | |||
| 180 | + | } | |||