|
| 1 | +package main |
| 2 | + |
| 3 | +import ( |
| 4 | + "context" |
| 5 | + "errors" |
| 6 | + "fmt" |
| 7 | + "log" |
| 8 | + "os/exec" |
| 9 | + "strings" |
| 10 | + "time" |
| 11 | + |
| 12 | + "github.com/function61/gokit/app/cli" |
| 13 | + "github.com/spf13/cobra" |
| 14 | +) |
| 15 | + |
| 16 | +func smokeTestEntrypoint() *cobra.Command { |
| 17 | + longRunning := false |
| 18 | + |
| 19 | + cmd := &cobra.Command{ |
| 20 | + Use: "smoketest", |
| 21 | + // Short: "Code bookmarks management", |
| 22 | + Args: cobra.NoArgs, |
| 23 | + Run: cli.RunnerNoArgs(func(ctx context.Context, _ *log.Logger) error { |
| 24 | + if longRunning { |
| 25 | + return smokeTest(ctx, smokeTestDefinition{ |
| 26 | + imageRef: "smoketest-longrunning", |
| 27 | + kind: "stay-up", |
| 28 | + }) |
| 29 | + } else { |
| 30 | + return smokeTest(ctx, smokeTestDefinition{ |
| 31 | + imageRef: "smoketest", |
| 32 | + args: []string{"--help"}, |
| 33 | + kind: "exit-immediately", |
| 34 | + exitImmediatelyOptions: exitImmediatelyOptions{ |
| 35 | + outputContains: "Usage: curl [options...] <url>", |
| 36 | + }, |
| 37 | + }) |
| 38 | + } |
| 39 | + }), |
| 40 | + } |
| 41 | + |
| 42 | + cmd.Flags().BoolVarP(&longRunning, "long-running", "l", longRunning, "Test a long-running daemon") |
| 43 | + |
| 44 | + return cmd |
| 45 | +} |
| 46 | + |
| 47 | +type smokeTestDefinition struct { |
| 48 | + imageRef string |
| 49 | + args []string |
| 50 | + kind string // "exit-immediately" | "stay-up" |
| 51 | + exitImmediatelyOptions exitImmediatelyOptions |
| 52 | +} |
| 53 | + |
| 54 | +type exitImmediatelyOptions struct { |
| 55 | + outputContains string |
| 56 | +} |
| 57 | + |
| 58 | +func smokeTest(ctx context.Context, testSpec smokeTestDefinition) error { |
| 59 | + withErr := func(err error) error { return fmt.Errorf("smokeTest: %w", err) } |
| 60 | + |
| 61 | + // TODO: generate |
| 62 | + const containerName = "smoketest-123" |
| 63 | + |
| 64 | + // kill signal to stop container as fast as possible when we want to stop. |
| 65 | + cmd := []string{"docker", "run", "--rm", "-t", "--stop-signal=SIGKILL", "--name=" + containerName, testSpec.imageRef} |
| 66 | + cmd = append(cmd, testSpec.args...) |
| 67 | + |
| 68 | + smokeTest := exec.CommandContext(ctx, cmd[0], cmd[1:]...) |
| 69 | + |
| 70 | + switch testSpec.kind { |
| 71 | + case "exit-immediately": |
| 72 | + return smokeTestExitImmediately(smokeTest, testSpec) |
| 73 | + case "stay-up": |
| 74 | + childCtx, cancel := context.WithTimeout(ctx, 5*time.Second) |
| 75 | + defer cancel() |
| 76 | + return smokeTestStayUp(childCtx, smokeTest, containerName) |
| 77 | + default: |
| 78 | + return withErr(fmt.Errorf("unknown kind: %s", testSpec.kind)) |
| 79 | + } |
| 80 | +} |
| 81 | + |
| 82 | +func smokeTestExitImmediately(smokeTest *exec.Cmd, testSpec smokeTestDefinition) error { |
| 83 | + withErr := func(err error) error { return fmt.Errorf("smokeTestExitImmediately: %w", err) } |
| 84 | + |
| 85 | + output, err := smokeTest.CombinedOutput() |
| 86 | + if err != nil { |
| 87 | + return withErr(fmt.Errorf("%w; output: %s", err, string(output))) |
| 88 | + } |
| 89 | + |
| 90 | + if outputContains := testSpec.exitImmediatelyOptions.outputContains; outputContains != "" { |
| 91 | + if !strings.Contains(string(output), outputContains) { |
| 92 | + return withErr(fmt.Errorf("output should contain '%s' but did not. output was: %s", outputContains, string(output))) |
| 93 | + } |
| 94 | + } |
| 95 | + |
| 96 | + return nil |
| 97 | +} |
| 98 | + |
| 99 | +func smokeTestStayUp(ctx context.Context, smokeTest *exec.Cmd, containerName string) error { |
| 100 | + withErr := func(err error) error { return fmt.Errorf("smokeTestStayUp: %w", err) } |
| 101 | + |
| 102 | + containerExited := make(chan error, 1) |
| 103 | + go func() { |
| 104 | + containerExited <- smokeTest.Run() |
| 105 | + }() |
| 106 | + |
| 107 | + select { |
| 108 | + case err := <-containerExited: // program exited itself |
| 109 | + return withErr(fmt.Errorf("unexpected container exit: %w", err)) |
| 110 | + case <-ctx.Done(): // stop requested before container exited by itself |
| 111 | + // ask the container to stop. for some reason sending SIGINT to `$ docker` will not work |
| 112 | + // (neither from shell, maybe it just forwards signals to the container?) but instead we must use `$ docker stop`. |
| 113 | + // |
| 114 | + // NOTE: using background context because we can't derive context from already-canceled context. |
| 115 | + if output, err := exec.CommandContext(context.Background(), "docker", "stop", containerName).CombinedOutput(); err != nil { |
| 116 | + // probably a race - program exited at the same time? |
| 117 | + return withErr(fmt.Errorf("got context cancel but stopping: %w: %s", err, string(output))) |
| 118 | + } |
| 119 | + |
| 120 | + <-containerExited |
| 121 | + |
| 122 | + if reason := ctx.Err(); errors.Is(reason, context.DeadlineExceeded) { |
| 123 | + // happy path: container stays up for at least the timeout that we wanted |
| 124 | + return nil |
| 125 | + } else { // maybe user cancelled the wait |
| 126 | + return withErr(reason) |
| 127 | + } |
| 128 | + } |
| 129 | +} |
0 commit comments