304 lines
7.7 KiB
Go
304 lines
7.7 KiB
Go
package hw3test
|
|
|
|
import (
|
|
"bytes"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"math/rand"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
"text/template"
|
|
"unicode"
|
|
|
|
"go.uber.org/zap"
|
|
)
|
|
|
|
// RunOpts contains command line arguments for the solution.
|
|
// Those are passed to the template.
|
|
type RunOpts struct {
|
|
// Solution options
|
|
Port int
|
|
WorkingDirectory string
|
|
ListenAddr string // Host
|
|
ServerDomain string
|
|
|
|
// Full run config that will be provided to the solution.
|
|
CommandLineArgs string
|
|
Env []string
|
|
|
|
// Full run config if solution will be running in docker.
|
|
DockerCommandLineArgs string
|
|
DockerEnvArgs string
|
|
DockerPortArgs string
|
|
DockerVolumeArgs string
|
|
|
|
// Hack to get exitcode of the solution.
|
|
ExitCode chan int
|
|
}
|
|
|
|
func (o *RunOpts) Address() string {
|
|
host := os.Getenv("SOLUTION_HOST")
|
|
if host == "" {
|
|
host = "localhost"
|
|
}
|
|
|
|
return fmt.Sprintf("http://%s:%d", host, o.Port)
|
|
}
|
|
|
|
func (o *RunOpts) GenerateRunConfig(t *TC, r *rand.Rand, gen *EnvGen) {
|
|
o.CommandLineArgs, o.Env = o.BuildConfig(r, gen)
|
|
|
|
dirsInDocker := []string{
|
|
"/files",
|
|
"/files0",
|
|
"/files1",
|
|
"/files2",
|
|
"/files3",
|
|
}
|
|
dirInDocker := dirsInDocker[r.Intn(len(dirsInDocker))]
|
|
|
|
if o.WorkingDirectory == "" {
|
|
dirInDocker = ""
|
|
} else {
|
|
o.DockerVolumeArgs = fmt.Sprintf(`-v "%s:%s"`, o.WorkingDirectory, dirInDocker)
|
|
}
|
|
|
|
o.DockerPortArgs = fmt.Sprintf("-p %d:%d", o.Port, o.Port)
|
|
|
|
dockerArgs, dockerEnv := RunOpts{
|
|
ListenAddr: o.ListenAddr,
|
|
Port: o.Port,
|
|
WorkingDirectory: dirInDocker,
|
|
ServerDomain: o.ServerDomain,
|
|
}.BuildConfig(r, gen)
|
|
|
|
o.DockerEnvArgs = ""
|
|
for _, env := range dockerEnv {
|
|
o.DockerEnvArgs += "--env \"" + env + "\" "
|
|
}
|
|
|
|
o.DockerCommandLineArgs = dockerArgs
|
|
}
|
|
|
|
// BuildConfig uses ListenAddr, Port, WorkingDirectory, ServerDomain.
|
|
func (o RunOpts) BuildConfig(r *rand.Rand, gen *EnvGen) (args string, env []string) {
|
|
if o.ListenAddr == "0.0.0.0" && r.Intn(2) == 1 {
|
|
// can omit default value
|
|
} else if gen.AllowEnv && r.Intn(3) == 1 {
|
|
// use env
|
|
env = append(env, fmt.Sprintf("SERVER_HOST=%s", o.ListenAddr))
|
|
} else if o.ListenAddr != "" {
|
|
// use plain cmdline args
|
|
args += fmt.Sprintf(" \"--host=%s\"", o.ListenAddr)
|
|
|
|
if r.Intn(2) == 1 {
|
|
// pass dummy env
|
|
env = append(env, fmt.Sprintf("SERVER_HOST=%s", "8.8.8.8"))
|
|
}
|
|
}
|
|
|
|
if o.Port == 8080 && r.Intn(2) == 1 {
|
|
// can omit default value
|
|
} else if gen.AllowEnv && r.Intn(3) == 1 {
|
|
// use env
|
|
env = append(env, fmt.Sprintf("SERVER_PORT=%d", o.Port))
|
|
} else {
|
|
// use plain cmdline args
|
|
args += fmt.Sprintf(" \"--port=%d\"", o.Port)
|
|
|
|
if r.Intn(2) == 1 {
|
|
// pass dummy env
|
|
env = append(env, fmt.Sprintf("SERVER_PORT=%d", 80))
|
|
}
|
|
}
|
|
|
|
if o.WorkingDirectory == "" && r.Intn(2) == 1 {
|
|
// can omit default value
|
|
} else if gen.AllowEnv && r.Intn(3) == 1 {
|
|
// use env
|
|
env = append(env, fmt.Sprintf("SERVER_WORKING_DIRECTORY=%s", o.WorkingDirectory))
|
|
} else if o.WorkingDirectory != "" {
|
|
// use plain cmdline args
|
|
args += fmt.Sprintf(" \"--working-directory=%s\"", o.WorkingDirectory)
|
|
|
|
if r.Intn(2) == 1 {
|
|
// pass dummy env
|
|
env = append(env, fmt.Sprintf("SERVER_WORKING_DIRECTORY=%s", "/"))
|
|
}
|
|
}
|
|
|
|
if o.ServerDomain == "localhost" && r.Intn(2) == 1 {
|
|
// can omit default value
|
|
} else if gen.AllowEnv && r.Intn(3) == 1 {
|
|
// use env
|
|
env = append(env, fmt.Sprintf("SERVER_DOMAIN=%s", o.ServerDomain))
|
|
} else if o.ServerDomain != "" {
|
|
// use plain cmdline args
|
|
args += fmt.Sprintf(" \"--server-domain=%s\"", o.ServerDomain)
|
|
|
|
if r.Intn(2) == 1 {
|
|
// pass dummy env
|
|
env = append(env, fmt.Sprintf("SERVER_DOMAIN=%s", "example.com"))
|
|
}
|
|
}
|
|
return args, env
|
|
}
|
|
|
|
// Runner is a helper for running HTTP server solution.
|
|
type Runner interface {
|
|
// Run the solution with the given options.
|
|
// Returns a function that can be used to stop the solution.
|
|
Run(t *TC, opts RunOpts) (stop func(), err error)
|
|
}
|
|
|
|
// CmdRunner runs command based on template.
|
|
type CmdRunner struct {
|
|
tmpl template.Template
|
|
useDocker bool
|
|
}
|
|
|
|
func NewCmdRunner(tmpl *template.Template, useDocker bool) *CmdRunner {
|
|
return &CmdRunner{
|
|
tmpl: *tmpl,
|
|
useDocker: useDocker,
|
|
}
|
|
}
|
|
|
|
// splitCommand preserves quoted arguments without invoking a shell.
|
|
func splitCommand(command string) ([]string, error) {
|
|
var args []string
|
|
var arg strings.Builder
|
|
quoted := false
|
|
started := false
|
|
for _, char := range command {
|
|
switch {
|
|
case char == '"':
|
|
quoted = !quoted
|
|
started = true
|
|
case unicode.IsSpace(char) && !quoted:
|
|
if started {
|
|
args = append(args, arg.String())
|
|
arg.Reset()
|
|
started = false
|
|
}
|
|
default:
|
|
arg.WriteRune(char)
|
|
started = true
|
|
}
|
|
}
|
|
if quoted {
|
|
return nil, fmt.Errorf("unclosed quote in launch command")
|
|
}
|
|
if started {
|
|
args = append(args, arg.String())
|
|
}
|
|
if len(args) == 0 {
|
|
return nil, fmt.Errorf("empty launch command")
|
|
}
|
|
return args, nil
|
|
}
|
|
|
|
func (r *CmdRunner) Run(t *TC, opts RunOpts) (stop func(), err error) {
|
|
var b bytes.Buffer
|
|
err = r.tmpl.Execute(&b, opts)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to execute template: %v", err)
|
|
}
|
|
|
|
envOpts := opts.Env
|
|
if r.useDocker {
|
|
envOpts = nil
|
|
}
|
|
|
|
cmdParts, err := splitCommand(b.String())
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var cidDir, cidFile string
|
|
if r.useDocker {
|
|
if len(cmdParts) < 2 || !strings.EqualFold(filepath.Base(cmdParts[0]), "docker") || cmdParts[1] != "run" {
|
|
return nil, fmt.Errorf("Docker launch template must start with docker run")
|
|
}
|
|
cidDir, err = os.MkdirTemp("", "hw3-container-")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create container ID directory: %w", err)
|
|
}
|
|
cidFile = filepath.Join(cidDir, "id")
|
|
cmdParts = append(append([]string{}, cmdParts[:2]...), append([]string{"--cidfile", cidFile}, cmdParts[2:]...)...)
|
|
}
|
|
Info(t, "Running command", zap.Strings("command", cmdParts), zap.Strings("env", envOpts))
|
|
|
|
cmd := exec.Command(cmdParts[0], cmdParts[1:]...)
|
|
cmd.Env = append(os.Environ(), envOpts...)
|
|
cmd.Stdout = NewProxyWriter(os.Stderr)
|
|
cmd.Stderr = NewProxyWriter(os.Stderr)
|
|
|
|
err = cmd.Start()
|
|
if err != nil {
|
|
if cidDir != "" {
|
|
_ = os.Remove(cidDir)
|
|
}
|
|
return nil, fmt.Errorf("failed to run command: %v", err)
|
|
}
|
|
|
|
go func() {
|
|
err := cmd.Wait()
|
|
if err != nil && err.Error() != "signal: killed" && err.Error() != "exit status 1" {
|
|
Warn(t, "Command finished with error", zap.Error(err))
|
|
}
|
|
if opts.ExitCode != nil {
|
|
if e, ok := err.(*exec.ExitError); ok {
|
|
opts.ExitCode <- e.ExitCode()
|
|
} else {
|
|
opts.ExitCode <- 0
|
|
}
|
|
}
|
|
}()
|
|
|
|
return func() {
|
|
if cmd.Process != nil {
|
|
if err := cmd.Process.Kill(); err != nil && !errors.Is(err, os.ErrProcessDone) {
|
|
Error(t, "Failed to kill command", zap.Error(err))
|
|
}
|
|
}
|
|
if r.useDocker {
|
|
id, err := os.ReadFile(cidFile)
|
|
if err == nil && len(strings.TrimSpace(string(id))) > 0 {
|
|
output, removeErr := exec.Command("docker", "rm", "-f", strings.TrimSpace(string(id))).CombinedOutput()
|
|
if removeErr != nil && !strings.Contains(string(output), "No such container") && !strings.Contains(string(output), "is already in progress") {
|
|
Error(t, "Failed to remove Docker container", zap.Error(removeErr), zap.ByteString("output", output))
|
|
}
|
|
} else if err != nil && !errors.Is(err, os.ErrNotExist) {
|
|
Error(t, "Failed to read container ID", zap.Error(err))
|
|
}
|
|
_ = os.Remove(cidFile)
|
|
_ = os.Remove(cidDir)
|
|
}
|
|
}, nil
|
|
}
|
|
|
|
// ProxyWriter is used to forward solution output to standard output.
|
|
type ProxyWriter struct {
|
|
w io.Writer
|
|
disable bool
|
|
}
|
|
|
|
func NewProxyWriter(w io.Writer) *ProxyWriter {
|
|
return &ProxyWriter{
|
|
w: w,
|
|
disable: boolFromEnv("DISABLE_SOLUTION_OUTPUT", false),
|
|
}
|
|
}
|
|
|
|
func (w *ProxyWriter) Write(b []byte) (n int, err error) {
|
|
if w.disable {
|
|
return len(b), nil
|
|
}
|
|
// TODO: if there will be sync problems, we can take a global lock
|
|
// and read until \n, then flush and apply color
|
|
return w.w.Write(b)
|
|
}
|