From d87acd4ccdac97db65e5c7bef0a0f6724d4b39e3 Mon Sep 17 00:00:00 2001 From: Jeff Wong Date: Mon, 2 Sep 2024 15:09:38 -0700 Subject: [PATCH] FEATURE: add runtime features (#849) adds commands for: start, run, stop, cleanup, destroy, logs, enter, restart, rebuild -- carrying over existing run commands from launcher1. Rebuild will also do its best to minimize downtime with the following steps: * Detect if Discourse is running as a single container or external DB * Detect if db:migrate is configured to run on container boot * Build initial container (keeping existing one online) * Exit running containers if it's a single container (otherwise keeps existing online) * Run migrations * Defer migrations if db:migrate is configured to run on container boot * Run migrations with SKIP_POST_DEPLOYMENT_MIGRATIONS=1 if it's a 2 container setup * Otherwise, run all migrations * Destroy the old container (finally stopping the current, if it's still up here) * Start the new container * Run post-deploy migrations * Run migrations with SKIP_POST_DEPLOYMENT_MIGRATIONS=0 if it's a 2 container setup --- launcher_go/v2/cli_build.go | 1 + launcher_go/v2/cli_runtime.go | 373 +++++++++++++++++++++++++++++ launcher_go/v2/cli_runtime_test.go | 204 ++++++++++++++++ launcher_go/v2/docker/commands.go | 30 +++ launcher_go/v2/main.go | 10 + 5 files changed, 618 insertions(+) create mode 100644 launcher_go/v2/cli_runtime.go create mode 100644 launcher_go/v2/cli_runtime_test.go diff --git a/launcher_go/v2/cli_build.go b/launcher_go/v2/cli_build.go index af59b75..00cdc50 100644 --- a/launcher_go/v2/cli_build.go +++ b/launcher_go/v2/cli_build.go @@ -64,6 +64,7 @@ type DockerConfigureCmd struct { func (r *DockerConfigureCmd) Run(cli *Cli, ctx *context.Context) error { config, err := config.LoadConfig(cli.ConfDir, r.Config, true, cli.TemplatesDir) + if err != nil { return errors.New("YAML syntax error. Please check your containers/*.yml config files.") } diff --git a/launcher_go/v2/cli_runtime.go b/launcher_go/v2/cli_runtime.go new file mode 100644 index 0000000..97a594f --- /dev/null +++ b/launcher_go/v2/cli_runtime.go @@ -0,0 +1,373 @@ +package main + +import ( + "bufio" + "context" + "errors" + "fmt" + "os" + "os/exec" + "runtime" + "strings" + "syscall" + "time" + + "github.com/discourse/discourse_docker/launcher_go/v2/config" + "github.com/discourse/discourse_docker/launcher_go/v2/docker" + "github.com/discourse/discourse_docker/launcher_go/v2/utils" + + "golang.org/x/sys/unix" +) + +/* + * start + * run + * stop + * cleanup + * destroy + * logs + * enter + * rebuild + * restart + */ + +type StartCmd struct { + Config string `arg:"" name:"config" help:"config" predictor:"config"` + DryRun bool `name:"dry-run" short:"n" help:"Do not start, print docker start command and exit."` + DockerArgs string `name:"docker-args" help:"Extra arguments to pass when running docker."` + RunImage string `name:"run-image" help:"Start with a custom image."` + Supervised bool `name:"supervised" env:"SUPERVISED" help:"Attach the running container on start."` + + extraEnv []string +} + +func (r *StartCmd) Run(cli *Cli, ctx *context.Context) error { + //start stopped container first if exists + running, _ := docker.ContainerRunning(r.Config) + + if running && !r.DryRun { + fmt.Fprintln(utils.Out, "Nothing to do, your container has already started!") + return nil + } + + exists, _ := docker.ContainerExists(r.Config) + + if exists && !r.DryRun { + fmt.Fprintln(utils.Out, "starting up existing container") + cmd := exec.CommandContext(*ctx, utils.DockerPath, "start", r.Config) + + if r.Supervised { + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + + cmd.Cancel = func() error { + if runtime.GOOS == "darwin" { + runCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + stopCmd := exec.CommandContext(runCtx, utils.DockerPath, "stop", r.Config) + utils.CmdRunner(stopCmd).Run() + cancel() + } + return unix.Kill(-cmd.Process.Pid, unix.SIGINT) + } + + cmd.Args = append(cmd.Args, "--attach") + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + } + + fmt.Fprintln(utils.Out, cmd) + + if err := utils.CmdRunner(cmd).Run(); err != nil { + return err + } + + return nil + } + + config, err := config.LoadConfig(cli.ConfDir, r.Config, true, cli.TemplatesDir) + + if err != nil { + return errors.New("YAML syntax error. Please check your containers/*.yml config files.") + } + + defaultHostname, _ := os.Hostname() + defaultHostname = defaultHostname + "-" + r.Config + hostname := config.DockerHostname(defaultHostname) + + restart := true + detatch := true + + if r.Supervised { + restart = false + detatch = false + } + + extraFlags := strings.Fields(r.DockerArgs) + bootCmd := config.BootCommand() + + runner := docker.DockerRunner{ + Config: config, + Ctx: ctx, + ContainerId: r.Config, + DryRun: r.DryRun, + CustomImage: r.RunImage, + Restart: restart, + Detatch: detatch, + ExtraFlags: extraFlags, + ExtraEnv: r.extraEnv, + Hostname: hostname, + Cmd: []string{bootCmd}, + } + + fmt.Fprintln(utils.Out, "starting new container...") + return runner.Run() +} + +type RunCmd struct { + RunImage string `name:"run-image" help:"Override the image used for running the container."` + DockerArgs string `name:"docker-args" help:"Extra arguments to pass when running docker"` + Config string `arg:"" name:"config" help:"config" predictor:"config"` + Cmd []string `arg:"" help:"command to run" passthrough:""` +} + +func (r *RunCmd) Run(cli *Cli, ctx *context.Context) error { + config, err := config.LoadConfig(cli.ConfDir, r.Config, true, cli.TemplatesDir) + if err != nil { + return errors.New("YAML syntax error. Please check your containers/*.yml config files.") + } + extraFlags := strings.Fields(r.DockerArgs) + runner := docker.DockerRunner{ + Config: config, + Ctx: ctx, + CustomImage: r.RunImage, + SkipPorts: true, + Rm: true, + Cmd: r.Cmd, + ExtraFlags: extraFlags, + } + return runner.Run() + return nil +} + +type StopCmd struct { + Config string `arg:"" name:"config" help:"config" predictor:"config"` +} + +func (r *StopCmd) Run(cli *Cli, ctx *context.Context) error { + exists, _ := docker.ContainerExists(r.Config) + if !exists { + fmt.Fprintln(utils.Out, r.Config+" was not found") + return nil + } + cmd := exec.CommandContext(*ctx, utils.DockerPath, "stop", "--time", "600", r.Config) + + fmt.Fprintln(utils.Out, cmd) + if err := utils.CmdRunner(cmd).Run(); err != nil { + return err + } + return nil +} + +type RestartCmd struct { + Config string `arg:"" name:"config" help:"config" predictor:"config"` + DockerArgs string `name:"docker-args" help:"Extra arguments to pass when running docker."` + RunImage string `name:"run-image" help:"Override the image used for running the container."` +} + +func (r *RestartCmd) Run(cli *Cli, ctx *context.Context) error { + start := StartCmd{Config: r.Config, DockerArgs: r.DockerArgs, RunImage: r.RunImage} + stop := StopCmd{Config: r.Config} + + if err := stop.Run(cli, ctx); err != nil { + return err + } + + if err := start.Run(cli, ctx); err != nil { + return err + } + + return nil +} + +type DestroyCmd struct { + Config string `arg:"" name:"config" help:"config" predictor:"config"` +} + +func (r *DestroyCmd) Run(cli *Cli, ctx *context.Context) error { + exists, _ := docker.ContainerExists(r.Config) + + if !exists { + fmt.Fprintln(utils.Out, r.Config+" was not found") + return nil + } + + cmd := exec.CommandContext(*ctx, utils.DockerPath, "stop", "--time", "600", r.Config) + fmt.Fprintln(utils.Out, cmd) + + if err := utils.CmdRunner(cmd).Run(); err != nil { + return err + } + + cmd = exec.CommandContext(*ctx, utils.DockerPath, "rm", r.Config) + fmt.Fprintln(utils.Out, cmd) + + if err := utils.CmdRunner(cmd).Run(); err != nil { + return err + } + + return nil +} + +type EnterCmd struct { + Config string `arg:"" name:"config" help:"config" predictor:"config"` +} + +func (r *EnterCmd) Run(cli *Cli, ctx *context.Context) error { + cmd := exec.CommandContext(*ctx, utils.DockerPath, "exec", "-it", r.Config, "/bin/bash", "--login") + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + if err := utils.CmdRunner(cmd).Run(); err != nil { + return err + } + + return nil +} + +type LogsCmd struct { + Config string `arg:"" name:"config" help:"config" predictor:"config"` +} + +func (r *LogsCmd) Run(cli *Cli, ctx *context.Context) error { + cmd := exec.CommandContext(*ctx, utils.DockerPath, "logs", r.Config) + output, err := utils.CmdRunner(cmd).Output() + + if err != nil { + return err + } + + fmt.Fprintln(utils.Out, string(output[:])) + return nil +} + +type RebuildCmd struct { + Config string `arg:"" name:"config" help:"config" predictor:"config"` + FullBuild bool `name:"full-build" help:"Run a full build image even when migrate on boot and precompile on boot are present in the config. Saves a fully built image with environment baked in. Without this flag, if MIGRATE_ON_BOOT is set in config it will defer migration until container start, and if PRECOMPILE_ON_BOOT is set in the config, it will defer configure step until container start."` + Clean bool `help:"also runs clean"` +} + +func (r *RebuildCmd) Run(cli *Cli, ctx *context.Context) error { + config, err := config.LoadConfig(cli.ConfDir, r.Config, true, cli.TemplatesDir) + + if err != nil { + return errors.New("YAML syntax error. Please check your containers/*.yml config files.") + } + + // if we're not in an all-in-one setup, we can run migrations while the app is running + externalDb := config.Env["DISCOURSE_DB_SOCKET"] == "" && config.Env["DISCOURSE_DB_HOST"] != "" + + build := DockerBuildCmd{Config: r.Config} + configure := DockerConfigureCmd{Config: r.Config} + stop := StopCmd{Config: r.Config} + destroy := DestroyCmd{Config: r.Config} + clean := CleanupCmd{} + extraEnv := []string{} + + if err := build.Run(cli, ctx); err != nil { + return err + } + + if !externalDb { + if err := stop.Run(cli, ctx); err != nil { + return err + } + } + + _, migrateOnBoot := config.Env["MIGRATE_ON_BOOT"] + + if !migrateOnBoot || r.FullBuild { + migrate := DockerMigrateCmd{Config: r.Config} + + if externalDb { + // defer post deploy migrations until after reboot + migrate.SkipPostDeploymentMigrations = true + } + + if err := migrate.Run(cli, ctx); err != nil { + return err + } + + extraEnv = append(extraEnv, "MIGRATE_ON_BOOT=0") + } + + _, precompileOnBoot := config.Env["PRECOMPILE_ON_BOOT"] + + if !precompileOnBoot || r.FullBuild { + if err := configure.Run(cli, ctx); err != nil { + return err + } + + extraEnv = append(extraEnv, "PRECOMPILE_ON_BOOT=0") + } + + if err := destroy.Run(cli, ctx); err != nil { + return err + } + + start := StartCmd{Config: r.Config, extraEnv: extraEnv} + + if err := start.Run(cli, ctx); err != nil { + return err + } + + // run post deploy migrations since we've rebooted + if externalDb { + migrate := DockerMigrateCmd{Config: r.Config} + if err := migrate.Run(cli, ctx); err != nil { + return err + } + } + + if r.Clean { + if err := clean.Run(cli, ctx); err != nil { + return err + } + } + + return nil +} + +type CleanupCmd struct{} + +func (r *CleanupCmd) Run(cli *Cli, ctx *context.Context) error { + cmd := exec.CommandContext(*ctx, utils.DockerPath, "container", "prune", "--filter", "until=1h") + + if err := utils.CmdRunner(cmd).Run(); err != nil { + return err + } + + cmd = exec.CommandContext(*ctx, utils.DockerPath, "image", "prune", "--all", "--filter", "until=1h") + + if err := utils.CmdRunner(cmd).Run(); err != nil { + return err + } + + _, err := os.Stat("/var/discourse/shared/standalone/postgres_data_old") + + if !os.IsNotExist(err) { + fmt.Fprintln(utils.Out, "Old PostgreSQL backup data cluster detected") + fmt.Fprintln(utils.Out, "Would you like to remove it? (y/N)") + scanner := bufio.NewScanner(os.Stdin) + scanner.Scan() + reply := scanner.Text() + if reply == "y" || reply == "Y" { + fmt.Fprintln(utils.Out, "removing old PostgreSQL data cluster at /var/discourse/shared/standalone/postgres_data_old...") + os.RemoveAll("/var/discourse/shared/standalone/postgres_data_old") + } else { + return errors.New("Cancelled") + } + } + + return nil +} diff --git a/launcher_go/v2/cli_runtime_test.go b/launcher_go/v2/cli_runtime_test.go new file mode 100644 index 0000000..c5ff5a9 --- /dev/null +++ b/launcher_go/v2/cli_runtime_test.go @@ -0,0 +1,204 @@ +package main_test + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "bytes" + "context" + "os" + + ddocker "github.com/discourse/discourse_docker/launcher_go/v2" + . "github.com/discourse/discourse_docker/launcher_go/v2/test_utils" + "github.com/discourse/discourse_docker/launcher_go/v2/utils" +) + +var _ = Describe("Runtime", func() { + var testDir string + var out *bytes.Buffer + var cli *ddocker.Cli + var ctx context.Context + + BeforeEach(func() { + utils.DockerPath = "docker" + out = &bytes.Buffer{} + utils.Out = out + testDir, _ = os.MkdirTemp("", "ddocker-test") + ctx = context.Background() + + cli = &ddocker.Cli{ + ConfDir: "./test/containers", + TemplatesDir: "./test", + BuildDir: testDir, + } + + utils.CmdRunner = CreateNewFakeCmdRunner() + }) + + AfterEach(func() { + os.RemoveAll(testDir) + }) + + Context("When running run commands", func() { + var checkStartCmd = func() { + Expect(len(RanCmds)).To(Equal(3)) + + cmd := GetLastCommand() + Expect(cmd.String()).To(ContainSubstring("docker ps --quiet --filter name=test")) + + cmd = GetLastCommand() + Expect(cmd.String()).To(ContainSubstring("docker ps --all --quiet --filter name=test")) + + cmd = GetLastCommand() + Expect(cmd.String()).To(ContainSubstring("docker run")) + Expect(cmd.String()).To(ContainSubstring("--detach")) + Expect(cmd.String()).To(ContainSubstring("--restart=always")) + Expect(cmd.String()).To(ContainSubstring("--name test local_discourse/test /sbin/boot")) + } + + var checkStartCmdWhenStarted = func() { + Expect(len(RanCmds)).To(Equal(1)) + + cmd := GetLastCommand() + Expect(cmd.String()).To(ContainSubstring("docker ps --quiet --filter name=test")) + } + + var checkStopCmd = func() { + Expect(len(RanCmds)).To(Equal(2)) + + cmd := GetLastCommand() + Expect(cmd.String()).To(ContainSubstring("docker ps --all --quiet --filter name=test")) + cmd = GetLastCommand() + Expect(cmd.String()).To(ContainSubstring("docker stop --time 600 test")) + } + + var checkStopCmdWhenMissing = func() { + Expect(len(RanCmds)).To(Equal(1)) + + cmd := GetLastCommand() + Expect(cmd.String()).To(ContainSubstring("docker ps --all --quiet --filter name=test")) + } + + Context("without a running container", func() { + It("should run start commands", func() { + runner := ddocker.StartCmd{Config: "test"} + runner.Run(cli, &ctx) + checkStartCmd() + }) + + It("should not run stop commands", func() { + runner := ddocker.StopCmd{Config: "test"} + runner.Run(cli, &ctx) + checkStopCmdWhenMissing() + }) + }) + + Context("with a running container", func() { + BeforeEach(func() { + //response should be non-empty, indicating a running container + response := []byte{123} + CmdOutputResponse = response + }) + + It("should not run start commands", func() { + runner := ddocker.StartCmd{Config: "test"} + runner.Run(cli, &ctx) + checkStartCmdWhenStarted() + }) + + It("should run stop commands", func() { + runner := ddocker.StopCmd{Config: "test"} + runner.Run(cli, &ctx) + checkStopCmd() + }) + + It("should keep running during commits, and be post-deploy migration aware when using a web only container", func() { + runner := ddocker.RebuildCmd{Config: "web_only"} + runner.Run(cli, &ctx) + + //initial build + cmd := GetLastCommand() + Expect(cmd.String()).To(ContainSubstring("docker build")) + + //migrate, skipping post deployment migrations + cmd = GetLastCommand() + Expect(cmd.String()).To(ContainSubstring("docker run")) + Expect(cmd.String()).To(ContainSubstring("--tags=db,migrate")) + Expect(cmd.String()).To(ContainSubstring("--env SKIP_POST_DEPLOYMENT_MIGRATIONS=1")) + + // precompile + cmd = GetLastCommand() + Expect(cmd.String()).To(ContainSubstring("docker run")) + Expect(cmd.String()).To(ContainSubstring("--tags=db,precompile")) + cmd = GetLastCommand() + Expect(cmd.String()).To(ContainSubstring("docker commit")) + cmd = GetLastCommand() + Expect(cmd.String()).To(ContainSubstring("docker rm")) + + // destroying + cmd = GetLastCommand() + Expect(cmd.String()).To(ContainSubstring("docker ps --all --quiet --filter name=web_only")) + cmd = GetLastCommand() + Expect(cmd.String()).To(ContainSubstring("docker stop --time 600 web_only")) + cmd = GetLastCommand() + Expect(cmd.String()).To(ContainSubstring("docker rm")) + + // starting container --run command won't run because + // tests already believe we're running + cmd = GetLastCommand() + Expect(cmd.String()).To(ContainSubstring("docker ps --quiet")) + + // run post-deploy migrations + cmd = GetLastCommand() + Expect(cmd.String()).To(ContainSubstring("docker run")) + Expect(cmd.String()).To(ContainSubstring("--tags=db,migrate")) + Expect(len(RanCmds)).To(Equal(0)) + }) + + It("should stop with standalone", func() { + runner := ddocker.RebuildCmd{Config: "standalone"} + + runner.Run(cli, &ctx) + + //initial build + cmd := GetLastCommand() + Expect(cmd.String()).To(ContainSubstring("docker build")) + cmd = GetLastCommand() + + // stop + Expect(cmd.String()).To(ContainSubstring("docker ps --all --quiet --filter name=standalone")) + cmd = GetLastCommand() + Expect(cmd.String()).To(ContainSubstring("docker stop")) + + // run migrate + cmd = GetLastCommand() + Expect(cmd.String()).To(ContainSubstring("docker run")) + Expect(cmd.String()).To(ContainSubstring("--tags=db,migrate")) + Expect(cmd.String()).ToNot(ContainSubstring("--env SKIP_POST_DEPLOYMENT_MIGRATIONS=1")) + + // run configure + cmd = GetLastCommand() + Expect(cmd.String()).To(ContainSubstring("docker run")) + Expect(cmd.String()).To(ContainSubstring("--tags=db,precompile")) + cmd = GetLastCommand() + Expect(cmd.String()).To(ContainSubstring("docker commit")) + cmd = GetLastCommand() + Expect(cmd.String()).To(ContainSubstring("docker rm")) + + // run destroy + cmd = GetLastCommand() + Expect(cmd.String()).To(ContainSubstring("docker ps --all --quiet")) + cmd = GetLastCommand() + Expect(cmd.String()).To(ContainSubstring("docker stop")) + cmd = GetLastCommand() + Expect(cmd.String()).To(ContainSubstring("docker rm standalone")) + + // run start (we think we're already started here so this is just ps) + cmd = GetLastCommand() + Expect(cmd.String()).To(ContainSubstring("docker ps --quiet")) + Expect(len(RanCmds)).To(Equal(0)) + }) + }) + + }) +}) diff --git a/launcher_go/v2/docker/commands.go b/launcher_go/v2/docker/commands.go index f552163..fa415d4 100644 --- a/launcher_go/v2/docker/commands.go +++ b/launcher_go/v2/docker/commands.go @@ -289,3 +289,33 @@ func (r *DockerPupsRunner) Run() error { return nil } + +func ContainerExists(container string) (bool, error) { + cmd := exec.Command(utils.DockerPath, "ps", "--all", "--quiet", "--filter", "name="+container) + result, err := utils.CmdRunner(cmd).Output() + + if err != nil { + return false, err + } + + if len(result) > 0 { + return true, nil + } + + return false, nil +} + +func ContainerRunning(container string) (bool, error) { + cmd := exec.Command(utils.DockerPath, "ps", "--quiet", "--filter", "name="+container) + result, err := utils.CmdRunner(cmd).Output() + + if err != nil { + return false, err + } + + if len(result) > 0 { + return true, nil + } + + return false, nil +} diff --git a/launcher_go/v2/main.go b/launcher_go/v2/main.go index 3ea0cee..0c8b669 100644 --- a/launcher_go/v2/main.go +++ b/launcher_go/v2/main.go @@ -20,6 +20,16 @@ type Cli struct { ConfigureCmd DockerConfigureCmd `cmd:"" name:"configure" help:"Configure and save an image with all dependencies and environment baked in. Updates themes and precompiles all assets. Saves resulting container."` MigrateCmd DockerMigrateCmd `cmd:"" name:"migrate" help:"Run migration tasks for a site. Running container is temporary and is not saved."` BootstrapCmd DockerBootstrapCmd `cmd:"" name:"bootstrap" help:"Builds, migrates, and configures an image. Resulting image is a fully built and configured Discourse image."` + + DestroyCmd DestroyCmd `cmd:"" alias:"rm" name:"destroy" help:"Shutdown and destroy container."` + LogsCmd LogsCmd `cmd:"" name:"logs" help:"Print logs for container."` + CleanupCmd CleanupCmd `cmd:"" name:"cleanup" help:"Cleanup unused containers."` + EnterCmd EnterCmd `cmd:"" name:"enter" help:"Connects to a shell running in the container."` + RunCmd RunCmd `cmd:"" name:"run" help:"Runs the specified command in context of a docker container."` + StartCmd StartCmd `cmd:"" name:"start" help:"Starts container."` + StopCmd StopCmd `cmd:"" name:"stop" help:"Stops container."` + RestartCmd RestartCmd `cmd:"" name:"restart" help:"Stops then starts container."` + RebuildCmd RebuildCmd `cmd:"" name:"rebuild" help:"Builds new image, then destroys old container, and starts new container."` } func main() { -- 2.25.1