diff --git a/.golangci.yml b/.golangci.yml index 175c9472..ab90fec9 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -14,7 +14,7 @@ linters: settings: gocyclo: - min-complexity: 25 + min-complexity: 15 gosec: excludes: - G501 diff --git a/cmd/solo/subcommand/root.go b/cmd/solo/subcommand/root.go index 439e5b0d..391e414f 100644 --- a/cmd/solo/subcommand/root.go +++ b/cmd/solo/subcommand/root.go @@ -72,35 +72,6 @@ func NewRootCommand(soloCtx *context.CliContext) (*cobra.Command, func() error) var memProfile string var cpuFile *os.File - stopProfilingCallback := func() error { - pprof.StopCPUProfile() - - if cpuFile != nil { - _ = cpuFile.Close() - } - - if memProfile != "" { - runtime.GC() - - f, err := os.OpenFile(memProfile, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600) - if err != nil { - return fmt.Errorf("failed to create memory profile %q: %v", memProfile, err) - } - - defer func() { - if closeErr := f.Close(); closeErr != nil { - _, _ = fmt.Fprintf(os.Stderr, "failed to close memory profile %q: %v\n", memProfile, closeErr) - } - }() - - if err := pprof.WriteHeapProfile(f); err != nil { - return fmt.Errorf("failed to write memory profile %q: %v", memProfile, err) - } - } - - return nil - } - cmd := &cobra.Command{ Use: "solo", SilenceUsage: true, @@ -156,5 +127,36 @@ func NewRootCommand(soloCtx *context.CliContext) (*cobra.Command, func() error) cmd.PersistentFlags().StringVar(&cpuProfile, "cpu-profile", "", "Enable CPU profiling and write data to the supplied file") cmd.PersistentFlags().StringVar(&memProfile, "mem-profile", "", "Enable memory profiling and write data to the supplied file") - return cmd, stopProfilingCallback + return cmd, stopProfilingCallback(cpuFile, memProfile) +} + +func stopProfilingCallback(cpuFile *os.File, memProfile string) func() error { + return func() error { + pprof.StopCPUProfile() + + if cpuFile != nil { + _ = cpuFile.Close() + } + + if memProfile != "" { + runtime.GC() + + f, err := os.OpenFile(memProfile, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600) + if err != nil { + return fmt.Errorf("failed to create memory profile %q: %v", memProfile, err) + } + + defer func() { + if closeErr := f.Close(); closeErr != nil { + _, _ = fmt.Fprintf(os.Stderr, "failed to close memory profile %q: %v\n", memProfile, closeErr) + } + }() + + if err := pprof.WriteHeapProfile(f); err != nil { + return fmt.Errorf("failed to write memory profile %q: %v", memProfile, err) + } + } + + return nil + } } diff --git a/internal/pkg/architecture_test.go b/internal/pkg/architecture_test.go index ee109768..20fb9203 100644 --- a/internal/pkg/architecture_test.go +++ b/internal/pkg/architecture_test.go @@ -196,73 +196,80 @@ func (t *ArchitectureTestSuite) TestConstructorsDontReturnInterfaces() { for _, subDirectory := range []string{"cmd", "internal", "test"} { scanPath := filepath.Join(t.projectRoot, subDirectory) - err := filepath.WalkDir(scanPath, func(path string, entry os.DirEntry, err error) error { - if err != nil || entry.IsDir() { - return err - } + err := filepath.WalkDir(scanPath, t.checkFileForInterfaceReturnTypeFunc(fileset, &violations)) - if !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") { - return nil - } + if err != nil { + t.FailNowf("failed to check constructor functions for returning interfaces", "%v", err) + } + } - parsedFile, err := parser.ParseFile(fileset, path, nil, parser.SkipObjectResolution) - if err != nil { - return err - } + for _, v := range violations { + t.Failf("Interface type returned from constructor function", "%s", v) + } +} - imports := buildImportAliasMap(parsedFile) +func (t *ArchitectureTestSuite) checkFileForInterfaceReturnTypeFunc( + fileset *token.FileSet, + violations *[]string, +) func(path string, entry os.DirEntry, err error) error { + return func(path string, entry os.DirEntry, err error) error { + if err != nil || entry.IsDir() { + return err + } - for _, declaration := range parsedFile.Decls { - // Ignore non-function declarations - fn, ok := declaration.(*ast.FuncDecl) - if !ok { - continue - } + if !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") { + return nil + } - // Ignore non-constructor functions - if !strings.HasPrefix(fn.Name.Name, "New") { - continue - } + parsedFile, err := parser.ParseFile(fileset, path, nil, parser.SkipObjectResolution) + if err != nil { + return err + } - // Ignore methods on structs - if fn.Recv != nil && fn.Recv.NumFields() > 0 { - continue - } + imports := buildImportAliasMap(parsedFile) - // Ignore functions that return nothing - if fn.Type.Results == nil { - continue - } + for _, declaration := range parsedFile.Decls { + // Ignore non-function declarations + fn, ok := declaration.(*ast.FuncDecl) + if !ok { + continue + } - // For each returned parameter - for _, result := range fn.Type.Results.List { - pkgPath, isInterface, err := t.resolveInterfaceTypeInPackage(result.Type, imports) - if err != nil { - t.FailNowf("failed to resolve types package", "%v", err) - } + // Ignore non-constructor functions + if !strings.HasPrefix(fn.Name.Name, "New") { + continue + } - if isInterface { - pos := fileset.Position(fn.Pos()) - filePath := strings.TrimPrefix(pos.Filename, t.projectRoot+string(filepath.Separator)) + // Ignore methods on structs + if fn.Recv != nil && fn.Recv.NumFields() > 0 { + continue + } - violations = append(violations, fmt.Sprintf( - "%s:%d: %s() returns interface type from %s", - filePath, pos.Line, fn.Name.Name, pkgPath, - )) - } - } + // Ignore functions that return nothing + if fn.Type.Results == nil { + continue } - return nil - }) + // For each returned parameter + for _, result := range fn.Type.Results.List { + pkgPath, isInterface, err := t.resolveInterfaceTypeInPackage(result.Type, imports) + if err != nil { + t.FailNowf("failed to resolve types package", "%v", err) + } - if err != nil { - t.FailNowf("failed to check constructor functions for returning interfaces", "%v", err) + if isInterface { + pos := fileset.Position(fn.Pos()) + filePath := strings.TrimPrefix(pos.Filename, t.projectRoot+string(filepath.Separator)) + + *violations = append(*violations, fmt.Sprintf( + "%s:%d: %s() returns interface type from %s", + filePath, pos.Line, fn.Name.Name, pkgPath, + )) + } + } } - } - for _, v := range violations { - t.Failf("Interface type returned from constructor function", "%s", v) + return nil } } diff --git a/internal/pkg/common/app/cmd/exec_command_args_extractor.go b/internal/pkg/common/app/cmd/exec_command_args_extractor.go new file mode 100644 index 00000000..0972e95e --- /dev/null +++ b/internal/pkg/common/app/cmd/exec_command_args_extractor.go @@ -0,0 +1,103 @@ +package cmd + +import ( + "strings" +) + +type execCommandArgsExtractor struct { + extracted []string + current strings.Builder + escaped bool + singleQuoted bool + doubleQuoted bool +} + +func newExecCommandArgsExtractor() *execCommandArgsExtractor { + return &execCommandArgsExtractor{ + extracted: []string{}, + current: strings.Builder{}, + escaped: false, + singleQuoted: false, + doubleQuoted: false, + } +} + +func (t *execCommandArgsExtractor) extractExecCommandArgs(command string) (string, []string) { + for _, char := range command { + t.extractExecCommandChar(char) + } + + if t.current.Len() > 0 { + t.extracted = append(t.extracted, t.current.String()) + } + + return t.extracted[0], t.extracted[1:] +} + +func (t *execCommandArgsExtractor) extractExecCommandChar(char int32) { + if t.isBackslash(char) { + t.extractBackslash() + } else if t.isDoubleQuote(char) { + t.extractDoubleQuote() + } else if t.isSingleQuote(char) { + t.extractSingleQuote() + } else if t.isSpace(char) { + t.extractSpace() + } else { + t.extractChar(char) + } +} + +func (t *execCommandArgsExtractor) isBackslash(char int32) bool { + return char == '\\' && !t.escaped && !t.singleQuoted && !t.doubleQuoted +} + +func (t *execCommandArgsExtractor) extractBackslash() { + t.escaped = true +} + +func (t *execCommandArgsExtractor) isDoubleQuote(char int32) bool { + return char == '"' && !t.escaped && !t.singleQuoted +} + +func (t *execCommandArgsExtractor) extractDoubleQuote() { + if t.doubleQuoted { + t.doubleQuoted = false + + t.extracted = append(t.extracted, t.current.String()) + t.current.Reset() + } else { + t.doubleQuoted = true + } +} + +func (t *execCommandArgsExtractor) isSingleQuote(char int32) bool { + return char == '\'' && !t.escaped && !t.doubleQuoted +} + +func (t *execCommandArgsExtractor) extractSingleQuote() { + if t.singleQuoted { + t.singleQuoted = false + + t.extracted = append(t.extracted, t.current.String()) + t.current.Reset() + } else { + t.singleQuoted = true + } +} + +func (t *execCommandArgsExtractor) isSpace(char int32) bool { + return char == ' ' && !t.escaped && !t.singleQuoted && !t.doubleQuoted +} + +func (t *execCommandArgsExtractor) extractSpace() { + if t.current.Len() > 0 { + t.extracted = append(t.extracted, t.current.String()) + t.current.Reset() + } +} + +func (t *execCommandArgsExtractor) extractChar(char int32) { + t.current.WriteRune(char) + t.escaped = false +} diff --git a/internal/pkg/common/app/cmd/split_command.go b/internal/pkg/common/app/cmd/split_command.go index aba46971..87fa9269 100644 --- a/internal/pkg/common/app/cmd/split_command.go +++ b/internal/pkg/common/app/cmd/split_command.go @@ -7,59 +7,13 @@ func SplitCommand(shell string, command string) (string, []string) { // and at least one character for the binary if len(command) >= 2 && strings.HasPrefix(command, "/") { // Exec format - return extractExecCommandArgs(command) + return newExecCommandArgsExtractor().extractExecCommandArgs(command) } // Shell format return extractShellCommandArgs(shell, command) } -func extractExecCommandArgs(command string) (string, []string) { - var extracted []string - var current strings.Builder - escaped := false - singleQuoted := false - doubleQuoted := false - - for _, char := range command { - if char == '\\' && !escaped && !singleQuoted && !doubleQuoted { - escaped = true - } else if char == '"' && !escaped && !singleQuoted { - if doubleQuoted { - doubleQuoted = false - - extracted = append(extracted, current.String()) - current.Reset() - } else { - doubleQuoted = true - } - } else if char == '\'' && !escaped && !doubleQuoted { - if singleQuoted { - singleQuoted = false - - extracted = append(extracted, current.String()) - current.Reset() - } else { - singleQuoted = true - } - } else if char == ' ' && !escaped && !singleQuoted && !doubleQuoted { - if current.Len() > 0 { - extracted = append(extracted, current.String()) - current.Reset() - } - } else { - current.WriteRune(char) - escaped = false - } - } - - if current.Len() > 0 { - extracted = append(extracted, current.String()) - } - - return extracted[0], extracted[1:] -} - func extractShellCommandArgs(shell string, command string) (string, []string) { return shell, []string{"-c", command} } diff --git a/internal/pkg/entrypoint/app/workflow/grpc_workflow_runner.go b/internal/pkg/entrypoint/app/workflow/grpc_workflow_runner.go index aa75031d..ec80c8d3 100644 --- a/internal/pkg/entrypoint/app/workflow/grpc_workflow_runner.go +++ b/internal/pkg/entrypoint/app/workflow/grpc_workflow_runner.go @@ -12,7 +12,7 @@ import ( "google.golang.org/grpc/metadata" commonworkflow "github.com/spaulg/solo/internal/pkg/common/domain/wms" - services2 "github.com/spaulg/solo/internal/pkg/common/infra/grpc/services" + "github.com/spaulg/solo/internal/pkg/common/infra/grpc/services" entrypointcontext "github.com/spaulg/solo/internal/pkg/entrypoint/app/context" ) @@ -22,11 +22,11 @@ const FirstPostStartContainerCompleteMetadataKey = "first_post_start_container_c type GrpcWorkflowRunner struct { entrypointCtx *entrypointcontext.EntrypointContext conn *grpc.ClientConn - workflowClient services2.WorkflowClient + workflowClient services.WorkflowClient metadataState *MetadataState } -type WorkflowStream grpc.BidiStreamingClient[services2.RunWorkflowStreamRequest, services2.WorkflowStreamResponse] +type WorkflowStream grpc.BidiStreamingClient[services.RunWorkflowStreamRequest, services.WorkflowStreamResponse] func NewGrpcWorkflowRunner( entrypointCtx *entrypointcontext.EntrypointContext, @@ -47,7 +47,7 @@ func NewGrpcWorkflowRunner( } entrypointCtx.Logger.Info("Creating new service client") - client := services2.NewWorkflowClient(conn) + client := services.NewWorkflowClient(conn) return &GrpcWorkflowRunner{ entrypointCtx: entrypointCtx, @@ -70,9 +70,9 @@ func (t *GrpcWorkflowRunner) Execute(workflowName commonworkflow.WorkflowName) e } }(stream) - if err := stream.Send(&services2.RunWorkflowStreamRequest{ - Request: &services2.RunWorkflowStreamRequest_RunRequest{ - RunRequest: &services2.WorkflowRunRequest{ + if err := stream.Send(&services.RunWorkflowStreamRequest{ + Request: &services.RunWorkflowStreamRequest_RunRequest{ + RunRequest: &services.WorkflowRunRequest{ WorkflowName: workflowName.String(), }, }, @@ -89,7 +89,7 @@ func (t *GrpcWorkflowRunner) Execute(workflowName commonworkflow.WorkflowName) e } switch instruction.Action { - case services2.WorkflowAction_RUN_COMMAND_ACTION: + case services.WorkflowAction_RUN_COMMAND_ACTION: t.entrypointCtx.Logger.Info(fmt.Sprintf("Running command: %s %v\n", instruction.RunCommand.Command, instruction.RunCommand.Arguments)) exitCode, err := t.execute( @@ -100,11 +100,11 @@ func (t *GrpcWorkflowRunner) Execute(workflowName commonworkflow.WorkflowName) e t.entrypointCtx.Logger.Info(fmt.Sprintf("%s\n", stdout)) t.entrypointCtx.Logger.Info(fmt.Sprintf("%s\n", stderr)) - if err := stream.Send(&services2.RunWorkflowStreamRequest{ - Request: &services2.RunWorkflowStreamRequest_StreamRequest{ - StreamRequest: &services2.WorkflowStreamRequest{ - Result: services2.WorkflowResult_RUN_COMMAND_RESULT, - RunCommandResult: &services2.WorkflowRunResult{ + if err := stream.Send(&services.RunWorkflowStreamRequest{ + Request: &services.RunWorkflowStreamRequest_StreamRequest{ + StreamRequest: &services.WorkflowStreamRequest{ + Result: services.WorkflowResult_RUN_COMMAND_RESULT, + RunCommandResult: &services.WorkflowRunResult{ Stdout: stdout, Stderr: stderr, }, @@ -122,11 +122,11 @@ func (t *GrpcWorkflowRunner) Execute(workflowName commonworkflow.WorkflowName) e return err } - if err := stream.Send(&services2.RunWorkflowStreamRequest{ - Request: &services2.RunWorkflowStreamRequest_StreamRequest{ - StreamRequest: &services2.WorkflowStreamRequest{ - Result: services2.WorkflowResult_RUN_COMMAND_RESULT, - RunCommandResult: &services2.WorkflowRunResult{ + if err := stream.Send(&services.RunWorkflowStreamRequest{ + Request: &services.RunWorkflowStreamRequest_StreamRequest{ + StreamRequest: &services.WorkflowStreamRequest{ + Result: services.WorkflowResult_RUN_COMMAND_RESULT, + RunCommandResult: &services.WorkflowRunResult{ ExitCode: &exitCode, }, }, @@ -135,7 +135,7 @@ func (t *GrpcWorkflowRunner) Execute(workflowName commonworkflow.WorkflowName) e return err } - case services2.WorkflowAction_COMPLETE_ACTION: + case services.WorkflowAction_COMPLETE_ACTION: switch workflowName { case commonworkflow.FirstPreStartContainer: t.metadataState.Set(FirstPreStartContainerCompleteMetadataKey, "true") @@ -193,7 +193,28 @@ func (t *GrpcWorkflowRunner) execute( var wg sync.WaitGroup wg.Add(1) - go func() { + go t.outputPipeReader(&wg, stdout, stderr, streamOutput)() + wg.Wait() + + err = cmd.Wait() + + var exitErr *exec.ExitError + if err != nil && !errors.As(err, &exitErr) { + t.entrypointCtx.Logger.Error(fmt.Sprintf("Run finished with error: %v\n", err)) + return 0, err + } + + exitCode := cmd.ProcessState.ExitCode() + return uint32(exitCode), nil // nolint:gosec +} + +func (t *GrpcWorkflowRunner) outputPipeReader( + wg *sync.WaitGroup, + stdout io.ReadCloser, + stderr io.ReadCloser, + streamOutput func(stdout string, stderr string) error, +) func() { + return func() { defer wg.Done() stdoutBuffer := make([]byte, 64*1024) @@ -227,18 +248,5 @@ func (t *GrpcWorkflowRunner) execute( break } } - }() - - wg.Wait() - - err = cmd.Wait() - - var exitErr *exec.ExitError - if err != nil && !errors.As(err, &exitErr) { - t.entrypointCtx.Logger.Error(fmt.Sprintf("Run finished with error: %v\n", err)) - return 0, err } - - exitCode := cmd.ProcessState.ExitCode() - return uint32(exitCode), nil // nolint:gosec } diff --git a/internal/pkg/host/app/project_control.go b/internal/pkg/host/app/project_control.go index 2ae26e47..abe3ac60 100644 --- a/internal/pkg/host/app/project_control.go +++ b/internal/pkg/host/app/project_control.go @@ -13,6 +13,7 @@ import ( "github.com/spaulg/solo/internal/pkg/host/app/wms" "github.com/spaulg/solo/internal/pkg/host/domain" "github.com/spaulg/solo/internal/pkg/host/domain/events" + "github.com/spaulg/solo/internal/pkg/host/infra/container" "github.com/spaulg/solo/internal/pkg/host/infra/grpc" ) @@ -153,21 +154,11 @@ func (t *ProjectControl) internalStart() error { } // Start GRPC services - grpcServer, err := t.grpcServerFactory.Build( - orchestrator, - workflowExecutionTracker, - t.soloCtx.Project, - t.soloCtx.Config.Workflow.Grpc.ServerPort, - ) - + grpcServer, err := t.launchGrpcService(orchestrator, workflowExecutionTracker) if err != nil { return fmt.Errorf("failed to build GRPC server: %w", err) } - if err := grpcServer.Start(); err != nil { - return fmt.Errorf("failed to start GRPC server: %w", err) - } - defer grpcServer.Stop() if err := t.copyEntrypointToState(); err != nil { @@ -200,49 +191,7 @@ func (t *ProjectControl) internalStart() error { return fmt.Errorf("failed to start services: %w", err) } - if err := guard.Wait(func(container string, guardCallback func(name workflowcommon.WorkflowName) error) error { - if err := guardCallback(workflowcommon.FirstPreStartContainer); err != nil { - return err - } - - if err := guardCallback(workflowcommon.FirstPreStartService); err != nil { - return err - } - - if err := guardCallback(workflowcommon.PreStartContainer); err != nil { - return err - } - - if err := guardCallback(workflowcommon.PreStartService); err != nil { - return err - } - - // Exec post start commands - postStartEvents := []workflowcommon.WorkflowName{ - workflowcommon.PostStartContainer, - workflowcommon.PostStartService, - workflowcommon.FirstPostStartContainer, - workflowcommon.FirstPostStartService, - } - - for _, event := range postStartEvents { - postStartCommand := []string{ - t.soloCtx.Config.Entrypoint.ContainerEntrypointPath, - "trigger-event", - event.String(), - } - - if err := orchestrator.StartCommand(container, postStartCommand); err != nil { - return fmt.Errorf("error running compose: %w", err) - } - - if err := guardCallback(event); err != nil { - return err - } - } - - return nil - }); err != nil { + if err := guard.Wait(startGuard(orchestrator, t.soloCtx.Config.Entrypoint.ContainerEntrypointPath)); err != nil { return fmt.Errorf("error waiting for services to complete workflows: %w", err) } @@ -281,22 +230,12 @@ func (t *ProjectControl) internalStop() error { return fmt.Errorf("failed to load workflow execution tracker: %w", err) } - grpcServer, err := t.grpcServerFactory.Build( - orchestrator, - workflowExecutionTracker, - t.soloCtx.Project, - t.soloCtx.Config.Workflow.Grpc.ServerPort, - ) - + // Start GRPC services + grpcServer, err := t.launchGrpcService(orchestrator, workflowExecutionTracker) if err != nil { return fmt.Errorf("failed to build GRPC server: %w", err) } - // Start GRPC services - if err := grpcServer.Start(); err != nil { - return fmt.Errorf("failed to start GRPC server: %w", err) - } - defer grpcServer.Stop() // Populate a list of container names that will be stopped @@ -314,20 +253,7 @@ func (t *ProjectControl) internalStop() error { t.eventManager.Subscribe(guard) defer t.eventManager.Unsubscribe(guard) - if err := guard.Wait(func(container string, _ func(_ workflowcommon.WorkflowName) error) error { - // Exec pre stop commands - preStopCommand := []string{ - t.soloCtx.Config.Entrypoint.ContainerEntrypointPath, - "trigger-event", - workflowcommon.PreStopContainer.String(), - } - - if err := orchestrator.StartCommand(container, preStopCommand); err != nil { - return fmt.Errorf("error running compose: %w", err) - } - - return nil - }); err != nil { + if err := guard.Wait(stopGuard(orchestrator, t.soloCtx.Config.Entrypoint.ContainerEntrypointPath)); err != nil { return fmt.Errorf("error waiting for services to complete workflows: %w", err) } } @@ -369,22 +295,12 @@ func (t *ProjectControl) internalDestroy() error { } if len(serviceStatus.RunningServices) > 0 { - grpcServer, err := t.grpcServerFactory.Build( - orchestrator, - workflowExecutionTracker, - t.soloCtx.Project, - t.soloCtx.Config.Workflow.Grpc.ServerPort, - ) - + // Start GRPC services + grpcServer, err := t.launchGrpcService(orchestrator, workflowExecutionTracker) if err != nil { return fmt.Errorf("failed to build GRPC server: %w", err) } - // Start GRPC services - if err := grpcServer.Start(); err != nil { - return fmt.Errorf("failed to start GRPC server: %w", err) - } - defer grpcServer.Stop() // Populate a list of container names that will be destroyed @@ -402,20 +318,7 @@ func (t *ProjectControl) internalDestroy() error { t.eventManager.Subscribe(guard) defer t.eventManager.Unsubscribe(guard) - if err := guard.Wait(func(container string, _ func(_ workflowcommon.WorkflowName) error) error { - // Exec pre destroy commands - preDestroyCommand := []string{ - t.soloCtx.Config.Entrypoint.ContainerEntrypointPath, - "trigger-event", - workflowcommon.PreDestroyContainer.String(), - } - - if err := orchestrator.StartCommand(container, preDestroyCommand); err != nil { - return fmt.Errorf("error running compose: %w", err) - } - - return nil - }); err != nil { + if err := guard.Wait(destroyGuard(orchestrator, t.soloCtx.Config.Entrypoint.ContainerEntrypointPath)); err != nil { return fmt.Errorf("error waiting for services to complete workflows: %w", err) } } @@ -556,3 +459,25 @@ func (t *ProjectControl) copyEntrypointToState() error { return nil } + +func (t *ProjectControl) launchGrpcService( + orchestrator container.Orchestrator, + workflowExecutionTracker *wms.WorkflowExecTracker, +) (grpc.Server, error) { + grpcServer, err := t.grpcServerFactory.Build( + orchestrator, + workflowExecutionTracker, + t.soloCtx.Project, + t.soloCtx.Config.Workflow.Grpc.ServerPort, + ) + + if err != nil { + return nil, fmt.Errorf("failed to build GRPC server: %w", err) + } + + if err := grpcServer.Start(); err != nil { + return nil, fmt.Errorf("failed to start GRPC server: %w", err) + } + + return grpcServer, nil +} diff --git a/internal/pkg/host/app/project_control_guards.go b/internal/pkg/host/app/project_control_guards.go new file mode 100644 index 00000000..936ae0c5 --- /dev/null +++ b/internal/pkg/host/app/project_control_guards.go @@ -0,0 +1,97 @@ +package app + +import ( + "fmt" + + workflowcommon "github.com/spaulg/solo/internal/pkg/common/domain/wms" + "github.com/spaulg/solo/internal/pkg/host/infra/container" +) + +func startGuard( + orchestrator container.Orchestrator, + containerEntrypointPath string, +) func(container string, guardCallback func(name workflowcommon.WorkflowName) error) error { + return func(container string, guardCallback func(name workflowcommon.WorkflowName) error) error { + if err := guardCallback(workflowcommon.FirstPreStartContainer); err != nil { + return err + } + + if err := guardCallback(workflowcommon.FirstPreStartService); err != nil { + return err + } + + if err := guardCallback(workflowcommon.PreStartContainer); err != nil { + return err + } + + if err := guardCallback(workflowcommon.PreStartService); err != nil { + return err + } + + // Exec post start commands + postStartEvents := []workflowcommon.WorkflowName{ + workflowcommon.PostStartContainer, + workflowcommon.PostStartService, + workflowcommon.FirstPostStartContainer, + workflowcommon.FirstPostStartService, + } + + for _, event := range postStartEvents { + postStartCommand := []string{ + containerEntrypointPath, + "trigger-event", + event.String(), + } + + if err := orchestrator.StartCommand(container, postStartCommand); err != nil { + return fmt.Errorf("error running compose: %w", err) + } + + if err := guardCallback(event); err != nil { + return err + } + } + + return nil + } +} + +func stopGuard( + orchestrator container.Orchestrator, + containerEntrypointPath string, +) func(container string, guardCallback func(name workflowcommon.WorkflowName) error) error { + return func(container string, _ func(_ workflowcommon.WorkflowName) error) error { + // Exec pre stop commands + preStopCommand := []string{ + containerEntrypointPath, + "trigger-event", + workflowcommon.PreStopContainer.String(), + } + + if err := orchestrator.StartCommand(container, preStopCommand); err != nil { + return fmt.Errorf("error running compose: %w", err) + } + + return nil + } +} + +func destroyGuard( + orchestrator container.Orchestrator, + containerEntrypointPath string, +) func(container string, guardCallback func(name workflowcommon.WorkflowName) error) error { + return func(container string, _ func(_ workflowcommon.WorkflowName) error) error { + // Exec pre destroy commands + preDestroyCommand := []string{ + containerEntrypointPath, + "trigger-event", + workflowcommon.PreDestroyContainer.String(), + } + + if err := orchestrator.StartCommand(container, preDestroyCommand); err != nil { + return fmt.Errorf("error running compose: %w", err) + } + + return nil + } +} diff --git a/internal/pkg/host/app/project_tooling.go b/internal/pkg/host/app/project_tooling.go index a9fd192c..2993e874 100644 --- a/internal/pkg/host/app/project_tooling.go +++ b/internal/pkg/host/app/project_tooling.go @@ -9,6 +9,7 @@ import ( "github.com/spaulg/solo/internal/pkg/common/app/cmd" "github.com/spaulg/solo/internal/pkg/host/app/context" + "github.com/spaulg/solo/internal/pkg/host/infra/container" ) const maxOutputPreviewLen = 200 @@ -91,55 +92,66 @@ func (t *ProjectTooling) ExecuteShell(shell string, index int, serviceName strin } if shell == "" { - // List the shells in the container - catShellsCommand := []string{t.soloCtx.Config.Entrypoint.ContainerEntrypointPath, "cat-shells"} - - output, err := orchestrator.RunCommand(fullContainerName, catShellsCommand) + shell, err = t.listContainerShells(orchestrator, fullContainerName) if err != nil { - return err + return fmt.Errorf("failed to list shells in container %s: %w", fullContainerName, err) } + } + + return orchestrator.ForkAndExecute(fullContainerName, shell, nil, workingDirectory) +} - // Select a shell to use - var shellList []string - if err := json.Unmarshal([]byte(output), &shellList); err != nil { - outputPreview := output +func (t *ProjectTooling) listContainerShells(orchestrator container.Orchestrator, fullContainerName string) (string, error) { + // List the shells in the container + catShellsCommand := []string{t.soloCtx.Config.Entrypoint.ContainerEntrypointPath, "cat-shells"} - if len(outputPreview) > maxOutputPreviewLen { - outputPreview = outputPreview[:maxOutputPreviewLen] + "..." - } + output, err := orchestrator.RunCommand(fullContainerName, catShellsCommand) + if err != nil { + return "", err + } - return fmt.Errorf("failed to parse shell list JSON from cat-shells for container %s: %w; output preview: %q", - fullContainerName, err, outputPreview) + // Select a shell to use + var shellList []string + if err := json.Unmarshal([]byte(output), &shellList); err != nil { + outputPreview := output + + if len(outputPreview) > maxOutputPreviewLen { + outputPreview = outputPreview[:maxOutputPreviewLen] + "..." } - if len(shellList) > 0 { - shellmap := make(map[string][]string) + return "", fmt.Errorf("failed to parse shell list JSON from cat-shells for container %s: %w; output preview: %q", + fullContainerName, err, outputPreview) + } + + var shell string - for _, fullShellPath := range shellList { - shellPath, shellFile := path.Split(fullShellPath) - if _, ok := shellmap[shellFile]; !ok { - shellmap[shellFile] = make([]string, 0) - } + if len(shellList) > 0 { + shellmap := make(map[string][]string) - shellmap[shellFile] = append(shellmap[shellFile], shellPath) + for _, fullShellPath := range shellList { + shellPath, shellFile := path.Split(fullShellPath) + if _, ok := shellmap[shellFile]; !ok { + shellmap[shellFile] = make([]string, 0) } - for _, priorityShell := range t.soloCtx.Config.Shell.ShellPriority { - if shellPaths, ok := shellmap[priorityShell]; ok && len(shellPaths) > 0 { - shell = path.Join(shellPaths[len(shellPaths)-1], priorityShell) - break - } - } + shellmap[shellFile] = append(shellmap[shellFile], shellPath) + } - // If a shell from the preferred list could not be - // selected, take the first one from the list - if shell == "" { - shell = shellList[0] + for _, priorityShell := range t.soloCtx.Config.Shell.ShellPriority { + if shellPaths, ok := shellmap[priorityShell]; ok && len(shellPaths) > 0 { + shell = path.Join(shellPaths[len(shellPaths)-1], priorityShell) + break } - } else { - shell = t.soloCtx.Config.Shell.DefaultShell } + + // If a shell from the preferred list could not be + // selected, take the first one from the list + if shell == "" { + shell = shellList[0] + } + } else { + shell = t.soloCtx.Config.Shell.DefaultShell } - return orchestrator.ForkAndExecute(fullContainerName, shell, nil, workingDirectory) + return shell, nil } diff --git a/internal/pkg/host/infra/container/docker.go b/internal/pkg/host/infra/container/docker.go index 90078953..0b1a1bd0 100644 --- a/internal/pkg/host/infra/container/docker.go +++ b/internal/pkg/host/infra/container/docker.go @@ -248,71 +248,7 @@ func (t *DockerOrchestrator) ServicesStatus(serviceNames []string) (*ServiceStat return nil, err } - var runningServiceNames []string - runningServiceNameMap := make(map[string]bool) - - var stoppedServiceNames []string - stoppedServiceNameMap := make(map[string]bool) - - var exitedServiceNames []string - exitedServiceNameMap := make(map[string]bool) - - var absentServiceNames []string - var notRunningServiceNames []string - - buffer := stdoutBuf.Bytes() - reader := bufio.NewReader(bytes.NewReader(buffer)) - - for { - line, err := reader.ReadBytes('\n') - if err != nil { - if err == io.EOF { - break - } - - return nil, err - } - - serviceStatus := ComposeServiceStatus{} - if err := json.Unmarshal(line, &serviceStatus); err != nil { - return nil, err - } - - // Filter for running/stopped services - if serviceStatus.State == "running" && !runningServiceNameMap[serviceStatus.Service] { - runningServiceNameMap[serviceStatus.Service] = true - runningServiceNames = append(runningServiceNames, serviceStatus.Service) - } else if serviceStatus.State == "stopped" && !stoppedServiceNameMap[serviceStatus.Service] { - stoppedServiceNameMap[serviceStatus.Service] = true - stoppedServiceNames = append(stoppedServiceNames, serviceStatus.Service) - notRunningServiceNames = append(notRunningServiceNames, serviceStatus.Service) - } else if serviceStatus.State == "exited" && !exitedServiceNameMap[serviceStatus.Service] { - exitedServiceNameMap[serviceStatus.Service] = true - exitedServiceNames = append(exitedServiceNames, serviceStatus.Service) - notRunningServiceNames = append(notRunningServiceNames, serviceStatus.Service) - } - } - - // Add services with no container - if serviceNames == nil { - serviceNames = t.project.Services().ServiceNames() - } - - for _, service := range serviceNames { - if !runningServiceNameMap[service] && !stoppedServiceNameMap[service] { - // Service is neither running nor stopped - absentServiceNames = append(absentServiceNames, service) - notRunningServiceNames = append(notRunningServiceNames, service) - } - } - - return &ServiceStatus{ - RunningServices: runningServiceNames, - StoppedServices: stoppedServiceNames, - ExitedServices: exitedServiceNames, - AbsentServices: absentServiceNames, - NotRunningServices: notRunningServiceNames, - }, nil + return t.extractServiceStatus(serviceNames, stdoutBuf) } func (t *DockerOrchestrator) GetHostGatewayHostname() string { @@ -504,3 +440,71 @@ func (t *DockerOrchestrator) dockerInspect(artifactName string, nameOrID string) return &inspect, nil } + +func (t *DockerOrchestrator) extractServiceStatus(serviceNames []string, outputBuf bytes.Buffer) (*ServiceStatus, error) { + var runningServiceNames []string + runningServiceNameMap := make(map[string]bool) + + var stoppedServiceNames []string + stoppedServiceNameMap := make(map[string]bool) + + var exitedServiceNames []string + exitedServiceNameMap := make(map[string]bool) + + var absentServiceNames []string + var notRunningServiceNames []string + + buffer := outputBuf.Bytes() + reader := bufio.NewReader(bytes.NewReader(buffer)) + + for { + line, err := reader.ReadBytes('\n') + if err != nil { + if err == io.EOF { + break + } + + return nil, err + } + + serviceStatus := ComposeServiceStatus{} + if err := json.Unmarshal(line, &serviceStatus); err != nil { + return nil, err + } + + // Filter for running/stopped services + if serviceStatus.State == "running" && !runningServiceNameMap[serviceStatus.Service] { + runningServiceNameMap[serviceStatus.Service] = true + runningServiceNames = append(runningServiceNames, serviceStatus.Service) + } else if serviceStatus.State == "stopped" && !stoppedServiceNameMap[serviceStatus.Service] { + stoppedServiceNameMap[serviceStatus.Service] = true + stoppedServiceNames = append(stoppedServiceNames, serviceStatus.Service) + notRunningServiceNames = append(notRunningServiceNames, serviceStatus.Service) + } else if serviceStatus.State == "exited" && !exitedServiceNameMap[serviceStatus.Service] { + exitedServiceNameMap[serviceStatus.Service] = true + exitedServiceNames = append(exitedServiceNames, serviceStatus.Service) + notRunningServiceNames = append(notRunningServiceNames, serviceStatus.Service) + } + } + + // Add services with no container + if serviceNames == nil { + serviceNames = t.project.Services().ServiceNames() + } + + for _, service := range serviceNames { + if !runningServiceNameMap[service] && !stoppedServiceNameMap[service] { + // Service is neither running nor stopped + absentServiceNames = append(absentServiceNames, service) + notRunningServiceNames = append(notRunningServiceNames, service) + } + } + + return &ServiceStatus{ + RunningServices: runningServiceNames, + StoppedServices: stoppedServiceNames, + ExitedServices: exitedServiceNames, + AbsentServices: absentServiceNames, + NotRunningServices: notRunningServiceNames, + }, nil +}