diff --git a/cmd/configure.go b/cmd/configure.go index 66c865a..fa8728a 100644 --- a/cmd/configure.go +++ b/cmd/configure.go @@ -5,7 +5,6 @@ package cmd import ( "github.com/goodylabs/tug/internal/app" - "github.com/goodylabs/tug/pkg/dependecies" "github.com/spf13/cobra" ) @@ -14,10 +13,8 @@ var configureCmd = &cobra.Command{ Use: "configure", Short: "Configure tug", Run: func(cmd *cobra.Command, args []string) { - container := dependecies.InitDependencyContainer() - err := container.Invoke(func(configureUseCase *app.ConfigureUseCase) error { - return configureUseCase.Execute() - }) + configureUseCase := app.NewConfigureUseCase() + err := configureUseCase.Execute() if err != nil { cmd.PrintErrf("%v\n", err) } diff --git a/cmd/constants.go b/cmd/constants.go new file mode 100644 index 0000000..4817cde --- /dev/null +++ b/cmd/constants.go @@ -0,0 +1,7 @@ +package cmd + +const ( + checkHint = "Check are you able to establish SSH connection hosts in project" + customHostHint = "Manually input host - won't use project config" + customUserHint = "Manually input user (only to use with '--host' flag)" +) diff --git a/cmd/docker.go b/cmd/docker.go index 1c57c47..fcb4847 100644 --- a/cmd/docker.go +++ b/cmd/docker.go @@ -2,7 +2,8 @@ package cmd import ( "github.com/goodylabs/tug/internal/app" - "github.com/goodylabs/tug/pkg/dependecies" + "github.com/goodylabs/tug/internal/modules/action" + "github.com/goodylabs/tug/internal/modules/loadproject" "github.com/spf13/cobra" ) @@ -10,21 +11,25 @@ var dockerCmd = &cobra.Command{ Use: "docker", Short: "Abstraction layer for docker operations related to project repo", Run: func(cmd *cobra.Command, args []string) { - check, err := cmd.Flags().GetBool("check") + checkConnectionUseCase := app.NewCheckConnectionUseCase() + useModuleUseCase := app.NewUseModuleV2UseCase() - container := dependecies.InitDependencyContainer( - dependecies.WithDockerHandler, - ) - if check { - err = container.Invoke(func(checkConnectionUseCase *app.CheckConnectionUseCase) error { - return checkConnectionUseCase.Execute() - }) - } else { - err = container.Invoke(func(useModuleUseCase *app.UseModuleUseCase) error { - return useModuleUseCase.Execute() - }) + if check, _ := cmd.Flags().GetBool("check"); check == true { + if err := checkConnectionUseCase.Execute(loadproject.DockerStrategy); err != nil { + cmd.PrintErrf("%v\n", err) + } + return } - if err != nil { + + if host, _ := cmd.Flags().GetString("host"); host != "" { + user, _ := cmd.Flags().GetString("user") + if err := useModuleUseCase.ExecuteDirect(user, host, action.Docker); err != nil { + cmd.PrintErrf("%v\n", err) + } + return + } + + if err := useModuleUseCase.Execute(loadproject.DockerStrategy, action.Docker); err != nil { cmd.PrintErrf("%v\n", err) } }, @@ -32,5 +37,7 @@ var dockerCmd = &cobra.Command{ func init() { rootCmd.AddCommand(dockerCmd) - dockerCmd.Flags().Bool("check", false, "Check SSH connections before running Docker commands") + dockerCmd.Flags().Bool("check", false, checkHint) + dockerCmd.Flags().String("host", "", customHostHint) + dockerCmd.Flags().String("user", "root", customUserHint) } diff --git a/cmd/pm2.go b/cmd/pm2.go index d28ce39..60a50c7 100644 --- a/cmd/pm2.go +++ b/cmd/pm2.go @@ -2,7 +2,8 @@ package cmd import ( "github.com/goodylabs/tug/internal/app" - "github.com/goodylabs/tug/pkg/dependecies" + "github.com/goodylabs/tug/internal/modules/action" + "github.com/goodylabs/tug/internal/modules/loadproject" "github.com/spf13/cobra" ) @@ -10,27 +11,34 @@ var pm2Cmd = &cobra.Command{ Use: "pm2", Short: "Abstraction layer for pm2 operations related to project repo", Run: func(cmd *cobra.Command, args []string) { - check, err := cmd.Flags().GetBool("check") + checkConnectionUseCase := app.NewCheckConnectionUseCase() + useModuleUseCase := app.NewUseModuleV2UseCase() - container := dependecies.InitDependencyContainer( - dependecies.WithPm2Handler, - ) - if check { - err = container.Invoke(func(checkConnectionUseCase *app.CheckConnectionUseCase) error { - return checkConnectionUseCase.Execute() - }) - } else { - err = container.Invoke(func(useModuleUseCase *app.UseModuleUseCase) error { - return useModuleUseCase.Execute() - }) + if check, _ := cmd.Flags().GetBool("check"); check == true { + if err := checkConnectionUseCase.Execute(loadproject.Pm2Strategy); err != nil { + cmd.PrintErrf("%v\n", err) + } + return } - if err != nil { + + if host, _ := cmd.Flags().GetString("host"); host != "" { + user, _ := cmd.Flags().GetString("user") + if err := useModuleUseCase.ExecuteDirect(user, host, action.Pm2); err != nil { + cmd.PrintErrf("%v\n", err) + } + return + } + + if err := useModuleUseCase.Execute(loadproject.Pm2Strategy, action.Pm2); err != nil { cmd.PrintErrf("%v\n", err) } + }, } func init() { rootCmd.AddCommand(pm2Cmd) - pm2Cmd.Flags().Bool("check", false, "Check SSH connections before running Docker commands") + pm2Cmd.Flags().Bool("check", false, checkHint) + pm2Cmd.Flags().String("host", "", customHostHint) + pm2Cmd.Flags().String("user", "root", customUserHint) } diff --git a/cmd/pystrano.go b/cmd/pystrano.go index dafd102..50dc062 100644 --- a/cmd/pystrano.go +++ b/cmd/pystrano.go @@ -5,7 +5,8 @@ package cmd import ( "github.com/goodylabs/tug/internal/app" - "github.com/goodylabs/tug/pkg/dependecies" + "github.com/goodylabs/tug/internal/modules/action" + "github.com/goodylabs/tug/internal/modules/loadproject" "github.com/spf13/cobra" ) @@ -14,20 +15,18 @@ var pystranoCmd = &cobra.Command{ Use: "pystrano", Short: "Abstraction layer for pm2 operations related to project repo", Run: func(cmd *cobra.Command, args []string) { - check, err := cmd.Flags().GetBool("check") - container := dependecies.InitDependencyContainer( - dependecies.WithPystranoHandler, - ) - if check { - err = container.Invoke(func(checkConnectionUseCase *app.CheckConnectionUseCase) error { - return checkConnectionUseCase.Execute() - }) - } else { - err = container.Invoke(func(useModuleUseCase *app.UseModuleUseCase) error { - return useModuleUseCase.Execute() - }) + if check, _ := cmd.Flags().GetBool("check"); check == true { + checkConnectionUseCase := app.NewCheckConnectionUseCase() + if err := checkConnectionUseCase.Execute(loadproject.PystranoStrategy); err != nil { + cmd.PrintErrf("%v\n", err) + } + return } + + useCase := app.NewUseModuleV2UseCase() + err := useCase.Execute(loadproject.PystranoStrategy, action.Pystrano) + if err != nil { cmd.PrintErrf("%v\n", err) } @@ -36,14 +35,5 @@ var pystranoCmd = &cobra.Command{ func init() { rootCmd.AddCommand(pystranoCmd) - - // Here you will define your flags and configuration settings. - - // Cobra supports Persistent Flags which will work for this command - // and all subcommands, e.g.: - // pystranoCmd.PersistentFlags().String("foo", "", "A help for foo") - - // Cobra supports local flags which will only run when this command - // is called directly, e.g.: - // pystranoCmd.Flags().BoolP("toggle", "t", false, "Help message for toggle") + pystranoCmd.Flags().Bool("check", false, "Check SSH connections before running Docker commands") } diff --git a/cmd/swarm.go b/cmd/swarm.go index 7062154..56d5dcc 100644 --- a/cmd/swarm.go +++ b/cmd/swarm.go @@ -2,7 +2,8 @@ package cmd import ( "github.com/goodylabs/tug/internal/app" - "github.com/goodylabs/tug/pkg/dependecies" + "github.com/goodylabs/tug/internal/modules/action" + "github.com/goodylabs/tug/internal/modules/loadproject" "github.com/spf13/cobra" ) @@ -10,23 +11,22 @@ var swarmCmd = &cobra.Command{ Use: "swarm", Short: "Abstraction layer for docker swarm operations related to project repo", Run: func(cmd *cobra.Command, args []string) { - check, err := cmd.Flags().GetBool("check") - container := dependecies.InitDependencyContainer( - dependecies.WithSwarmHandler, - ) - if check { - err = container.Invoke(func(checkConnectionUseCase *app.CheckConnectionUseCase) error { - return checkConnectionUseCase.Execute() - }) - } else { - err = container.Invoke(func(useModuleUseCase *app.UseModuleUseCase) error { - return useModuleUseCase.Execute() - }) + if check, _ := cmd.Flags().GetBool("check"); check == true { + checkConnectionUseCase := app.NewCheckConnectionUseCase() + if err := checkConnectionUseCase.Execute(loadproject.DockerStrategy); err != nil { + cmd.PrintErrf("%v\n", err) + } + return } + + useCase := app.NewUseModuleV2UseCase() + err := useCase.Execute(loadproject.DockerStrategy, action.Swarm) + if err != nil { cmd.PrintErrf("%v\n", err) } + }, } diff --git a/internal/adapters/prompter.go b/internal/adapters/prompter.go index d70e3d2..fe101cc 100644 --- a/internal/adapters/prompter.go +++ b/internal/adapters/prompter.go @@ -127,3 +127,17 @@ func (p *prompter) runPrompter(options []ports.DisplayValueOpts, label string) ( func (p *prompter) ChooseFromListWithDisplayValue(options []ports.DisplayValueOpts, label string) (string, error) { return p.runPrompter(options, label) } + +func (p *prompter) AskUserForInput(prompt string) (string, error) { + input := promptui.Prompt{ + Label: prompt, + Stdout: noBellWriter{os.Stdout}, + } + + value, err := input.Run() + if err != nil { + return "", err + } + + return value, nil +} diff --git a/internal/adapters/sshconnector.go b/internal/adapters/sshconnector.go index 7adfcda..d21df9d 100644 --- a/internal/adapters/sshconnector.go +++ b/internal/adapters/sshconnector.go @@ -15,7 +15,8 @@ import ( ) type sshConnector struct { - client *ssh.Client + client *ssh.Client + passphrase []byte } func NewSSHConnector() ports.SSHConnector { @@ -69,16 +70,22 @@ func (s *sshConnector) loadSSHKeysFromDir() ([]ssh.AuthMethod, error) { if err != nil { if _, ok := err.(*ssh.PassphraseMissingError); ok { fmt.Print("Enter passphrase for SSH key: ") - passphrase, perr := term.ReadPassword(int(os.Stdin.Fd())) - fmt.Println() - if perr != nil { - return nil, perr + + if "" == string(s.passphrase) { + passphraseInput, perr := term.ReadPassword(int(os.Stdin.Fd())) + fmt.Println() + if perr != nil { + return nil, perr + } + + s.passphrase = passphraseInput } - signer, err = ssh.ParsePrivateKeyWithPassphrase(keyData, passphrase) + signer, err = ssh.ParsePrivateKeyWithPassphrase(keyData, s.passphrase) if err != nil { return nil, err } + } else { return nil, err } diff --git a/internal/app/check_connection.go b/internal/app/check_connection.go index b3e9809..5d15230 100644 --- a/internal/app/check_connection.go +++ b/internal/app/check_connection.go @@ -1,87 +1,27 @@ package app import ( - "fmt" - "sync" - - "github.com/goodylabs/tug/internal/ports" + "github.com/goodylabs/tug/internal/modules/checkconnections" + "github.com/goodylabs/tug/internal/modules/loadproject" ) type CheckConnectionUseCase struct { - handler ports.TechnologyHandler - sshConnector ports.SSHConnector + checkConnectionsService *checkconnections.CheckConnectionsService } -func NewCheckConnectionUseCase(handler ports.TechnologyHandler, sshConnector ports.SSHConnector) *CheckConnectionUseCase { +func NewCheckConnectionUseCase() *CheckConnectionUseCase { return &CheckConnectionUseCase{ - handler: handler, - sshConnector: sshConnector, + checkConnectionsService: checkconnections.NewCheckConnectionsService(), } } -func (p *CheckConnectionUseCase) Execute() error { - if err := p.handler.LoadConfigFromFile(); err != nil { - return err - } - - availableEnvs, err := p.handler.GetAvailableEnvs() +func (p *CheckConnectionUseCase) Execute(loadTech loadproject.StrategyName) error { + lp := loadproject.NewLoadProject() + pCfg, err := lp.Execute(loadTech) if err != nil { return err } - var wg sync.WaitGroup - errCh := make(chan error, 1) - - for _, env := range availableEnvs { - hosts, err := p.handler.GetAvailableHosts(env) - if err != nil { - return err - } - - for _, host := range hosts { - wg.Add(1) - go func(env, host string) { - defer wg.Done() - if err := p.checkHost(env, host); err != nil { - select { - case errCh <- err: - default: - } - } - }(env, host) - } - } - - wg.Wait() - - select { - case err := <-errCh: - return err - default: - return nil - } -} - -func (p *CheckConnectionUseCase) checkHost(env, host string) error { - template := "Env: %s, Host: %s - %s\n" - - sshConfig, err := p.handler.GetSSHConfig(env, host) - if err != nil { - return err - } - - sshConStr := sshConfig.User + "@" + sshConfig.Host - - if err := p.sshConnector.ConfigureSSHConnection(sshConfig); err != nil { - fmt.Printf(template, env, sshConStr, "🚫 can not ssh connect") - return nil - } - - if _, err := p.handler.GetAvailableResources(sshConfig); err != nil { - fmt.Printf(template, env, sshConStr, "⚠️ can not list resources") - return nil - } - - fmt.Printf(template, env, sshConStr, "✅ - everything is ok") + p.checkConnectionsService.Execute(pCfg) return nil } diff --git a/internal/app/configure.go b/internal/app/configure.go index 5bfeaa9..75e9497 100644 --- a/internal/app/configure.go +++ b/internal/app/configure.go @@ -4,6 +4,7 @@ import ( "fmt" "path/filepath" + "github.com/goodylabs/tug/internal/adapters" "github.com/goodylabs/tug/internal/ports" "github.com/goodylabs/tug/internal/tughelper" "github.com/goodylabs/tug/pkg/config" @@ -13,9 +14,9 @@ type ConfigureUseCase struct { prompter ports.Prompter } -func NewConfigureUseCase(prompter ports.Prompter) *ConfigureUseCase { +func NewConfigureUseCase() *ConfigureUseCase { return &ConfigureUseCase{ - prompter: prompter, + prompter: adapters.NewPrompter(), } } diff --git a/internal/app/configure_test.go b/internal/app/configure_test.go deleted file mode 100644 index cf952de..0000000 --- a/internal/app/configure_test.go +++ /dev/null @@ -1,38 +0,0 @@ -package app_test - -import ( - "os" - "testing" - - "github.com/goodylabs/tug/internal/tughelper" - "github.com/goodylabs/tug/tests/mocks" - "github.com/stretchr/testify/assert" -) - -func TestConfigureUseCaseOk(t *testing.T) { - testCases := []struct { - promptChoices []int - expected string - }{ - { - promptChoices: []int{1}, - expected: "some_priv_key", - }, - { - promptChoices: []int{0}, - expected: "id_rsa", - }, - } - - for _, test := range testCases { - os.Remove(tughelper.GetTugConfigPath()) - - useCase := mocks.SetupConfigureUseCaseWithMocks(test.promptChoices) - err := useCase.Execute() - assert.NoError(t, err) - - tugConfig, err := tughelper.GetTugConfig() - assert.NoError(t, err, "Expected no error, got: %v", err) - assert.Contains(t, tugConfig.SSHKeyPath, test.expected) - } -} diff --git a/internal/app/usemodule.go b/internal/app/usemodule.go deleted file mode 100644 index 4ca9a33..0000000 --- a/internal/app/usemodule.go +++ /dev/null @@ -1,174 +0,0 @@ -package app - -import ( - "fmt" - "strings" - - "github.com/goodylabs/tug/internal/ports" - "github.com/goodylabs/tug/pkg/utils" -) - -type stepFunc func() (stepFunc, error) - -type UseModuleUseCase struct { - handler ports.TechnologyHandler - sshConnector ports.SSHConnector - prompter ports.Prompter - context struct { - selectedEnv string - sshConfig *ports.SSHConfig - action string - resource string - remoteHostname string - } - stack []stepFunc -} - -func NewUseModuleUseCase(handler ports.TechnologyHandler, sshConnector ports.SSHConnector, prompter ports.Prompter) *UseModuleUseCase { - return &UseModuleUseCase{ - handler: handler, - sshConnector: sshConnector, - prompter: prompter, - } -} - -func (u *UseModuleUseCase) Execute() error { - - if err := u.handler.LoadConfigFromFile(); err != nil { - return err - } - - u.stack = []stepFunc{u.stepSelectEnv} - - for len(u.stack) > 0 { - stackLen := len(u.stack) - nextStep, err := u.stack[stackLen-1]() - if err != nil { - return err - } - if nextStep == nil { - u.stack = u.stack[:stackLen-1] - continue - } - u.stack = append(u.stack, nextStep) - } - - return nil -} - -func (u *UseModuleUseCase) stepSelectEnv() (stepFunc, error) { - availableEnvs, err := u.handler.GetAvailableEnvs() - if err != nil { - return nil, err - } - - selectedEnv, err := u.prompter.ChooseFromList(availableEnvs, "Choose an environment:") - if err != nil { - return nil, nil - } - - u.context.selectedEnv = selectedEnv - - return u.stepSelectHost, nil -} - -const helpCmdTemplate = ` -1. Check if you can connect as root manually: ssh %s -2. If you can, please run the following command to copy your user's SSH keys from root user to app user on host: - -grep -vxFf %s /root/.ssh/authorized_keys >> %s - -3. Try again to connect with tug. -` - -func (u *UseModuleUseCase) stepSelectHost() (stepFunc, error) { - availableHosts, err := u.handler.GetAvailableHosts(u.context.selectedEnv) - if err != nil { - return nil, err - } - - promptLabel := fmt.Sprintf("[ %s ] Choose a host:", u.context.selectedEnv) - selectedHost, err := u.prompter.ChooseFromList(availableHosts, promptLabel) - if err != nil { - return nil, nil - } - fmt.Println("Connecting to server...") - - sshConfig, err := u.handler.GetSSHConfig(u.context.selectedEnv, selectedHost) - if err != nil { - return nil, err - } - - if err = u.sshConnector.ConfigureSSHConnection(sshConfig); err != nil { - userAddr := sshConfig.User + "@" + sshConfig.Host - if sshConfig.User == "root" { - return nil, fmt.Errorf("Failed to connect to the server with %s - error: %s", userAddr, err.Error()) - } - rootAddr := "root" + "@" + sshConfig.Host - userAuthKeys := fmt.Sprintf("/home/%s/.ssh/authorized_keys", sshConfig.User) - helpCommand := fmt.Sprintf(helpCmdTemplate, rootAddr, userAuthKeys, userAuthKeys) - return nil, fmt.Errorf("Failed to connect to the server with %s - error: %s\nWhy is that? \n%s\n", userAddr, err.Error(), helpCommand) - } - - u.context.sshConfig = sshConfig - - remoteHostname := u.getRemoteHostname() - u.context.remoteHostname = utils.NormalizeSpaces(remoteHostname) - - return u.stepSelectAction, nil -} - -func (u *UseModuleUseCase) getRemoteHostname() string { - output, err := u.sshConnector.RunCommand("hostname") - if err != nil { - return "failed_to_display_hostname" - } - return output -} - -func (u *UseModuleUseCase) stepSelectAction() (stepFunc, error) { - actionTemplates := u.handler.GetAvailableActionTemplates() - - promptLabel := fmt.Sprintf("[ %s ] Choose an action:", u.context.remoteHostname) - fmt.Println(promptLabel) - cmdTemplate, err := u.prompter.ChooseFromMap(actionTemplates, promptLabel) - if err != nil { - fmt.Println("Moving back to host selection...") - return nil, nil - } - - u.context.action = cmdTemplate - - if strings.Contains(cmdTemplate, "%s") { - return u.stepSelectResource, nil - } else { - return u.stepExecuteAction, nil - } -} - -func (u *UseModuleUseCase) stepSelectResource() (stepFunc, error) { - resources, err := u.handler.GetAvailableResources(u.context.sshConfig) - if err != nil { - return nil, err - } - - promptLabel := fmt.Sprintf("[ %s ] Choose a resource:", u.context.remoteHostname) - resource, err := u.prompter.ChooseFromList(resources, promptLabel) - if err != nil { - u.context.sshConfig = nil - return nil, nil - } - u.context.resource = resource - return u.stepExecuteAction, nil -} - -func (u *UseModuleUseCase) stepExecuteAction() (stepFunc, error) { - var remoteCmd = u.context.action - - if strings.Contains(remoteCmd, "%s") { - remoteCmd = fmt.Sprintf(u.context.action, u.context.resource) - } - - u.sshConnector.RunInteractiveCommand(remoteCmd) - return nil, nil -} diff --git a/internal/app/usemodule.v2.go b/internal/app/usemodule.v2.go new file mode 100644 index 0000000..3d88206 --- /dev/null +++ b/internal/app/usemodule.v2.go @@ -0,0 +1,167 @@ +package app + +import ( + "fmt" + "strings" + + "github.com/goodylabs/tug/internal/adapters" + "github.com/goodylabs/tug/internal/modules" + "github.com/goodylabs/tug/internal/modules/action" + "github.com/goodylabs/tug/internal/modules/loadproject" + "github.com/goodylabs/tug/internal/ports" +) + +type stepFunc func() (stepFunc, error) + +type UseModuleV2UseCase struct { + prompter ports.Prompter + sshService *action.SSHService + actionMgr *action.ActionManager + projectConfig modules.ProjectConfig + + ctx struct { + env string + hostname string + template string + } + stack []stepFunc +} + +func NewUseModuleV2UseCase() *UseModuleV2UseCase { + return &UseModuleV2UseCase{ + sshService: action.NewSSHService(adapters.NewSSHConnector()), + prompter: adapters.NewPrompter(), + } +} + +func (u *UseModuleV2UseCase) Execute(loadTech loadproject.StrategyName, actionTech action.StrategyName) error { + lp := loadproject.NewLoadProject() + pCfg, err := lp.Execute(loadTech) + if err != nil { + return err + } + u.projectConfig = pCfg + + actStrategy, err := action.GetStrategy(actionTech) + if err != nil { + return err + } + u.actionMgr = action.NewActionManager(actStrategy) + + u.stack = []stepFunc{u.stepSelectEnv} + return u.runStack() +} + +func (u *UseModuleV2UseCase) ExecuteDirect(user, host string, actionTech action.StrategyName) error { + actStrategy, err := action.GetStrategy(actionTech) + if err != nil { + return err + } + u.actionMgr = action.NewActionManager(actStrategy) + + fmt.Printf("Connecting to %s@%s...\n", user, host) + hostname, err := u.sshService.Connect(user, host) + if err != nil { + return err + } + + u.ctx.hostname = hostname + u.ctx.env = "direct" + + u.stack = []stepFunc{u.stepSelectAction} + return u.runStack() +} + +func (u *UseModuleV2UseCase) runStack() error { + for len(u.stack) > 0 { + stackLen := len(u.stack) + currentStep := u.stack[stackLen-1] + + nextStep, err := currentStep() + if err != nil { + return err + } + + if nextStep == nil { + u.stack = u.stack[:stackLen-1] + continue + } + + if fmt.Sprintf("%v", nextStep) == fmt.Sprintf("%v", currentStep) { + continue + } + + u.stack = append(u.stack, nextStep) + } + return nil +} + +func (u *UseModuleV2UseCase) stepSelectEnv() (stepFunc, error) { + envs := u.projectConfig.GetAvailableEnvs() + selected, err := u.prompter.ChooseFromList(envs, "Choose environment:") + if err != nil { + return nil, nil + } + + u.ctx.env = selected + return u.stepSelectHost, nil +} + +func (u *UseModuleV2UseCase) stepSelectHost() (stepFunc, error) { + hosts, err := u.projectConfig.GetAvailableHosts(u.ctx.env) + if err != nil { + return nil, err + } + + label := fmt.Sprintf("[%s] Select host:", u.ctx.env) + host, err := u.prompter.ChooseFromList(hosts, label) + if err != nil { + return nil, nil + } + + envCfg, _ := u.projectConfig.GetEnvConfig(u.ctx.env) + + fmt.Println("Connecting...") + hostname, err := u.sshService.Connect(envCfg.User, host) + if err != nil { + return nil, err + } + + u.ctx.hostname = hostname + return u.stepSelectAction, nil +} + +func (u *UseModuleV2UseCase) stepSelectAction() (stepFunc, error) { + templates := u.actionMgr.GetAvailableActionTemplates() + + label := fmt.Sprintf("[%s] Select action:", u.ctx.hostname) + selected, err := u.prompter.ChooseFromMap(templates, label) + if err != nil { + return nil, nil + } + + u.ctx.template = selected + + if strings.Contains(selected, "%s") { + return u.stepSelectResource, nil + } + + u.sshService.RunAction(u.ctx.template, "") + return u.stepSelectAction, nil +} + +func (u *UseModuleV2UseCase) stepSelectResource() (stepFunc, error) { + res, err := u.actionMgr.GetAvailableResources(u.sshService.GetConnector()) + if err != nil { + return nil, fmt.Errorf("failed to fetch resources: %w", err) + } + + label := fmt.Sprintf("[%s] Select resource:", u.ctx.hostname) + resource, err := u.prompter.ChooseFromList(res, label) + if err != nil { + return nil, nil + } + + u.sshService.RunAction(u.ctx.template, resource) + return u.stepSelectResource, nil +} diff --git a/internal/constants/constants.go b/internal/constants/constants.go deleted file mode 100644 index f6c2a41..0000000 --- a/internal/constants/constants.go +++ /dev/null @@ -1,22 +0,0 @@ -package constants - -const ( - STACK_NAME = "STACK_NAME" - PANIC = "You really f*cked up sth with your data" -) - -const ( - TARGET_IP_FIELD = "TARGET_IP" - TARGET_IP_FIELD_LEGACY = "IP_ADDRESS" -) - -const ( - GITHUB_LAST_RELEASE = `https://api.github.com/repos/goodylabs/tug/releases/latest` - DOWNLOAD_SCRIPT_URL = `https://raw.githubusercontent.com/goodylabs/tug/refs/heads/main/scripts/download.sh` -) - -const ( - ECOSYSTEM_CONFIG_FILE = "ecosystem.config.js" - DOCKER_CONFIG_FILE = "deploy.sh" - DEVOPS_DIR = "devops" -) diff --git a/internal/modules/.DS_Store b/internal/modules/.DS_Store new file mode 100644 index 0000000..5008ddf Binary files /dev/null and b/internal/modules/.DS_Store differ diff --git a/internal/modules/action/factory.go b/internal/modules/action/factory.go new file mode 100644 index 0000000..33670ab --- /dev/null +++ b/internal/modules/action/factory.go @@ -0,0 +1,27 @@ +package action + +import "fmt" + +type StrategyName string + +const ( + Docker StrategyName = "docker" + Pm2 StrategyName = "pm2" + Pystrano StrategyName = "pystrano" + Swarm StrategyName = "swarm" +) + +func GetStrategy(tech StrategyName) (ActionStrategy, error) { + switch tech { + case Docker: + return NewDockerActionStrategy(), nil + case Pm2: + return NewPm2ActionStrategy(), nil + case Pystrano: + return NewPystranoActionStrategy(), nil + case Swarm: + return NewSwarmActionStrategy(), nil + default: + return nil, fmt.Errorf("unsupported action strategy: %s", tech) + } +} diff --git a/internal/modules/action/models.go b/internal/modules/action/models.go new file mode 100644 index 0000000..f8cbfc0 --- /dev/null +++ b/internal/modules/action/models.go @@ -0,0 +1,26 @@ +package action + +import ( + "github.com/goodylabs/tug/internal/ports" +) + +type ActionStrategy interface { + GetResources(ssh ports.SSHConnector) ([]string, error) + GetTemplates() map[string]string +} + +type ActionManager struct { + strategy ActionStrategy +} + +func NewActionManager(strategy ActionStrategy) *ActionManager { + return &ActionManager{strategy: strategy} +} + +func (m *ActionManager) GetAvailableResources(ssh ports.SSHConnector) ([]string, error) { + return m.strategy.GetResources(ssh) +} + +func (m *ActionManager) GetAvailableActionTemplates() map[string]string { + return m.strategy.GetTemplates() +} diff --git a/internal/modules/action/ssh_service.go b/internal/modules/action/ssh_service.go new file mode 100644 index 0000000..4cb81e8 --- /dev/null +++ b/internal/modules/action/ssh_service.go @@ -0,0 +1,48 @@ +package action + +import ( + "fmt" + + "github.com/goodylabs/tug/internal/ports" + "github.com/goodylabs/tug/pkg/utils" +) + +type SSHService struct { + connector ports.SSHConnector +} + +func NewSSHService(connector ports.SSHConnector) *SSHService { + return &SSHService{connector: connector} +} + +// GetConnector wystawia interfejs SSH do zadań specjalnych (np. listowanie zasobów) +func (s *SSHService) GetConnector() ports.SSHConnector { + return s.connector +} + +func (s *SSHService) Connect(user, host string) (string, error) { + cfg := &ports.SSHConfig{ + Host: host, + User: user, + Port: 22, + } + + if err := s.connector.ConfigureSSHConnection(cfg); err != nil { + errorMsg := fmt.Errorf("failed to connect as %s@%s: %w", cfg.User, cfg.Host, err) + return "", errorMsg + } + + hostname, err := s.connector.RunCommand("hostname") + if err != nil { + return "unknown_host", nil + } + return utils.NormalizeSpaces(hostname), nil +} + +func (s *SSHService) RunAction(template, resource string) { + cmd := template + if resource != "" { + cmd = fmt.Sprintf(template, resource) + } + s.connector.RunInteractiveCommand(cmd) +} diff --git a/internal/modules/docker/docker/services/getactiontemplates.go b/internal/modules/action/strategy_docker.go similarity index 55% rename from internal/modules/docker/docker/services/getactiontemplates.go rename to internal/modules/action/strategy_docker.go index b937d02..cbdd8b1 100644 --- a/internal/modules/docker/docker/services/getactiontemplates.go +++ b/internal/modules/action/strategy_docker.go @@ -1,10 +1,23 @@ -package services +package action -const continueMsg = "echo 'Done, press Enter to continue...' && read" +import ( + "encoding/json" + "fmt" + "strings" -func GetActionTemplates() map[string]string { + "github.com/goodylabs/tug/internal/ports" +) + +type DockerActionStrategy struct{} + +func NewDockerActionStrategy() *DockerActionStrategy { + return &DockerActionStrategy{} +} + +func (s *DockerActionStrategy) GetTemplates() map[string]string { + const continueMsg = "echo 'Done, press Enter to continue...' && read" return map[string]string{ - "docker -- logs -f ": "docker logs -f %s", + "docker -- logs -f ": "docker logs -f %s", "docker -- exec -u root -ti sh": "docker exec -u root -it %s sh", "docker -- logs | less": "docker logs %s | less", "docker -- restart ": "docker restart %s && " + continueMsg, @@ -20,3 +33,28 @@ func GetActionTemplates() map[string]string { "traefik -- show config services": "docker exec %s sh -c 'apk add --no-cache --no-progress -q curl && curl localhost:8080/api/rawdata' | jq '.services' | less", } } + +func (s *DockerActionStrategy) GetResources(ssh ports.SSHConnector) ([]string, error) { + output, err := ssh.RunCommand("docker ps --format json") + if err != nil { + return nil, fmt.Errorf("failed to list docker containers: %w", err) + } + + var resourceNames []string + lines := strings.SplitSeq(strings.TrimSpace(output), "\n") + + for line := range lines { + if line == "" { + continue + } + var container struct { + Names string `json:"Names"` + } + if err := json.Unmarshal([]byte(line), &container); err != nil { + continue + } + resourceNames = append(resourceNames, container.Names) + } + + return resourceNames, nil +} diff --git a/internal/modules/action/strategy_pm2.go b/internal/modules/action/strategy_pm2.go new file mode 100644 index 0000000..fe088ba --- /dev/null +++ b/internal/modules/action/strategy_pm2.go @@ -0,0 +1,61 @@ +package action + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/goodylabs/tug/internal/ports" +) + +type Pm2ActionStrategy struct{} + +func NewPm2ActionStrategy() *Pm2ActionStrategy { + return &Pm2ActionStrategy{} +} + +func (s *Pm2ActionStrategy) GetTemplates() map[string]string { + const nvmPrefix = "source ~/.nvm/nvm.sh; " + const continueMsg = " && echo 'Done, press Enter to continue...' && read" + + return map[string]string{ + "pm2 -- logs ": nvmPrefix + "pm2 logs %s", + "pm2 -- logs | less": nvmPrefix + "pm2 logs %s | less", + "pm2 -- logs": nvmPrefix + "pm2 logs", + "pm2 -- status | less": nvmPrefix + "pm2 status %s | less", + "pm2 -- show ": nvmPrefix + "pm2 show %s" + continueMsg, + "pm2 -- describe ": nvmPrefix + "pm2 describe %s" + continueMsg, + "pm2 -- restart ": nvmPrefix + "pm2 restart %s", + "pm2 -- monit": nvmPrefix + "pm2 monit", + "pm2 -- update": nvmPrefix + "pm2 update", + "bash -- bash": nvmPrefix + "bash", + "bash -- htop": nvmPrefix + "htop", + } +} + +func (s *Pm2ActionStrategy) GetResources(ssh ports.SSHConnector) ([]string, error) { + const jlistCmd = `source ~/.nvm/nvm.sh; pm2 jlist | sed -n '/^\[/,$p'` + + output, err := ssh.RunCommand(jlistCmd) + if err != nil { + return nil, fmt.Errorf("failed to list pm2 processes: %w", err) + } + + type pm2Item struct { + Name string `json:"name"` + } + + var pm2List []pm2Item + if err := json.Unmarshal([]byte(strings.TrimSpace(output)), &pm2List); err != nil { + return nil, fmt.Errorf("failed to parse PM2 list output: %w", err) + } + + var resourceNames []string + for _, item := range pm2List { + if item.Name != "" { + resourceNames = append(resourceNames, item.Name) + } + } + + return resourceNames, nil +} diff --git a/internal/modules/action/strategy_pystrano.go b/internal/modules/action/strategy_pystrano.go new file mode 100644 index 0000000..b4e5b80 --- /dev/null +++ b/internal/modules/action/strategy_pystrano.go @@ -0,0 +1,59 @@ +package action + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/goodylabs/tug/internal/ports" +) + +type PystranoActionStrategy struct{} + +func NewPystranoActionStrategy() *PystranoActionStrategy { + return &PystranoActionStrategy{} +} + +func (s *PystranoActionStrategy) GetTemplates() map[string]string { + const continueMsg = "echo 'Done, press Enter to continue...' && read" + + return map[string]string{ + "pystrano -- logs -f ": "pystrano logs -f %s", + "pystrano -- restart ": "pystrano restart %s && " + continueMsg, + "pystrano -- status": "pystrano ps", + "bash -- bash": "bash", + "bash -- htop": "htop", + "bash -- btop": "btop", + "bash -- df -h": "df -h && " + continueMsg, + "bash -- free -h": "free -h && " + continueMsg, + } +} + +func (s *PystranoActionStrategy) GetResources(ssh ports.SSHConnector) ([]string, error) { + output, err := ssh.RunCommand("pystrano ps --format json") + if err != nil { + return nil, fmt.Errorf("failed to list pystrano resources: %w", err) + } + + type pystranoResource struct { + Name string `json:"Name"` + } + + lines := strings.Split(strings.TrimSpace(output), "\n") + var resourceNames []string + + for _, line := range lines { + if !strings.HasPrefix(line, "{") { + continue + } + var res pystranoResource + if err := json.Unmarshal([]byte(line), &res); err != nil { + continue + } + if res.Name != "" { + resourceNames = append(resourceNames, res.Name) + } + } + + return resourceNames, nil +} diff --git a/internal/modules/action/strategy_swarm.go b/internal/modules/action/strategy_swarm.go new file mode 100644 index 0000000..ab1962b --- /dev/null +++ b/internal/modules/action/strategy_swarm.go @@ -0,0 +1,67 @@ +package action + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/goodylabs/tug/internal/ports" +) + +type SwarmActionStrategy struct{} + +func NewSwarmActionStrategy() *SwarmActionStrategy { + return &SwarmActionStrategy{} +} + +func (s *SwarmActionStrategy) GetTemplates() map[string]string { + const continueMsg = "echo 'Done, press Enter to continue...' && read" + + return map[string]string{ + "swarm -- ls": "watch docker service ls", + "swarm -- ps ": "watch docker service ps %s --no-trunc", + "swarm -- ps (only running)": `watch 'docker service ps --filter desired-state=running --format "{{.ID}} {{.Name}} - {{.Node}} | {{.Image}}" %s'`, + "swarm -- inspect ": "docker service inspect %s | jq | less", + "swarm -- restart ": "docker service update %s --force && " + continueMsg, + "swarm -- logs -f ": "docker service logs -f %s", + "swarm -- logs | less": "docker service logs %s | less", + "swarm -- scale replicas to 0": "docker service scale %s=0 && " + continueMsg, + "swarm -- scale replicas to 1": "docker service scale %s=1 && " + continueMsg, + "swarm -- scale replicas to 3": "docker service scale %s=3 && " + continueMsg, + "bash -- bash": "bash", + "bash -- htop": "htop", + } +} + +func (s *SwarmActionStrategy) GetResources(ssh ports.SSHConnector) ([]string, error) { + + output, err := ssh.RunCommand("docker service ls --format json") + if err != nil { + return nil, fmt.Errorf("failed to list docker services: %w", err) + } + + type serviceDTO struct { + Name string `json:"Name"` + } + + var resourceNames []string + + lines := strings.Split(strings.TrimSpace(output), "\n") + for _, line := range lines { + if line == "" { + continue + } + + var service serviceDTO + if err := json.Unmarshal([]byte(line), &service); err != nil { + + continue + } + + if service.Name != "" { + resourceNames = append(resourceNames, service.Name) + } + } + + return resourceNames, nil +} diff --git a/internal/modules/checkconnections/check_connections_service.go b/internal/modules/checkconnections/check_connections_service.go new file mode 100644 index 0000000..387ff4e --- /dev/null +++ b/internal/modules/checkconnections/check_connections_service.go @@ -0,0 +1,59 @@ +package checkconnections + +import ( + "fmt" + "sync" + + "github.com/goodylabs/tug/internal/adapters" + "github.com/goodylabs/tug/internal/modules" + "github.com/goodylabs/tug/internal/ports" +) + +type CheckConnectionsService struct { + sshConnector ports.SSHConnector +} + +func NewCheckConnectionsService() *CheckConnectionsService { + return &CheckConnectionsService{ + sshConnector: adapters.NewSSHConnector(), + } +} + +func (c *CheckConnectionsService) Execute(projectConfig modules.ProjectConfig) { + fmt.Printf("🔍 Verification for project config\n\n") + + var wg sync.WaitGroup + envs := projectConfig.GetAvailableEnvs() + + for _, envName := range envs { + envCfg, _ := projectConfig.GetEnvConfig(envName) + + for _, host := range envCfg.Hosts { + wg.Add(1) + + go func(envName, host, user string) { + defer wg.Done() + + statusTemplate := " %-15s %-30s %s\n" + sshTarget := fmt.Sprintf("%s@%s", user, host) + + sshCfg := &ports.SSHConfig{ + Host: host, + Port: 22, + User: user, + } + + if err := c.sshConnector.ConfigureSSHConnection(sshCfg); err != nil { + fmt.Printf(statusTemplate, envName, sshTarget, "🚫 Connection failed") + return + } + + fmt.Printf(statusTemplate, envName, sshTarget, "✅ OK") + + }(envName, host, envCfg.User) + } + } + + wg.Wait() + fmt.Println("\n✨ Verification finished.") +} diff --git a/internal/modules/docker/common/api.go b/internal/modules/docker/common/api.go deleted file mode 100644 index 69dcbd1..0000000 --- a/internal/modules/docker/common/api.go +++ /dev/null @@ -1,107 +0,0 @@ -package dockercommon - -import ( - "errors" - "fmt" - "path/filepath" - - "github.com/goodylabs/tug/internal/modules/docker/common/services" - "github.com/goodylabs/tug/internal/ports" - "github.com/goodylabs/tug/pkg/config" -) - -type EnvCfg struct { - Name string - User string - Hosts []string -} - -type DockerCommon struct { - SSHConnector ports.SSHConnector - Config map[string]EnvCfg -} - -func NewDockerCommon(sshConnector ports.SSHConnector) *DockerCommon { - return &DockerCommon{ - SSHConnector: sshConnector, - Config: make(map[string]EnvCfg), - } -} -func (d *DockerCommon) LoadConfigFromFile() error { - - baseDir := config.GetBaseDir() - devopsDirPath := filepath.Join(baseDir, DEVOPS_DIR) - - envs, err := services.ListEnvs(devopsDirPath) - if err != nil { - return err - } - - if len(envs) == 0 { - return fmt.Errorf("no docker envs found on path %s", devopsDirPath) - } - - for _, env := range envs { - - scriptPath := filepath.Join(devopsDirPath, env, DEPLOY_FILE) - - var hosts []string - if targetIp := services.GetSingleIpFromShellFile(scriptPath, TARGET_IP_VAR); targetIp != "" { - hosts = []string{targetIp} - } - - if ipAddress := services.GetSingleIpFromShellFile(scriptPath, IP_ADDRESS_VAR); ipAddress != "" { - hosts = []string{ipAddress} - } - - if multiIps := services.GetMultipleIpsFromShellScript(scriptPath, IP_ADDRESSES_VAR); len(multiIps) != 0 { - hosts = multiIps - } - - if len(hosts) != 0 { - d.Config[env] = EnvCfg{ - Name: env, - User: "root", - Hosts: hosts, - } - } - } - - if len(d.Config) == 0 { - return fmt.Errorf("no valid docker configuration found in %s", devopsDirPath) - } - - return nil -} - -func (d *DockerCommon) GetAvailableEnvs() ([]string, error) { - if d.Config == nil { - return []string{}, errors.New("Can not get available environments - config is not loaded") - } - - var envs []string - for env := range d.Config { - envs = append(envs, env) - } - return envs, nil -} - -func (d *DockerCommon) GetAvailableHosts(env string) ([]string, error) { - if d.Config == nil { - return []string{}, errors.New("Can not get available hosts - config is not loaded") - } - - return d.Config[env].Hosts, nil -} - -func (d *DockerCommon) GetSSHConfig(env, host string) (*ports.SSHConfig, error) { - if d.Config == nil { - return nil, errors.New("Can not get ssh config - config is not loaded") - } - - return &ports.SSHConfig{ - Host: host, - User: d.Config[env].User, - Port: 22, - }, nil -} diff --git a/internal/modules/docker/common/constants.go b/internal/modules/docker/common/constants.go deleted file mode 100644 index 98c66ca..0000000 --- a/internal/modules/docker/common/constants.go +++ /dev/null @@ -1,9 +0,0 @@ -package dockercommon - -const ( - TARGET_IP_VAR = "TARGET_IP" - IP_ADDRESS_VAR = "IP_ADDRESS" - IP_ADDRESSES_VAR = "IP_ADDRESSES" - DEVOPS_DIR = "devops" - DEPLOY_FILE = "deploy.sh" -) diff --git a/internal/modules/docker/common/services/loadconfig.go b/internal/modules/docker/common/services/loadconfig.go deleted file mode 100644 index 3f558ed..0000000 --- a/internal/modules/docker/common/services/loadconfig.go +++ /dev/null @@ -1,66 +0,0 @@ -package services - -import ( - "bufio" - "fmt" - "os" - "strings" - - "github.com/goodylabs/tug/pkg/utils" -) - -func ListEnvs(devopsDirPath string) ([]string, error) { - envs, err := utils.ListDirsOnPath(devopsDirPath) - if err != nil { - return []string{}, fmt.Errorf("Can not read config from file, err: %w", err) - } - return envs, nil -} - -func getShellVariableValue(scriptPath, variable string) (string, error) { - file, err := os.Open(scriptPath) - if err != nil { - return "", err - } - defer file.Close() - - scanner := bufio.NewScanner(file) - - for scanner.Scan() { - line := scanner.Text() - - if !strings.HasPrefix(line, variable+"=") { - continue - } - - rightPart := strings.TrimPrefix(line, variable+"=") - - var clearedStr = rightPart - clearedStr = strings.ReplaceAll(clearedStr, `"`, "") - clearedStr = strings.ReplaceAll(clearedStr, `'`, "") - clearedStr = strings.ReplaceAll(clearedStr, "`", "") - - return clearedStr, nil - } - - return "", nil -} - -func GetSingleIpFromShellFile(scriptPath, variable string) string { - value, _ := getShellVariableValue(scriptPath, variable) - return value -} - -func GetMultipleIpsFromShellScript(scriptPath, variable string) []string { - value, _ := getShellVariableValue(scriptPath, variable) - - if value == "" { - return []string{} - } - - var trimmedValue = value - trimmedValue = strings.TrimPrefix(trimmedValue, "(") - trimmedValue = strings.TrimSuffix(trimmedValue, ")") - - return strings.Split(trimmedValue, " ") -} diff --git a/internal/modules/docker/common/services/loadconfig_test.go b/internal/modules/docker/common/services/loadconfig_test.go deleted file mode 100644 index 6e088e3..0000000 --- a/internal/modules/docker/common/services/loadconfig_test.go +++ /dev/null @@ -1,87 +0,0 @@ -package services_test - -import ( - "path/filepath" - "testing" - - dockercommon "github.com/goodylabs/tug/internal/modules/docker/common" - "github.com/goodylabs/tug/internal/modules/docker/common/services" - "github.com/goodylabs/tug/pkg/config" - "github.com/stretchr/testify/assert" -) - -func TestListEnvs(t *testing.T) { - - var devopsDirPath = filepath.Join(config.GetBaseDir(), "devops") - - t.Run("existing path", func(t *testing.T) { - envs, err := services.ListEnvs(devopsDirPath) - - assert.NoError(t, err) - assert.Equal(t, []string{"localhost", "production"}, envs[0:2]) - }) - - t.Run("non existing path", func(t *testing.T) { - _, err := services.ListEnvs("404") - - assert.ErrorContains(t, err, "Can not read config from file, err: open 404") - }) -} - -func TestGetSingleIpFromShellFile(t *testing.T) { - - var devopsDirPath = filepath.Join(config.GetBaseDir(), "devops") - - t.Run("IP_ADDRESS variable", func(t *testing.T) { - scriptPath := filepath.Join(devopsDirPath, "localhost", dockercommon.DEPLOY_FILE) - - ip := services.GetSingleIpFromShellFile(scriptPath, dockercommon.IP_ADDRESS_VAR) - - assert.Equal(t, "unix:///var/run/docker.sock", ip) - }) - - t.Run("TARGET_IP variable", func(t *testing.T) { - scriptPath := filepath.Join(devopsDirPath, "production", dockercommon.DEPLOY_FILE) - - ip := services.GetSingleIpFromShellFile(scriptPath, dockercommon.TARGET_IP_VAR) - - assert.Equal(t, "", ip) - }) - - t.Run("empty line on non-existing variable", func(t *testing.T) { - scriptPath := filepath.Join(devopsDirPath, "production", dockercommon.DEPLOY_FILE) - - ip := services.GetSingleIpFromShellFile(scriptPath, "404") - - assert.Equal(t, "", ip) - }) -} - -func TestGetMultipleIpsFromShellFile(t *testing.T) { - - var devopsDirPath = filepath.Join(config.GetBaseDir(), "devops") - - t.Run("IP_ADDRESS variable", func(t *testing.T) { - scriptPath := filepath.Join(devopsDirPath, "production_v2", dockercommon.DEPLOY_FILE) - - ip := services.GetMultipleIpsFromShellScript(scriptPath, dockercommon.IP_ADDRESSES_VAR) - - assert.Equal(t, []string{"ip_1", "ip_2", "ip_3"}, ip) - }) - - t.Run("empty line on non-existing variable", func(t *testing.T) { - scriptPath := filepath.Join(devopsDirPath, "production", "404") - - ip := services.GetMultipleIpsFromShellScript(scriptPath, "") - - assert.Len(t, ip, 0) - }) - - t.Run("empty line on non-existing file", func(t *testing.T) { - scriptPath := filepath.Join(devopsDirPath, "production", dockercommon.DEPLOY_FILE) - - ip := services.GetMultipleIpsFromShellScript(scriptPath, "404") - - assert.Len(t, ip, 0) - }) -} diff --git a/internal/modules/docker/docker/api.go b/internal/modules/docker/docker/api.go deleted file mode 100644 index 4c25c80..0000000 --- a/internal/modules/docker/docker/api.go +++ /dev/null @@ -1,37 +0,0 @@ -package docker - -import ( - "errors" - - dockercommon "github.com/goodylabs/tug/internal/modules/docker/common" - "github.com/goodylabs/tug/internal/modules/docker/docker/services" - "github.com/goodylabs/tug/internal/ports" -) - -type DockerManager struct { - *dockercommon.DockerCommon -} - -func NewDockerManager(sshConnector ports.SSHConnector) ports.TechnologyHandler { - return &DockerManager{ - DockerCommon: dockercommon.NewDockerCommon(sshConnector), - } -} - -func (d *DockerManager) GetAvailableResources(*ports.SSHConfig) ([]string, error) { - if d.DockerCommon.Config == nil { - return []string{}, errors.New("Can not get available resources - config is not loaded") - } - - dockerListCmd := "docker ps --format json" - output, err := d.DockerCommon.SSHConnector.RunCommand(dockerListCmd) - if err != nil { - return nil, err - } - - return services.GetResourcesFromJsonOutput(output) -} - -func (d *DockerManager) GetAvailableActionTemplates() map[string]string { - return services.GetActionTemplates() -} diff --git a/internal/modules/docker/docker/services/getresourcers.go b/internal/modules/docker/docker/services/getresourcers.go deleted file mode 100644 index 78655cb..0000000 --- a/internal/modules/docker/docker/services/getresourcers.go +++ /dev/null @@ -1,43 +0,0 @@ -package services - -import ( - "encoding/json" - "strings" -) - -type containerDTO struct { - Command string `json:"Command"` - CreatedAt string `json:"CreatedAt"` - ID string `json:"ID"` - Image string `json:"Image"` - Labels string `json:"Labels"` - LocalVolumes string `json:"LocalVolumes"` - Mounts string `json:"Mounts"` - Name string `json:"Names"` - Networks string `json:"Networks"` - Ports string `json:"Ports"` - RunningFor string `json:"RunningFor"` - Size string `json:"Size"` - State string `json:"State"` - Status string `json:"Status"` -} - -func GetResourcesFromJsonOutput(output string) ([]string, error) { - - var containers []containerDTO - - lines := strings.SplitSeq(strings.TrimSpace(output), "\n") - for line := range lines { - var container containerDTO - if err := json.Unmarshal([]byte(line), &container); err != nil { - continue - } - containers = append(containers, container) - } - - var containerNames []string - for _, container := range containers { - containerNames = append(containerNames, container.Name) - } - return containerNames, nil -} diff --git a/internal/modules/docker/docker/services/getresourcers_test.go b/internal/modules/docker/docker/services/getresourcers_test.go deleted file mode 100644 index d1306a8..0000000 --- a/internal/modules/docker/docker/services/getresourcers_test.go +++ /dev/null @@ -1,19 +0,0 @@ -package services_test - -import ( - "testing" - - "github.com/goodylabs/tug/internal/modules/docker/docker/services" - "github.com/stretchr/testify/assert" -) - -func TestGetResourcesFromJsonOutput(t *testing.T) { - output := ` - {"Command":"\"/entrypoint.sh --co…\"","CreatedAt":"2025-09-03 08:01:25 +0000 UTC","ID":"3f03d2082d78","Image":"traefik:v3.0","Names":"traefik_traefik.ue8","Size":"0B","State":"running","Status":"Up 2 hours"} - {"Command":"\"./docker-entrypoint…\"","CreatedAt":"2025-09-03 04:57:47 +0000 UTC","ID":"59a393c15ece","Image":"ghcr.io/some/test:654321","Names":"test_test.123","Size":"0B","State":"running","Status":"Up 5 hours (healthy)"} - {"Command":"\"/usr/bin/vector\"","CreatedAt":"2025-08-28 09:32:38 +0000 UTC","ID":"b1aeaf771207","Image":"timberio/vector:0.47.0-debian","Names":"vector-vector-1","Size":"0B","State":"running","Status":"Up 6 days"} - ` - containerNames, err := services.GetResourcesFromJsonOutput(output) - assert.NoError(t, err) - assert.Equal(t, []string{"traefik_traefik.ue8", "test_test.123", "vector-vector-1"}, containerNames) -} diff --git a/internal/modules/docker/swarm/api.go b/internal/modules/docker/swarm/api.go deleted file mode 100644 index 1ffb014..0000000 --- a/internal/modules/docker/swarm/api.go +++ /dev/null @@ -1,37 +0,0 @@ -package swarm - -import ( - "errors" - - dockercommon "github.com/goodylabs/tug/internal/modules/docker/common" - "github.com/goodylabs/tug/internal/modules/docker/swarm/services" - "github.com/goodylabs/tug/internal/ports" -) - -type SwarmManager struct { - *dockercommon.DockerCommon -} - -func NewSwarmManager(sshConnector ports.SSHConnector) ports.TechnologyHandler { - return &SwarmManager{ - DockerCommon: dockercommon.NewDockerCommon(sshConnector), - } -} - -func (d *SwarmManager) GetAvailableResources(*ports.SSHConfig) ([]string, error) { - if d.Config == nil { - return []string{}, errors.New("Can not get available resources - config is not loaded") - } - - dockerListCmd := "docker service ls --format json" - output, err := d.SSHConnector.RunCommand(dockerListCmd) - if err != nil { - return nil, err - } - - return services.GetResourcesFromJsonOutput(output) -} - -func (d *SwarmManager) GetAvailableActionTemplates() map[string]string { - return services.GetActionTemplates() -} diff --git a/internal/modules/docker/swarm/services/getactiontemplates.go b/internal/modules/docker/swarm/services/getactiontemplates.go deleted file mode 100644 index e02edd2..0000000 --- a/internal/modules/docker/swarm/services/getactiontemplates.go +++ /dev/null @@ -1,21 +0,0 @@ -package services - -const continueMsg = "echo 'Done, press Enter to continue...' && read" - -func GetActionTemplates() map[string]string { - return map[string]string{ - "swarm -- ls": "watch docker service ls", - "swarm -- ps ": "watch docker service ps %s --no-trunc", - "swarm -- ps (only running)": `watch 'docker service ps --filter desired-state=running --format "{{.ID}} {{.Name}} - {{.Node}} | {{.Image}}" %s'`, - "swarm -- inspect ": "docker service inspect %s | jq | less", - "swarm -- restart ": "docker service update %s --force && " + continueMsg, - "swarm -- logs -f ": "docker service logs -f %s", - "swarm -- logs | less": "docker service logs %s | less", - "swarm -- scale replicas to 0": "docker service scale %s=0 && " + continueMsg, - "swarm -- scale replicas to 1": "docker service scale %s=1 && " + continueMsg, - "swarm -- scale replicas to 3": "docker service scale %s=3 && " + continueMsg, - "bash -- bash": "bash", - "bash -- htop": "htop", - // "[swarm] remove ": "docker service remove %s && " + continueMsg, - } -} diff --git a/internal/modules/docker/swarm/services/getresourcers.go b/internal/modules/docker/swarm/services/getresourcers.go deleted file mode 100644 index 88f4b0a..0000000 --- a/internal/modules/docker/swarm/services/getresourcers.go +++ /dev/null @@ -1,35 +0,0 @@ -package services - -import ( - "encoding/json" - "strings" -) - -type serviceDTO struct { - ID string `json:"ID"` - Image string `json:"Image"` - Mode string `json:"Mode"` - Name string `json:"Name"` - Ports string `json:"Ports"` - Replicas string `json:"Replicas"` -} - -func GetResourcesFromJsonOutput(output string) ([]string, error) { - - var services []serviceDTO - - lines := strings.SplitSeq(strings.TrimSpace(output), "\n") - for line := range lines { - var service serviceDTO - if err := json.Unmarshal([]byte(line), &service); err != nil { - continue - } - services = append(services, service) - } - - var serviceNames []string - for _, service := range services { - serviceNames = append(serviceNames, service.Name) - } - return serviceNames, nil -} diff --git a/internal/modules/docker/swarm/services/getresourcers_test.go b/internal/modules/docker/swarm/services/getresourcers_test.go deleted file mode 100644 index c0b38a3..0000000 --- a/internal/modules/docker/swarm/services/getresourcers_test.go +++ /dev/null @@ -1,18 +0,0 @@ -package services_test - -import ( - "testing" - - "github.com/goodylabs/tug/internal/modules/docker/swarm/services" - "github.com/stretchr/testify/assert" -) - -func TestGetResourcesFromJsonOutput(t *testing.T) { - output := ` - {"ID":"vkxz3t57kouf","Image":"traefik:v3.0","Mode":"global","Name":"traefik_traefik","Ports":"","Replicas":"3/3"} - {"ID":"vzlr2sm6403z","Image":"ir.goodylabs.com/other/test:6574839","Mode":"replicated","Name":"web_app","Ports":"","Replicas":"3/3 (max 1 per node)"} - ` - serviceNames, err := services.GetResourcesFromJsonOutput(output) - assert.NoError(t, err) - assert.Equal(t, []string{"traefik_traefik", "web_app"}, serviceNames) -} diff --git a/internal/modules/loadproject/load_project.go b/internal/modules/loadproject/load_project.go new file mode 100644 index 0000000..ac1521d --- /dev/null +++ b/internal/modules/loadproject/load_project.go @@ -0,0 +1,41 @@ +package loadproject + +import ( + "fmt" + + "github.com/goodylabs/tug/internal/modules" +) + +type StrategyName string + +const ( + DockerStrategy StrategyName = "docker" + Pm2Strategy StrategyName = "pm2" + PystranoStrategy StrategyName = "pystrano" + InputStrategy StrategyName = "input" +) + +type LoadProject struct{} + +func NewLoadProject() *LoadProject { + return &LoadProject{} +} + +func (lp *LoadProject) Execute(strategyName StrategyName) (modules.ProjectConfig, error) { + var strategy modules.LoadStrategy + + switch strategyName { + case DockerStrategy: + strategy = NewDockerLoadStrategy() + case Pm2Strategy: + strategy = NewPm2LoadStrategy() + case PystranoStrategy: + strategy = NewPystranoLoadStrategy() + case InputStrategy: + strategy = NewInputLoadStrategy() + default: + return modules.ProjectConfig{}, fmt.Errorf("unsupported strategy: %s", strategyName) + } + + return strategy.Execute() +} diff --git a/internal/modules/loadproject/strategy_docker.go b/internal/modules/loadproject/strategy_docker.go new file mode 100644 index 0000000..3590cba --- /dev/null +++ b/internal/modules/loadproject/strategy_docker.go @@ -0,0 +1,122 @@ +package loadproject + +import ( + "bufio" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/goodylabs/tug/internal/modules" + "github.com/goodylabs/tug/pkg/config" + "github.com/goodylabs/tug/pkg/utils" +) + +const ( + DEVOPS_DIR = "devops" + DEPLOY_FILE = "deploy.sh" + TARGET_IP_VAR = "TARGET_IP" + IP_ADDRESS_VAR = "IP_ADDRESS" + IP_ADDRESSES_VAR = "IP_ADDRESSES" +) + +type DockerLoadStrategy struct{} + +func NewDockerLoadStrategy() *DockerLoadStrategy { + return &DockerLoadStrategy{} +} + +func (s *DockerLoadStrategy) Execute() (modules.ProjectConfig, error) { + projectCfg := modules.ProjectConfig{ + Config: make(map[string]modules.EnvConfig), + } + + baseDir := config.GetBaseDir() + devopsDirPath := filepath.Join(baseDir, DEVOPS_DIR) + + envs, err := utils.ListDirsOnPath(devopsDirPath) + if err != nil { + return projectCfg, fmt.Errorf("docker strategy: cannot read dirs: %w", err) + } + + for _, env := range envs { + scriptPath := filepath.Join(devopsDirPath, env, DEPLOY_FILE) + hosts := s.parseHosts(scriptPath) + + if len(hosts) > 0 { + projectCfg.Config[env] = modules.EnvConfig{ + Name: env, + User: "root", + Hosts: hosts, + } + } + } + + if len(projectCfg.Config) == 0 { + return projectCfg, fmt.Errorf("no valid docker configuration in %s", devopsDirPath) + } + + return projectCfg, nil +} + +func (s *DockerLoadStrategy) parseHosts(path string) []string { + if hosts := s.getMultipleIps(path, IP_ADDRESSES_VAR); len(hosts) > 0 { + return hosts + } + if host := s.getSingleVar(path, IP_ADDRESS_VAR); host != "" { + return []string{host} + } + if host := s.getSingleVar(path, TARGET_IP_VAR); host != "" { + return []string{host} + } + return nil +} + +func (s *DockerLoadStrategy) getSingleVar(path, key string) string { + file, err := os.Open(path) + if err != nil { + return "" + } + defer file.Close() + + scanner := bufio.NewScanner(file) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if after, ok := strings.CutPrefix(line, key+"="); ok { + val := after + return strings.Trim(val, `"' `+"`") + } + } + return "" +} + +func (s *DockerLoadStrategy) getMultipleIps(path, key string) []string { + file, err := os.Open(path) + if err != nil { + return nil + } + defer file.Close() + + scanner := bufio.NewScanner(file) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if !strings.HasPrefix(line, key+"=") { + continue + } + + raw := strings.TrimPrefix(line, key+"=") + cleanRaw := strings.Trim(raw, `()"' `+"`") + + parts := strings.Fields(cleanRaw) + + var result []string + for _, part := range parts { + cleanPart := strings.Trim(part, `"'`+"`") + if cleanPart != "" { + result = append(result, cleanPart) + } + } + return result + } + return nil +} diff --git a/internal/modules/loadproject/strategy_docker_test.go b/internal/modules/loadproject/strategy_docker_test.go new file mode 100644 index 0000000..5115b03 --- /dev/null +++ b/internal/modules/loadproject/strategy_docker_test.go @@ -0,0 +1,59 @@ +package loadproject_test + +import ( + "testing" + + "github.com/goodylabs/tug/internal/modules/loadproject" + "github.com/stretchr/testify/assert" +) + +func TestDockerLoadStrategy_Legacy(t *testing.T) { + strategy := loadproject.NewDockerLoadStrategy() + + t.Run("Check ListEnvs logic via Execute", func(t *testing.T) { + cfg, err := strategy.Execute() + + assert.NoError(t, err) + assert.Contains(t, cfg.Config, "localhost") + assert.Contains(t, cfg.Config, "production") + }) + + t.Run("IP_ADDRESS variable logic", func(t *testing.T) { + cfg, err := strategy.Execute() + assert.NoError(t, err) + + expectedHost := "unix:///var/run/docker.sock" + assert.Equal(t, []string{expectedHost}, cfg.Config["localhost"].Hosts) + }) + + t.Run("TARGET_IP variable logic", func(t *testing.T) { + cfg, err := strategy.Execute() + assert.NoError(t, err) + + expectedHost := "" + assert.Equal(t, []string{expectedHost}, cfg.Config["production"].Hosts) + }) + + t.Run("Multiple IPs logic", func(t *testing.T) { + cfg, err := strategy.Execute() + assert.NoError(t, err) + + expectedHosts := []string{"ip_1", "ip_2", "ip_3"} + assert.Equal(t, expectedHosts, cfg.Config["production_v2"].Hosts) + }) +} + +func TestLoadProject_Selector(t *testing.T) { + lp := loadproject.NewLoadProject() + + t.Run("Execute with Docker Strategy", func(t *testing.T) { + cfg, err := lp.Execute(loadproject.DockerStrategy) + assert.NoError(t, err) + assert.NotEmpty(t, cfg.Config) + }) + + t.Run("Execute with unsupported strategy", func(t *testing.T) { + _, err := lp.Execute("non-existent") + assert.ErrorContains(t, err, "unsupported strategy") + }) +} diff --git a/internal/modules/loadproject/strategy_input.go b/internal/modules/loadproject/strategy_input.go new file mode 100644 index 0000000..6d0899b --- /dev/null +++ b/internal/modules/loadproject/strategy_input.go @@ -0,0 +1,49 @@ +package loadproject + +import ( + "fmt" + + "github.com/goodylabs/tug/internal/adapters" + "github.com/goodylabs/tug/internal/modules" + "github.com/goodylabs/tug/internal/ports" +) + +type InputLoadStrategy struct { + prompter ports.Prompter +} + +func NewInputLoadStrategy() *InputLoadStrategy { + return &InputLoadStrategy{ + prompter: adapters.NewPrompter(), + } +} + +func (s *InputLoadStrategy) Execute() (modules.ProjectConfig, error) { + host, err := s.prompter.AskUserForInput("Enter IP address") + if err != nil { + return modules.ProjectConfig{}, err + } + + defaultUser := "root" + userPrompt := fmt.Sprintf("Enter username (default: %s)", defaultUser) + user, err := s.prompter.AskUserForInput(userPrompt) + if err != nil { + return modules.ProjectConfig{}, err + } + + if user == "" { + user = defaultUser + } + + defaultEnv := map[string]modules.EnvConfig{ + "default": { + User: user, + Hosts: []string{ + host, + }}, + } + + return modules.ProjectConfig{ + Config: defaultEnv, + }, nil +} diff --git a/internal/modules/loadproject/strategy_pm2.go b/internal/modules/loadproject/strategy_pm2.go new file mode 100644 index 0000000..7918e45 --- /dev/null +++ b/internal/modules/loadproject/strategy_pm2.go @@ -0,0 +1,120 @@ +package loadproject + +import ( + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + + "github.com/goodylabs/tug/internal/modules" + "github.com/goodylabs/tug/pkg/config" +) + +const ( + tmpJsonPath = "/tmp/tug_pm2_config.json" + ecosystemJsScript = `const fs = require('fs'); const config = require('%s'); fs.writeFileSync('%s', JSON.stringify(config));` + ecosystemCjsScript = `const fs = require('fs'); const config = require('%s'); fs.writeFileSync('%s', JSON.stringify(config));` +) + +type FlexHost []string + +func (fh *FlexHost) UnmarshalJSON(data []byte) error { + if len(data) == 0 { + return nil + } + var single string + if err := json.Unmarshal(data, &single); err == nil { + *fh = []string{single} + return nil + } + var slice []string + if err := json.Unmarshal(data, &slice); err != nil { + return err + } + *fh = slice + return nil +} + +type pm2ConfigDTO struct { + Deploy map[string]struct { + User string `json:"user"` + Host FlexHost `json:"host"` + } `json:"deploy"` +} + +type Pm2LoadStrategy struct{} + +func NewPm2LoadStrategy() *Pm2LoadStrategy { + return &Pm2LoadStrategy{} +} + +func (s *Pm2LoadStrategy) Execute() (modules.ProjectConfig, error) { + projectCfg := modules.ProjectConfig{ + Config: make(map[string]modules.EnvConfig), + } + + configPath, err := s.getPm2ConfigPath(config.GetBaseDir()) + if err != nil { + return projectCfg, err + } + + if err := s.convertJsFileToJson(configPath); err != nil { + return projectCfg, err + } + defer os.Remove(tmpJsonPath) + + jsonFile, err := os.ReadFile(tmpJsonPath) + if err != nil { + return projectCfg, fmt.Errorf("failed to read temp PM2 json: %w", err) + } + + var dto pm2ConfigDTO + if err := json.Unmarshal(jsonFile, &dto); err != nil { + return projectCfg, fmt.Errorf("failed to unmarshal PM2 config: %w", err) + } + + for envName, deployCfg := range dto.Deploy { + if deployCfg.User == "" || len(deployCfg.Host) == 0 { + continue + } + + projectCfg.Config[envName] = modules.EnvConfig{ + Name: envName, + User: deployCfg.User, + Hosts: deployCfg.Host, + } + } + + return projectCfg, nil +} + +func (s *Pm2LoadStrategy) getPm2ConfigPath(dir string) (string, error) { + options := []string{"ecosystem.config.cjs", "ecosystem.config.js"} + for _, name := range options { + path := filepath.Join(dir, name) + if _, err := os.Stat(path); err == nil { + return path, nil + } + } + return "", fmt.Errorf("ecosystem config file not found in %s", dir) +} + +func (s *Pm2LoadStrategy) convertJsFileToJson(path string) error { + var nodeScript string + switch { + case strings.HasSuffix(path, ".js"): + nodeScript = fmt.Sprintf(ecosystemJsScript, path, tmpJsonPath) + case strings.HasSuffix(path, ".cjs"): + nodeScript = fmt.Sprintf(ecosystemCjsScript, path, tmpJsonPath) + default: + return fmt.Errorf("unsupported file extension: %s", path) + } + + cmd := exec.Command("node", "-e", nodeScript) + if output, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("node conversion failed: %w (output: %s)", err, string(output)) + } + return nil +} diff --git a/internal/modules/loadproject/strategy_pm2_test.go b/internal/modules/loadproject/strategy_pm2_test.go new file mode 100644 index 0000000..1ad0945 --- /dev/null +++ b/internal/modules/loadproject/strategy_pm2_test.go @@ -0,0 +1,33 @@ +package loadproject_test + +import ( + "os" + "strings" + "testing" + + "github.com/goodylabs/tug/internal/modules/loadproject" + "github.com/stretchr/testify/assert" +) + +func TestPm2LoadStrategy_Execute(t *testing.T) { + testFileName := "ecosystem.config.js" + content := []byte(`module.exports = { deploy: { staging: { user: 'staging-user', host: ['1.2.3.4'] } } };`) + + lp := loadproject.NewLoadProject() + + _ = os.WriteFile(testFileName, content, 0644) + defer os.Remove(testFileName) + + t.Run("successful load via LoadProject factory", func(t *testing.T) { + cfg, err := lp.Execute(loadproject.Pm2Strategy) + + if err != nil && strings.Contains(err.Error(), "not found") { + t.Errorf("Error: %s", err.Error()) + } + + assert.NoError(t, err) + assert.Contains(t, cfg.Config, "staging") + assert.Equal(t, "staging-user", cfg.Config["staging"].User) + assert.Equal(t, []string{"xxx.xxx.xxx.xxx"}, cfg.Config["staging"].Hosts) + }) +} diff --git a/internal/modules/loadproject/strategy_pystrano.go b/internal/modules/loadproject/strategy_pystrano.go new file mode 100644 index 0000000..1d2fd6d --- /dev/null +++ b/internal/modules/loadproject/strategy_pystrano.go @@ -0,0 +1,102 @@ +package loadproject + +import ( + "fmt" + "os" + "path/filepath" + "sort" + + "github.com/goodylabs/tug/internal/modules" + "github.com/goodylabs/tug/pkg/config" + "github.com/goodylabs/tug/pkg/utils" +) + +type PystranoLoadStrategy struct{} + +func NewPystranoLoadStrategy() *PystranoLoadStrategy { + return &PystranoLoadStrategy{} +} + +func (s *PystranoLoadStrategy) Execute() (modules.ProjectConfig, error) { + projectCfg := modules.ProjectConfig{ + Config: make(map[string]modules.EnvConfig), + } + + pystranoDir := filepath.Join(config.GetBaseDir(), "deploy") + + deploymentFiles, err := s.findDeploymentYAML(pystranoDir) + if err != nil { + return projectCfg, fmt.Errorf("pystrano strategy: %w", err) + } + + if len(deploymentFiles) == 0 { + return projectCfg, fmt.Errorf("no pystrano config files found in 'deploy' directory") + } + + sort.Strings(deploymentFiles) + + for _, relPath := range deploymentFiles { + fullPath := filepath.Join(pystranoDir, relPath) + + hosts, err := s.retrieveHosts(fullPath) + if err != nil { + continue + } + + if len(hosts) > 0 { + envName := filepath.Dir(relPath) + + projectCfg.Config[envName] = modules.EnvConfig{ + Name: envName, + User: "root", + Hosts: hosts, + } + } + } + + return projectCfg, nil +} + +func (s *PystranoLoadStrategy) findDeploymentYAML(baseDir string) ([]string, error) { + var result []string + + err := filepath.WalkDir(baseDir, func(path string, d os.DirEntry, err error) error { + if err != nil { + return err + } + if !d.IsDir() { + base := filepath.Base(path) + if base == "deployment.yml" || base == "deployment.yaml" { + rel, err := filepath.Rel(baseDir, path) + if err != nil { + return err + } + result = append(result, rel) + } + } + return nil + }) + + return result, err +} + +type pystranoConfigFile struct { + Servers []struct { + Host string `yaml:"host"` + } `yaml:"servers"` +} + +func (s *PystranoLoadStrategy) retrieveHosts(filePath string) ([]string, error) { + var cfg pystranoConfigFile + if err := utils.ReadYAML(filePath, &cfg); err != nil { + return nil, err + } + + var hosts []string + for _, server := range cfg.Servers { + if server.Host != "" { + hosts = append(hosts, server.Host) + } + } + return hosts, nil +} diff --git a/internal/modules/loadproject/strategy_pystrano_test.go b/internal/modules/loadproject/strategy_pystrano_test.go new file mode 100644 index 0000000..1bc30aa --- /dev/null +++ b/internal/modules/loadproject/strategy_pystrano_test.go @@ -0,0 +1,34 @@ +package loadproject_test + +import ( + "path/filepath" + "testing" + + "github.com/goodylabs/tug/internal/modules/loadproject" + "github.com/stretchr/testify/assert" +) + +func TestPystranoLoadStrategy_Execute(t *testing.T) { + lp := loadproject.NewLoadProject() + + t.Run("successful load pystrano environments", func(t *testing.T) { + cfg, err := lp.Execute(loadproject.PystranoStrategy) + + assert.NoError(t, err) + assert.NotEmpty(t, cfg.Config) + + cmsProdKey := filepath.Join("cms", "production") + if env, ok := cfg.Config[cmsProdKey]; ok { + assert.Len(t, env.Hosts, 4) + assert.Equal(t, "c_p_ipv4_nr_2", env.Hosts[1]) + } else { + t.Errorf("Environment %s not found in config", cmsProdKey) + } + + schedulerStagingKey := filepath.Join("scheduler", "staging") + if env, ok := cfg.Config[schedulerStagingKey]; ok { + assert.Len(t, env.Hosts, 1) + assert.Equal(t, "s_s_ipv4_nr_1", env.Hosts[0]) + } + }) +} diff --git a/internal/modules/models.go b/internal/modules/models.go new file mode 100644 index 0000000..dbe4885 --- /dev/null +++ b/internal/modules/models.go @@ -0,0 +1,54 @@ +package modules + +import ( + "fmt" + "slices" + "sort" +) + +type LoadStrategy interface { + Execute() (ProjectConfig, error) +} + +type EnvConfig struct { + Name string + User string + Hosts []string +} + +type ProjectConfig struct { + Config map[string]EnvConfig +} + +func (pc *ProjectConfig) GetAvailableEnvs() []string { + envs := make([]string, 0, len(pc.Config)) + for env := range pc.Config { + envs = append(envs, env) + } + sort.Strings(envs) + return envs +} + +func (pc *ProjectConfig) GetAvailableHosts(env string) ([]string, error) { + cfg, ok := pc.Config[env] + if !ok { + return nil, fmt.Errorf("environment not found: %s", env) + } + return cfg.Hosts, nil +} + +func (pc *ProjectConfig) GetEnvConfig(env string) (EnvConfig, error) { + cfg, ok := pc.Config[env] + if !ok { + return EnvConfig{}, fmt.Errorf("environment not found: %s", env) + } + return cfg, nil +} + +func (pc *ProjectConfig) IsHostInEnv(env, host string) bool { + cfg, ok := pc.Config[env] + if !ok { + return false + } + return slices.Contains(cfg.Hosts, host) +} diff --git a/internal/modules/pm2/api.go b/internal/modules/pm2/api.go deleted file mode 100644 index e02b3c3..0000000 --- a/internal/modules/pm2/api.go +++ /dev/null @@ -1,111 +0,0 @@ -package pm2 - -import ( - "encoding/json" - "errors" - "os" - - "github.com/goodylabs/tug/internal/ports" - "github.com/goodylabs/tug/pkg/config" -) - -type Pm2Handler struct { - config *pm2ConfigDTO - sshConnector ports.SSHConnector -} - -func NewPm2Handler(sshConnector ports.SSHConnector) ports.TechnologyHandler { - return &Pm2Handler{ - sshConnector: sshConnector, - } -} - -func (p *Pm2Handler) LoadConfigFromFile() error { - if p.config != nil { - return errors.New("Can not load config - it is already loaded") - } - - configPath, err := GetPm2ConfigPath(config.GetBaseDir()) - if err != nil { - return err - } - - if err := ConvertJsFileToJson(configPath); err != nil { - return err - } - - jsonFile, err := os.ReadFile(tmpJsonPath) - if err != nil { - return err - } - - if err := json.Unmarshal(jsonFile, &p.config); err != nil { - return errors.New("failed to unmarshal PM2 config: " + err.Error()) - } - - if len(p.config.Deploy) == 0 { - return errors.New("PM2 config is missing 'deploy' environments") - } - - for env, cfg := range p.config.Deploy { - if cfg.User == "" { - return errors.New("missing user for environment: " + env) - } - if len(cfg.Host) == 0 { - return errors.New("no hosts defined for environment: " + env) - } - } - - return nil -} - -func (p *Pm2Handler) GetAvailableEnvs() ([]string, error) { - if p.config == nil { - return []string{}, errors.New("Can not get available environments - config is not loaded") - } - - return p.config.ListEnvironments(), nil -} - -func (p *Pm2Handler) GetAvailableHosts(env string) ([]string, error) { - if p.config == nil { - return nil, errors.New("Can not get available hosts - config is not loaded") - } - - return p.config.ListHostsInEnv(env), nil -} - -func (p *Pm2Handler) GetSSHConfig(env, host string) (*ports.SSHConfig, error) { - if p.config == nil { - return nil, errors.New("Can not get SSH config - PM2 config is not loaded") - } - - return &ports.SSHConfig{ - User: p.config.Deploy[env].User, - Host: host, - Port: 22, - }, nil -} - -func (p *Pm2Handler) GetAvailableResources(sshConfig *ports.SSHConfig) ([]string, error) { - output, err := p.sshConnector.RunCommand(jlistCmd) - if err != nil { - return nil, err - } - - var pm2List []pm2ListItemDTO - if err := json.Unmarshal([]byte(output), &pm2List); err != nil { - return nil, errors.New("failed to parse PM2 list output: " + err.Error()) - } - - var resources []string - for _, item := range pm2List { - resources = append(resources, item.Name) - } - - return resources, nil -} - -func (p *Pm2Handler) GetAvailableActionTemplates() map[string]string { - return commandTemplates -} diff --git a/internal/modules/pm2/api_test.go b/internal/modules/pm2/api_test.go deleted file mode 100644 index 61a5f4c..0000000 --- a/internal/modules/pm2/api_test.go +++ /dev/null @@ -1,87 +0,0 @@ -package pm2_test - -import ( - "testing" - - "github.com/goodylabs/tug/internal/modules/pm2" - "github.com/goodylabs/tug/tests/mocks" - "github.com/stretchr/testify/assert" -) - -func TestLoadConfigFromFile(t *testing.T) { - pm2Handler := pm2.NewPm2Handler(nil) - - err := pm2Handler.LoadConfigFromFile() - assert.NoError(t, err) - - err = pm2Handler.LoadConfigFromFile() - assert.Error(t, err) -} - -func GetAvailableEnvs(t *testing.T) { - pm2Handler := pm2.NewPm2Handler(nil) - pm2Handler.LoadConfigFromFile() - - envs, err := pm2Handler.GetAvailableEnvs() - assert.NoError(t, err) - assert.Len(t, envs, 6) -} - -func TestGetAvailableEnvs(t *testing.T) { - pm2Handler := pm2.NewPm2Handler(nil) - pm2Handler.LoadConfigFromFile() - - envs := []string{"staging", "production_RO_2", "production_2"} - expected := []int{1, 3, 2} - - for i, env := range envs { - hosts, err := pm2Handler.GetAvailableHosts(env) - assert.NoError(t, err) - assert.Len(t, hosts, expected[i]) - } -} - -func TestGetSSHConfig(t *testing.T) { - pm2Handler := pm2.NewPm2Handler(nil) - pm2Handler.LoadConfigFromFile() - - sshConfig, err := pm2Handler.GetSSHConfig("staging", "xxx.xxx.xxx.xxx") - assert.NoError(t, err) - assert.Equal(t, sshConfig.Host, "xxx.xxx.xxx.xxx") - assert.Equal(t, sshConfig.User, "staging-user") - assert.Equal(t, sshConfig.Port, 22) - - sshConfig, err = pm2Handler.GetSSHConfig("production_RO_2", "ddd.ddd.ddd.ddd") - assert.NoError(t, err) - assert.Equal(t, sshConfig.Host, "ddd.ddd.ddd.ddd") - assert.Equal(t, sshConfig.User, "root") - assert.Equal(t, sshConfig.Port, 22) -} - -func TestGetAvailableResources(t *testing.T) { - - t.Run("invalid output", func(t *testing.T) { - pm2Handler := pm2.NewPm2Handler( - mocks.NewSSHConnectorMock(invalidOutput1, nil), - ) - pm2Handler.LoadConfigFromFile() - - _, err := pm2Handler.GetAvailableResources(nil) - assert.ErrorContains(t, err, "failed to parse PM2 list output") - }) - - t.Run("valid output", func(t *testing.T) { - pm2Handler := pm2.NewPm2Handler( - mocks.NewSSHConnectorMock(output1, nil), - ) - pm2Handler.LoadConfigFromFile() - - resources, err := pm2Handler.GetAvailableResources(nil) - assert.NoError(t, err) - assert.Len(t, resources, 3) - assert.Equal(t, resources[0], "pm2-logrotate") - assert.Equal(t, resources[1], "staging_ro") - assert.Equal(t, resources[2], "api-staging") - }) - -} diff --git a/internal/modules/pm2/commands.go b/internal/modules/pm2/commands.go deleted file mode 100644 index d26c1d3..0000000 --- a/internal/modules/pm2/commands.go +++ /dev/null @@ -1,19 +0,0 @@ -package pm2 - -const ( - jlistCmd = `source ~/.nvm/nvm.sh; pm2 jlist | sed -n '/^\[/,$p'` -) - -var commandTemplates = map[string]string{ - "pm2 -- logs | less": `source ~/.nvm/nvm.sh; pm2 logs %s | less`, - "pm2 -- logs ": `source ~/.nvm/nvm.sh; pm2 logs %s`, - "pm2 -- logs": `source ~/.nvm/nvm.sh; pm2 logs`, - "pm2 -- status | less": `source ~/.nvm/nvm.sh; pm2 status %s | less`, - "pm2 -- show ": `source ~/.nvm/nvm.sh; pm2 show %s && read`, - "pm2 -- restart ": `source ~/.nvm/nvm.sh; pm2 restart %s`, - "pm2 -- describe ": `source ~/.nvm/nvm.sh; pm2 describe %s && read`, - "pm2 -- monit": `source ~/.nvm/nvm.sh; pm2 monit`, - "pm2 -- update": `source ~/.nvm/nvm.sh; pm2 update`, - "bash -- bash": `source ~/.nvm/nvm.sh; bash`, - "bash -- htop": `source ~/.nvm/nvm.sh; htop`, -} diff --git a/internal/modules/pm2/constants.go b/internal/modules/pm2/constants.go deleted file mode 100644 index 72bfdd2..0000000 --- a/internal/modules/pm2/constants.go +++ /dev/null @@ -1,33 +0,0 @@ -package pm2 - -const tmpJsonPath = "/tmp/ecosystem.json" - -const ecosystemJsScript = `const fs = require("fs"); -const config = require("%s"); - -if (config.deploy) { - for (const key in config.deploy) { - const deployEntry = config.deploy[key]; - if (typeof deployEntry.host === "string") { - deployEntry.host = [deployEntry.host]; - } - } -} - -fs.writeFileSync("%s", JSON.stringify(config, null, 2));` - -const ecosystemCjsScript = `import config from "%s"; - -const { default: ecosystemConfig } = await import("%s"); - -if (ecosystemConfig.deploy) { - for (const key in ecosystemConfig.deploy) { - const deployEntry = ecosystemConfig.deploy[key]; - if (typeof deployEntry.host === "string") { - deployEntry.host = [deployEntry.host]; - } - } -} - -const fs = await import("fs/promises"); -await fs.writeFile("%s", JSON.stringify(ecosystemConfig, null, 2));` diff --git a/internal/modules/pm2/dtos.go b/internal/modules/pm2/dtos.go deleted file mode 100644 index 960f766..0000000 --- a/internal/modules/pm2/dtos.go +++ /dev/null @@ -1,31 +0,0 @@ -package pm2 - -type pm2ConfigDTO struct { - Apps []struct { - Name string `json:"name"` - } `json:"apps"` - Deploy map[string]struct { - User string `json:"user"` - Host []string `json:"host"` - } `json:"deploy"` -} - -func (e *pm2ConfigDTO) ListEnvironments() []string { - envs := make([]string, 0, len(e.Deploy)) - for env := range e.Deploy { - envs = append(envs, env) - } - return envs -} - -func (e *pm2ConfigDTO) ListHostsInEnv(env string) []string { - hosts := []string{} - for host := range e.Deploy[env].Host { - hosts = append(hosts, e.Deploy[env].Host[host]) - } - return hosts -} - -type pm2ListItemDTO struct { - Name string `json:"name"` -} diff --git a/internal/modules/pm2/helpers.go b/internal/modules/pm2/helpers.go deleted file mode 100644 index 39e5d38..0000000 --- a/internal/modules/pm2/helpers.go +++ /dev/null @@ -1,40 +0,0 @@ -package pm2 - -import ( - "fmt" - "os" - "os/exec" - "path/filepath" - "strings" - - "github.com/goodylabs/tug/pkg/config" -) - -func GetPm2ConfigPath(dir string) (string, error) { - options := []string{"ecosystem.config.cjs", "ecosystem.config.js"} - for _, name := range options { - path := filepath.Join(dir, name) - if _, err := os.Stat(path); err == nil { - return path, nil - } - } - return "", fmt.Errorf("ecosystem.config.cjs/js file not found in %s", config.GetBaseDir()) -} - -func ConvertJsFileToJson(path string) error { - var nodeScript string - - switch { - case strings.HasSuffix(path, ".js"): - nodeScript = fmt.Sprintf(ecosystemJsScript, path, tmpJsonPath) - case strings.HasSuffix(path, ".cjs"): - nodeScript = fmt.Sprintf(ecosystemCjsScript, path, path, tmpJsonPath) - default: - return fmt.Errorf("unsupported ecosystem.config.cjs/js file type: %s", path) - } - - if err := exec.Command("node", "-e", nodeScript).Run(); err != nil { - return fmt.Errorf("failed to convert ecosystem.config.cjs/js file to JSON: %w", err) - } - return nil -} diff --git a/internal/modules/pm2/helpers_test.go b/internal/modules/pm2/helpers_test.go deleted file mode 100644 index e77e3af..0000000 --- a/internal/modules/pm2/helpers_test.go +++ /dev/null @@ -1,34 +0,0 @@ -package pm2_test - -import ( - "testing" - - "github.com/goodylabs/tug/internal/modules/pm2" - "github.com/goodylabs/tug/pkg/config" - "github.com/stretchr/testify/assert" -) - -const output1 = `[{ "name": "pm2-logrotate", "pid": 3105},{ "name": "staging_ro", "pid": 4043331},{ "name": "api-staging", "pid": 4041884}]` - -const invalidOutput1 = `>>>> In-memory PM2 is out-of-date, do: ->>>> $ pm2 update -In memory PM2 version: 5.2.2 -Local PM2 version: 6.0.8 - -[{ "name": "pm2-logrotate", "pid": 3105},{ "name": "staging_ro", "pid": 4043331},{ "name": "api-staging", "pid": 4041884}] -` - -func TestGetPm2ConfigPath(t *testing.T) { - t.Run("ecosystem.config.js exists", func(t *testing.T) { - path, err := pm2.GetPm2ConfigPath(config.GetBaseDir()) - assert.Contains(t, path, "ecosystem.config.js") - assert.NoError(t, err) - }) - - t.Run("none of ecosystem.config.* exists", func(t *testing.T) { - path, err := pm2.GetPm2ConfigPath("non-existing-dir") - assert.Equal(t, "", path) - assert.ErrorContains(t, err, "file not found") - }) - -} diff --git a/internal/modules/pystrano/api.go b/internal/modules/pystrano/api.go deleted file mode 100644 index e218855..0000000 --- a/internal/modules/pystrano/api.go +++ /dev/null @@ -1,114 +0,0 @@ -package pystrano - -import ( - "fmt" - "path/filepath" - - "github.com/goodylabs/tug/internal/modules/pystrano/services" - "github.com/goodylabs/tug/internal/ports" - "github.com/goodylabs/tug/pkg/config" -) - -type envCfg map[string][]string - -type PystranoManager struct { - sshconnector ports.SSHConnector - config envCfg -} - -func NewPystranoManager(sshConnector ports.SSHConnector) ports.TechnologyHandler { - return &PystranoManager{ - sshconnector: sshConnector, - config: make(envCfg), - } -} - -func (p *PystranoManager) LoadConfigFromFile() error { - pystranoDir := filepath.Join(config.GetBaseDir(), "deploy") - - deploymentFiles, err := services.FindDeploymentYAML(pystranoDir) - if err != nil { - return err - } - if len(deploymentFiles) == 0 { - return fmt.Errorf("no pystrano config files found in 'deploy' directory") - } - - for _, file := range deploymentFiles { - deployFilePath := filepath.Join(pystranoDir, file) - hosts, err := services.RetrieveHostsFromConfigFile(deployFilePath) - if err != nil { - continue - } - if len(hosts) == 0 { - continue - } - p.config[file] = hosts - } - - if len(p.config) == 0 { - return fmt.Errorf("could not load any valid pystrano config files from 'deploy' directory") - } - - return nil -} - -func (p *PystranoManager) GetAvailableEnvs() ([]string, error) { - var envs []string - for env, _ := range p.config { - envs = append(envs, env) - } - return envs, nil -} - -func (p *PystranoManager) GetAvailableHosts(env string) ([]string, error) { - hosts, exists := p.config[env] - if !exists { - return []string{}, fmt.Errorf("no such environment: %s", env) - } - return hosts, nil -} - -func (p *PystranoManager) GetSSHConfig(env, host string) (*ports.SSHConfig, error) { - hosts, exists := p.config[env] - if !exists { - return nil, fmt.Errorf("no such environment: %s", env) - } - - for _, h := range hosts { - if h == host { - return &ports.SSHConfig{ - Host: h, - Port: 22, - User: "root", - }, nil - } - } - return nil, fmt.Errorf("no such host: %s in environment: %s", host, env) -} - -func (p *PystranoManager) GetAvailableResources(sshConfig *ports.SSHConfig) ([]string, error) { - return []string{}, fmt.Errorf("func GetAvailableResources not implemented") -} - -func (p *PystranoManager) GetAvailableActionTemplates() map[string]string { - return services.GetActionTemplates() -} - -// func (d *PystranoManager) GetAvailableResources(*ports.SSHConfig) ([]string, error) { -// if d.Config == nil { -// return []string{}, errors.New("Can not get available resources - config is not loaded") -// } - -// pystranoListCmd := "pystrano ps --format json" -// output, err := d.SSHConnector.RunCommand(pystranoListCmd) -// if err != nil { -// return nil, err -// } - -// return services.GetResourcesFromJsonOutput(output) -// } - -// func (d *PystranoManager) GetAvailableActionTemplates() map[string]string { -// return services.GetActionTemplates() -// } diff --git a/internal/modules/pystrano/services/getactiontemplates.go b/internal/modules/pystrano/services/getactiontemplates.go deleted file mode 100644 index d980a03..0000000 --- a/internal/modules/pystrano/services/getactiontemplates.go +++ /dev/null @@ -1,13 +0,0 @@ -package services - -const continueMsg = "echo 'Done, press Enter to continue...' && read" - -func GetActionTemplates() map[string]string { - return map[string]string{ - "bash -- bash": "bash", - "bash -- htop": "htop", - "bash -- btop": "btop", - "bash -- df -h": "df -h && " + continueMsg, - "bash -- free -h": "free -h && " + continueMsg, - } -} diff --git a/internal/modules/pystrano/services/loadconfig.go b/internal/modules/pystrano/services/loadconfig.go deleted file mode 100644 index a792d43..0000000 --- a/internal/modules/pystrano/services/loadconfig.go +++ /dev/null @@ -1,46 +0,0 @@ -package services - -import ( - "os" - "path/filepath" - - "github.com/goodylabs/tug/pkg/utils" -) - -func FindDeploymentYAML(baseDir string) ([]string, error) { - var result []string - - err := filepath.WalkDir(baseDir, func(path string, d os.DirEntry, err error) error { - if err != nil { - return err - } - if !d.IsDir() && (filepath.Base(path) == "deployment.yml" || filepath.Base(path) == "deployment.yaml") { - rel, err := filepath.Rel(baseDir, path) - if err != nil { - return err - } - result = append(result, rel) - } - return nil - }) - - return result, err -} - -type pystranoConfigFile struct { - Servers []struct { - Host string `yaml:"host"` - } `yaml:"servers"` -} - -func RetrieveHostsFromConfigFile(filePath string) ([]string, error) { - var cfg pystranoConfigFile - if err := utils.ReadYAML(filePath, &cfg); err != nil { - return nil, err - } - var hosts []string - for _, server := range cfg.Servers { - hosts = append(hosts, server.Host) - } - return hosts, nil -} diff --git a/internal/modules/pystrano/services/loadconfig_test.go b/internal/modules/pystrano/services/loadconfig_test.go deleted file mode 100644 index c88df2f..0000000 --- a/internal/modules/pystrano/services/loadconfig_test.go +++ /dev/null @@ -1,38 +0,0 @@ -package services_test - -import ( - "path/filepath" - "testing" - - "github.com/goodylabs/tug/internal/modules/pystrano/services" - "github.com/goodylabs/tug/pkg/config" - "github.com/stretchr/testify/assert" -) - -func TestFindDeploymentYAML(t *testing.T) { - pystranoDir := filepath.Join(config.GetBaseDir(), "deploy") - deployFiles, err := services.FindDeploymentYAML(pystranoDir) - assert.NoError(t, err) - assert.Len(t, deployFiles, 4) - assert.Equal(t, deployFiles[0], filepath.Join("cms", "production", "deployment.yml")) -} - -func TestRetrieveHostsFromConfigFile(t *testing.T) { - pystranoDir := filepath.Join(config.GetBaseDir(), "deploy") - - t.Run("cms production", func(t *testing.T) { - cmsProdFile := filepath.Join(pystranoDir, "cms", "production", "deployment.yml") - hosts, err := services.RetrieveHostsFromConfigFile(cmsProdFile) - assert.NoError(t, err) - assert.Len(t, hosts, 4) - assert.Equal(t, hosts[1], "c_p_ipv4_nr_2") - }) - - t.Run("scheduler staging", func(t *testing.T) { - schedulerStagingFile := filepath.Join(pystranoDir, "scheduler", "staging", "deployment.yml") - hosts, err := services.RetrieveHostsFromConfigFile(schedulerStagingFile) - assert.NoError(t, err) - assert.Len(t, hosts, 1) - assert.Equal(t, hosts[0], "s_s_ipv4_nr_1") - }) -} diff --git a/internal/ports/adapters.go b/internal/ports/adapters.go index f07b99a..d96d91a 100644 --- a/internal/ports/adapters.go +++ b/internal/ports/adapters.go @@ -9,6 +9,7 @@ type Prompter interface { ChooseFromList([]string, string) (string, error) ChooseFromMap(map[string]string, string) (string, error) ChooseFromListWithDisplayValue([]DisplayValueOpts, string) (string, error) + AskUserForInput(string) (string, error) } type SSHConnector interface { diff --git a/pkg/config/config.go b/pkg/config/config.go index 25e5176..f80b69d 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -6,17 +6,35 @@ import ( "path/filepath" ) +const ( + ModeDev = "development" + ModeTest = "testing" + ModeProd = "production" +) + +var TugEnv string = ModeProd + var ( baseDir string homeDir string ) +func GetMode() string { + switch TugEnv { + case ModeDev, ModeTest, ModeProd: + return TugEnv + default: + log.Fatalf("\n[FATAL] Invalid TugEnv value: '%s'. Allowed: development, testing, production.", TugEnv) + return "" + } +} + func GetBaseDir() string { if baseDir == "" { - tugEnv := os.Getenv("TUG_ENV") - if tugEnv == "development" || tugEnv == "testing" { + mode := GetMode() + if mode == ModeDev || mode == ModeTest { projectRoot := findProjectRoot() - baseDir = filepath.Join(projectRoot, "."+tugEnv) + baseDir = filepath.Join(projectRoot, "."+mode) } else { baseDir = getEnvOrError("PWD") } @@ -26,10 +44,10 @@ func GetBaseDir() string { func GetHomeDir() string { if homeDir == "" { - tugEnv := os.Getenv("TUG_ENV") - if tugEnv == "testing" { + mode := GetMode() + if mode == ModeTest { projectRoot := findProjectRoot() - homeDir = filepath.Join(projectRoot, "."+tugEnv) + homeDir = filepath.Join(projectRoot, "."+mode) } else { homeDir = getEnvOrError("HOME") } @@ -40,7 +58,11 @@ func GetHomeDir() string { func getEnvOrError(envName string) string { value := os.Getenv(envName) if value == "" { - log.Fatalf("Environment variable %s is not set - tug does not support your shell configuration...", envName) + if envName == "PWD" { + dir, _ := os.Getwd() + return dir + } + log.Fatalf("[FATAL] Environment variable %s is not set.", envName) } return value } @@ -53,7 +75,7 @@ func findProjectRoot() string { } parent := filepath.Dir(dir) if parent == dir { - log.Fatal("Could not find project root with go.mod file") + log.Fatal("[FATAL] Could not find project root (go.mod).") } dir = parent } diff --git a/pkg/dependecies/container.go b/pkg/dependecies/container.go deleted file mode 100644 index baf7eb6..0000000 --- a/pkg/dependecies/container.go +++ /dev/null @@ -1,46 +0,0 @@ -package dependecies - -import ( - "github.com/goodylabs/tug/internal/adapters" - "github.com/goodylabs/tug/internal/app" - "github.com/goodylabs/tug/internal/modules/docker/docker" - "github.com/goodylabs/tug/internal/modules/docker/swarm" - "github.com/goodylabs/tug/internal/modules/pm2" - "github.com/goodylabs/tug/internal/modules/pystrano" - "go.uber.org/dig" -) - -type OptFunc func(*dig.Container) - -func WithDockerHandler(container *dig.Container) { - container.Provide(docker.NewDockerManager) -} - -func WithPm2Handler(container *dig.Container) { - container.Provide(pm2.NewPm2Handler) -} - -func WithSwarmHandler(container *dig.Container) { - container.Provide(swarm.NewSwarmManager) -} - -func WithPystranoHandler(container *dig.Container) { - container.Provide(pystrano.NewPystranoManager) -} - -func InitDependencyContainer(opts ...OptFunc) *dig.Container { - container := dig.New() - - container.Provide(adapters.NewSSHConnector) - container.Provide(adapters.NewPrompter) - - for _, opt := range opts { - opt(container) - } - - container.Provide(app.NewUseModuleUseCase) - container.Provide(app.NewCheckConnectionUseCase) - container.Provide(app.NewConfigureUseCase) - - return container -} diff --git a/pkg/utils/fileutils_test.go b/pkg/utils/fileutils_test.go index e0f61e6..5bd3c2a 100644 --- a/pkg/utils/fileutils_test.go +++ b/pkg/utils/fileutils_test.go @@ -4,7 +4,6 @@ import ( "path/filepath" "testing" - "github.com/goodylabs/tug/internal/constants" "github.com/goodylabs/tug/pkg/config" "github.com/goodylabs/tug/pkg/utils" "github.com/stretchr/testify/assert" @@ -20,7 +19,7 @@ func TestGetFileLinesOk(t *testing.T) { } for _, tt := range tests { - scriptAbsPath := filepath.Join(config.GetBaseDir(), constants.DEVOPS_DIR, tt.envDir, "deploy.sh") + scriptAbsPath := filepath.Join(config.GetBaseDir(), "devops", tt.envDir, "deploy.sh") lines, err := utils.GetFileLines(scriptAbsPath) assert.Equal(t, tt.linesNumber, len(lines)) assert.NoError(t, err) diff --git a/scripts/run_dev.sh b/scripts/run_dev.sh new file mode 100755 index 0000000..e5396a6 --- /dev/null +++ b/scripts/run_dev.sh @@ -0,0 +1,3 @@ +#!/bin/bash + +go run -ldflags="-X 'github.com/goodylabs/tug/pkg/config.TugEnv=development'" main.go $@ diff --git a/scripts/run_tests.sh b/scripts/run_tests.sh index 532084a..d7be47c 100755 --- a/scripts/run_tests.sh +++ b/scripts/run_tests.sh @@ -1,5 +1,3 @@ #!/bin/bash -export TUG_ENV="testing" - -gotestsum $@ ./... +gotestsum $@ -- -ldflags="-X 'github.com/goodylabs/tug/pkg/config.TugEnv=testing'" ./... diff --git a/tests/mocks/dependencies.go b/tests/mocks/dependencies.go deleted file mode 100644 index 3229133..0000000 --- a/tests/mocks/dependencies.go +++ /dev/null @@ -1,18 +0,0 @@ -package mocks - -import ( - "github.com/goodylabs/tug/internal/app" -) - -// func SetupPm2ManagerWithMocks(prompts []int, sshOutput string, sshErr error) ports.Pm2Manager { -// return pm2.NewPm2Manager( -// NewPrompterMock(prompts), -// NewSSHConnectorMock(sshOutput, sshErr), -// ) -// } - -func SetupConfigureUseCaseWithMocks(prompts []int) *app.ConfigureUseCase { - return app.NewConfigureUseCase( - NewPrompterMock(prompts), - ) -} diff --git a/tests/mocks/prompter.go b/tests/mocks/prompter.go deleted file mode 100644 index 54fe94f..0000000 --- a/tests/mocks/prompter.go +++ /dev/null @@ -1,32 +0,0 @@ -package mocks - -import ( - "github.com/goodylabs/tug/internal/ports" - "github.com/goodylabs/tug/pkg/utils" -) - -type prompterMock struct { - seq []int - index int -} - -func NewPrompterMock(seq []int) ports.Prompter { - return &prompterMock{ - seq: seq, - } -} - -func (p *prompterMock) ChooseFromList(options []string, label string) (string, error) { - i := p.seq[p.index] - p.index++ - utils.SortOptions(options) - return options[i], nil -} - -func (p *prompterMock) ChooseFromMap(map[string]string, string) (string, error) { - return "", nil -} - -func (p *prompterMock) ChooseFromListWithDisplayValue([]ports.DisplayValueOpts, string) (string, error) { - return "", nil -} diff --git a/tests/mocks/sshconnector.go b/tests/mocks/sshconnector.go deleted file mode 100644 index 1b4e6ad..0000000 --- a/tests/mocks/sshconnector.go +++ /dev/null @@ -1,45 +0,0 @@ -package mocks - -import ( - "errors" - - "github.com/goodylabs/tug/internal/ports" -) - -type sshConnectorMock struct { - runCmdOutput string - runCmdErr error - expectedInteractiveCmd string -} - -func NewSSHConnectorMock(runCmdOutput string, runCmdErr error) ports.SSHConnector { - return &sshConnectorMock{ - runCmdOutput: runCmdOutput, - runCmdErr: runCmdErr, - } -} - -func NewSSHConnectorInteractiveCommandMock(expectedInteractiveCmd string) ports.SSHConnector { - return &sshConnectorMock{ - expectedInteractiveCmd: expectedInteractiveCmd, - } -} - -func (m *sshConnectorMock) ConfigureSSHConnection(sshConfig *ports.SSHConfig) error { - return nil -} - -func (m *sshConnectorMock) CloseConnection() error { - return nil -} - -func (m *sshConnectorMock) RunCommand(cmd string) (string, error) { - return m.runCmdOutput, m.runCmdErr -} - -func (m *sshConnectorMock) RunInteractiveCommand(cmd string) error { - if cmd != m.expectedInteractiveCmd { - return errors.New("unexpected interactive command: " + cmd) - } - return nil -} diff --git a/tests/testutils/dockerutils.go b/tests/testutils/dockerutils.go deleted file mode 100644 index a6d137a..0000000 --- a/tests/testutils/dockerutils.go +++ /dev/null @@ -1,29 +0,0 @@ -package testutils - -import ( - "context" - "testing" - - "github.com/stretchr/testify/require" - "github.com/testcontainers/testcontainers-go" -) - -func StartContainer(t *testing.T, req testcontainers.ContainerRequest) testcontainers.Container { - ctx := context.Background() - - container, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ - ContainerRequest: req, - Started: true, - }) - - if t != nil { - require.NoError(t, err) - t.Cleanup(func() { - _ = container.Terminate(ctx) - }) - } else if err != nil { - panic(err) - } - - return container -}