detached 2dd0eb8

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
2dd0eb80f840c50bcb0fdfde2eab8a0846869af5
author
bnema · · 2023-11-07 21:32 UTC
parent
64ae531
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
55 require (
66 github.com/AlecAivazis/survey/v2 v2.3.7
77 github.com/PuerkitoBio/goquery v1.8.1
8+ github.com/charmbracelet/bubbles v0.16.1
9+ github.com/charmbracelet/lipgloss v0.7.1
810 github.com/cheggaaa/pb v1.0.29
911 github.com/docker/docker v24.0.6+incompatible
1012 github.com/docker/go-connections v0.4.0
@@ -21,16 +23,27 @@ require (
2123 )
2224
2325 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
2430 github.com/creack/pty v1.1.18 // indirect
2531 github.com/dustin/go-humanize v1.0.1 // indirect
2632 github.com/inconshreveable/mousetrap v1.1.0 // indirect
2733 github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
2834 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
2937 github.com/mattn/go-runewidth v0.0.15 // indirect
3038 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
3143 github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
3244 github.com/rivo/uniseg v0.4.4 // indirect
3345 github.com/spf13/pflag v1.0.5 // indirect
46+ golang.org/x/sync v0.4.0 // indirect
3447 golang.org/x/term v0.13.0 // indirect
3548 lukechampine.com/uint128 v1.3.0 // indirect
3649 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.76 github.com/AlecAivazis/survey/v2 v2.3.7
7 github.com/PuerkitoBio/goquery v1.8.17 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.2910 github.com/cheggaaa/pb v1.0.29
9 github.com/docker/docker v24.0.6+incompatible11 github.com/docker/docker v24.0.6+incompatible
10 github.com/docker/go-connections v0.4.012 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 // indirect30 github.com/creack/pty v1.1.18 // indirect
25 github.com/dustin/go-humanize v1.0.1 // indirect31 github.com/dustin/go-humanize v1.0.1 // indirect
26 github.com/inconshreveable/mousetrap v1.1.0 // indirect32 github.com/inconshreveable/mousetrap v1.1.0 // indirect
27 github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect33 github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
28 github.com/kr/text v0.2.0 // indirect34 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 // indirect37 github.com/mattn/go-runewidth v0.0.15 // indirect
30 github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d // indirect38 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 // indirect43 github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
32 github.com/rivo/uniseg v0.4.4 // indirect44 github.com/rivo/uniseg v0.4.4 // indirect
33 github.com/spf13/pflag v1.0.5 // indirect45 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 // indirect47 golang.org/x/term v0.13.0 // indirect
35 lukechampine.com/uint128 v1.3.0 // indirect48 lukechampine.com/uint128 v1.3.0 // indirect
36 modernc.org/cc/v3 v3.41.0 // indirect49 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 {
1616 // handler.FieldCheck(a)
1717 return &cobra.Command{
1818 Use: "ping",
19- Short: "Send a ping request to the backend",
19+ Short: "Send a ping request to your remote Gordon instance",
2020 PreRun: func(cmd *cobra.Command, args []string) {
2121 if err := handler.FieldCheck(a); err != nil {
2222 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 (
1212
1313 // NewRootCommand creates a new root command
1414 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{
1816 Run: func(cmd *cobra.Command, args []string) {
19- if !cmd.HasSubCommands() || cmd.CalledAs() == "" {
20- color.Green("Gordon %s", a.Config.GetVersion())
2117
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)
2721
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")
3024 }
25+
26+ fmt.Println()
27+ fmt.Println("Use \"gordon --help\" for more information about a command.")
3128 },
3229 }
33-
34- return cmd
3530 }
3631
37-func getBackendVersion(a *cli.App) (string, error) {
32+func getBackendVersion(a *cli.App) string {
3833 pingResp, err := handler.PerformPingRequest(a)
3934 if err != nil {
40- return "", err
35+ return fmt.Sprintf("Error: %s", err)
4136 }
4237
43- return pingResp.Version, nil
38+ return pingResp.Version
4439 }
@@ -12,33 +12,28 @@ import (
12 12
13 // NewRootCommand creates a new root command13 // 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 blue22+ 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 "", err35+ return fmt.Sprintf("Error: %s", err)
41 }36 }
42 37
43- return pingResp.Version, nil38+ return pingResp.Version
44 }39 }
M internal/cli/cmd/serve.go +1 -1
@@ -22,7 +22,7 @@ func NewServeCommand(a *server.App) *cobra.Command {
2222 // if no flags -p or --port are specified, the default port is used
2323 serveCmd := &cobra.Command{
2424 Use: "serve",
25- Short: "Start the Gordon server",
25+ Short: "Start a new Gordon server instance",
2626 Args: cobra.NoArgs,
2727 Run: func(cmd *cobra.Command, args []string) {
2828 // 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 used22 // 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 default28 // 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) {
7070 remoteVersion = remoteVersion[:len(remoteVersion)-len(getArch())-1]
7171 message := CheckForNewVersion(localVersion.Version, remoteVersion)
7272
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+
7378 if message != "" {
7479 // Create a new color attribute
7580 green := color.New(color.FgGreen).SprintFunc()
@@ -77,6 +82,7 @@ func checkAndUpdateVersion(c *Config) (string, error) {
7782 coloredMessage := green(fmt.Sprintf("New version %s is available", remoteVersion))
7883 return coloredMessage, nil
7984 }
85+
8086 return "", nil
8187
8288 }
@@ -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 attribute79 // 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, nil83 return coloredMessage, nil
79 }84 }
85+
80 return "", nil86 return "", nil
81 87
82 }88 }
M pkg/docker/images.go +24 -0
@@ -154,3 +154,27 @@ func WhoAmI() (string, error) {
154154
155155 return imageInfo.ID, nil
156156 }
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, nil155 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+}

Keyboard shortcuts

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

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

Branches 7

Tags 106

↑↓ moveenter openesc close