├── .github ├── ISSUE_TEMPLATE.md └── PULL_REQUEST_TEMPLATE.md ├── .gitignore ├── .travis.yml ├── CHANGELOG.md ├── CONTRIBUTING.md ├── Dockerfile ├── LICENSE ├── README.md ├── client ├── README.md └── client.go ├── commands ├── README.md ├── args.go ├── args_test.go ├── commands.go ├── commands_test.go └── testdata │ └── test.sh ├── config ├── README.md ├── config.go ├── config_test.go ├── decode │ ├── README.md │ ├── decode.go │ └── decode_test.go ├── logger │ ├── README.md │ ├── logging.go │ └── logging_test.go ├── services │ ├── README.md │ ├── docs.go │ ├── ips.go │ ├── ips_test.go │ ├── names.go │ └── names_test.go ├── template │ ├── README.md │ ├── template.go │ └── template_test.go ├── testdata │ └── test.json5 └── timing │ ├── README.md │ ├── duration.go │ └── duration_test.go ├── control ├── README.md ├── config.go ├── config_test.go ├── control.go ├── control_test.go ├── endpoints.go └── endpoints_test.go ├── core ├── README.md ├── app.go ├── app_test.go ├── flags.go ├── flags_test.go ├── signals.go ├── signals_test.go └── testdata │ └── test.sh ├── discovery ├── README.md ├── config.go ├── consul.go ├── consul_test.go ├── discovery.go ├── service.go └── test_server.go ├── docs ├── 10-lifecycle.md ├── 20-design.md ├── 30-configuration │ ├── 31-installation.md │ ├── 32-configuration-file.md │ ├── 33-consul.md │ ├── 34-jobs.md │ ├── 35-watches.md │ ├── 36-telemetry.md │ ├── 37-control-plane.md │ ├── 38-logging.md │ ├── 39-config-examples.md │ ├── README.md │ └── examples │ │ ├── consul-agent.json5 │ │ ├── database-config.json5 │ │ ├── nginx-upstreams.json5 │ │ ├── periodic-tasks.json5 │ │ ├── service-reg-only.json5 │ │ └── stopping.json5 ├── 40-support.md └── README.md ├── events ├── README.md ├── bus.go ├── eventcode_string.go ├── events.go ├── events_test.go ├── publisher.go ├── subscriber.go └── timer.go ├── glide.lock ├── glide.yaml ├── integration_tests ├── README.md ├── fixtures │ ├── app │ │ ├── Dockerfile │ │ ├── containerpilot-with-coprocess.json5 │ │ ├── containerpilot-with-file-log.json5 │ │ ├── containerpilot.json5 │ │ ├── reload-app.sh │ │ ├── reload-containerpilot.sh │ │ └── sensor.sh │ ├── consul │ │ ├── Dockerfile │ │ └── assert │ └── nginx │ │ ├── Dockerfile │ │ └── etc │ │ ├── nginx-consul.ctmpl │ │ ├── nginx-with-consul.json5 │ │ └── nginx │ │ └── nginx.conf └── tests │ ├── test_config_reload │ ├── docker-compose.yml │ └── run.sh │ ├── test_coprocess │ ├── coprocess.sh │ ├── docker-compose.yml │ └── run.sh │ ├── test_discovery_consul │ ├── containerpilot.json5 │ ├── docker-compose.yml │ └── run.sh │ ├── test_envvars │ ├── containerpilot.json5 │ ├── docker-compose.yml │ └── run.sh │ ├── test_logging │ ├── containerpilot.json5 │ ├── docker-compose.yml │ └── run.sh │ ├── test_no_command │ ├── docker-compose.yml │ └── run.sh │ ├── test_reap_zombies │ ├── containerpilot.json5 │ ├── docker-compose.yml │ ├── run.sh │ ├── slow-child.sh │ ├── slow-zombie.sh │ └── zombie.sh │ ├── test_reopen │ ├── docker-compose.yml │ ├── run.sh │ └── test_reopen.sh │ ├── test_sighup │ ├── containerpilot.json5 │ ├── docker-compose.yml │ └── run.sh │ ├── test_sigterm │ ├── containerpilot.json5 │ ├── docker-compose.yml │ └── run.sh │ ├── test_tasks │ ├── containerpilot.json5 │ ├── docker-compose.yml │ ├── run.sh │ └── task.sh │ ├── test_telemetry │ ├── check.sh │ ├── containerpilot.json5 │ ├── docker-compose.yml │ └── run.sh │ └── test_version_flag │ ├── docker-compose.yml │ └── run.sh ├── jobs ├── README.md ├── config.go ├── config_test.go ├── jobs.go ├── jobs_test.go ├── status.go └── testdata │ ├── TestErrJobConfigConsulDeregisterCriticalServiceAfter.json5 │ ├── TestErrJobConfigConsulEnableTagOverride.json5 │ ├── TestJobConfigConsulExtras.json5 │ ├── TestJobConfigHealthTimeout.json5 │ ├── TestJobConfigPeriodicTask.json5 │ ├── TestJobConfigServiceNonAdvertised.json5 │ ├── TestJobConfigServiceWithArrayExec.json5 │ ├── TestJobConfigServiceWithInitialStatus.json5 │ ├── TestJobConfigServiceWithPreStart.json5 │ ├── TestJobConfigServiceWithStopping.json5 │ ├── TestJobConfigSmokeTest.json5 │ ├── test.sh │ ├── test_coprocess.sh │ └── test_tasks.sh ├── main.go ├── makefile ├── scripts ├── add_dep.sh ├── cover.sh ├── docs.py ├── lint.sh ├── test.sh └── unit_test.sh ├── subcommands ├── README.md └── subcommands.go ├── sup ├── README.md └── sup.go ├── telemetry ├── README.md ├── metrics.go ├── metrics_config.go ├── metrics_config_test.go ├── metrics_test.go ├── status.go ├── status_test.go ├── telemetry.go ├── telemetry_config.go ├── telemetry_config_test.go ├── telemetry_test.go └── testdata │ ├── TestTelemetryConfigParse.json5 │ └── test.sh ├── tests ├── README.md ├── mocks │ ├── README.md │ └── discovery.go └── tests.go ├── version ├── README.md └── version.go └── watches ├── README.md ├── config.go ├── config_test.go ├── testdata ├── TestWatchesParse.json5 └── test.sh ├── watches.go └── watches_test.go /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | Thanks for reporting an issue with ContainerPilot! 2 | 3 | If you're reporting a problem, having trouble with a configuration, or think you've found a bug, please be sure to include the following information: 4 | 5 | - what is happening and what you expect to see 6 | - the output of `containerpilot -version` 7 | - the ContainerPilot configuration you're using 8 | - the output of any logs you can share; if you can it would be very helpful to turn on debug logging by adding `logging: { level: "DEBUG"}` to your ContainerPilot configuration. 9 | 10 | If you can't provide some of this information because it's private, include what you can and we'll try to assist anyways. If you're a Joyent customer you can also reach out to Joyent's support team. 11 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | Thanks for contributing to ContainerPilot! Please include with your pull request: 2 | 3 | - A description of what you did for the changelog 4 | - An explanation of why ContainerPilot needs this change 5 | - How to verify that it works (most PRs need tests!) 6 | - A link to the GitHub issue that it addresses 7 | 8 | If you're contributing a new feature, it's usually better to open an issue to discuss the feature rather than open a pull request unless the feature is trivial. 9 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Compiled Object files, Static and Dynamic libs (Shared Objects) 2 | *.o 3 | *.a 4 | *.so 5 | 6 | # Folders 7 | _obj 8 | _test 9 | .DS_Store 10 | 11 | # Architecture specific extensions/prefixes 12 | *.[568vq] 13 | [568vq].out 14 | 15 | *.cgo1.go 16 | *.cgo2.c 17 | _cgo_defun.c 18 | _cgo_gotypes.go 19 | _cgo_export.* 20 | 21 | _testmain.go 22 | 23 | *.exe 24 | *.test 25 | *.prof 26 | 27 | # more build and test outputs 28 | build/ 29 | release/ 30 | cover/ 31 | vendor/ 32 | .glide 33 | *.tar.gz 34 | *.log 35 | cover.out 36 | cover.html 37 | 38 | # fake paths from overlaying volumes in Docker 39 | /src 40 | 41 | # IDE files 42 | .idea 43 | *.iml 44 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | --- 2 | language: go 3 | go: 4 | - tip 5 | 6 | sudo: required 7 | 8 | services: 9 | - docker 10 | 11 | env: 12 | DOCKER_COMPOSE_VERSION: 1.11.2 13 | GO15VENDOREXPERIMENT: 1 14 | 15 | before_install: 16 | - sudo rm /usr/local/bin/docker-compose 17 | - curl -L https://github.com/docker/compose/releases/download/${DOCKER_COMPOSE_VERSION}/docker-compose-`uname -s`-`uname -m` > docker-compose 18 | - chmod +x docker-compose 19 | - sudo mv docker-compose /usr/local/bin 20 | 21 | install: 22 | - make vendor 23 | 24 | script: 25 | - make lint build 26 | - make test 27 | - make integration 28 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | ## Contributing 2 | 3 | ContainerPilot is open source under the [Mozilla Public License 2.0](https://github.com/joyent/containerpilot/blob/master/LICENSE). 4 | 5 | Pull requests on GitHub are welcome on any issue. If you'd like to propose a new feature, it's often a good idea to discuss the design by opening an issue first. We'll mark these as [`proposals`](https://github.com/joyent/containerpilot/issues?q=is%3Aopen+is%3Aissue+label%3Aproposal), and roadmap items will be maintained as [`enhancements`](https://github.com/joyent/containerpilot/issues?q=is%3Aopen+is%3Aissue+label%3Aenhancement). 6 | 7 | Many of our contributors have never contributed to an open source golang project before. If you are looking for a good first contribution, check out the [`help wanted` label](https://github.com/joyent/containerpilot/issues?q=is%3Aopen+is%3Aissue+label%3A"help+wanted"); not that we don't want help anywhere else of course! But these are low-hanging fruit to get started. 8 | 9 | Please make sure you've added tests for any new feature or tests that prove a bug has been fixed. Run `make lint` before submitting your PR. We test ContainerPilot on [TravisCI](https://travis-ci.org/joyent/containerpilot). 10 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.9 2 | 3 | ENV CONSUL_VERSION=1.0.0 4 | ENV GLIDE_VERSION=0.12.3 5 | 6 | RUN apt-get update \ 7 | && apt-get install -y unzip \ 8 | && go get github.com/golang/lint/golint \ 9 | && curl -Lo /tmp/glide.tgz "https://github.com/Masterminds/glide/releases/download/v${GLIDE_VERSION}/glide-v${GLIDE_VERSION}-linux-amd64.tar.gz" \ 10 | && tar -C /usr/bin -xzf /tmp/glide.tgz --strip=1 linux-amd64/glide \ 11 | && curl --fail -Lso consul.zip "https://releases.hashicorp.com/consul/${CONSUL_VERSION}/consul_${CONSUL_VERSION}_linux_amd64.zip" \ 12 | && unzip consul.zip -d /usr/bin 13 | 14 | ENV CGO_ENABLED 0 15 | ENV GOPATH /go:/cp 16 | -------------------------------------------------------------------------------- /client/README.md: -------------------------------------------------------------------------------- 1 | ## client 2 | 3 | [![GoDoc](https://godoc.org/github.com/joyent/containerpilot?status.svg)](https://godoc.org/github.com/joyent/containerpilot/client) 4 | -------------------------------------------------------------------------------- /client/client.go: -------------------------------------------------------------------------------- 1 | // Package client provides a HTTP client used to send commands to the 2 | // ContainerPilot control socket 3 | package client 4 | 5 | import ( 6 | "errors" 7 | "fmt" 8 | "net" 9 | "net/http" 10 | "strings" 11 | ) 12 | 13 | // HTTPClient provides a properly configured http.Client object used to send 14 | // requests out to a ContainerPilot process's control socket. 15 | type HTTPClient struct { 16 | http.Client 17 | socketPath string 18 | } 19 | 20 | var socketType = "unix" 21 | 22 | func socketDialer(socketPath string) func(string, string) (net.Conn, error) { 23 | return func(_, _ string) (net.Conn, error) { 24 | return net.Dial(socketType, socketPath) 25 | } 26 | } 27 | 28 | // NewHTTPClient initializes an client.HTTPClient object by configuring it's 29 | // socketPath for HTTP communication through the local file system. 30 | func NewHTTPClient(socketPath string) (*HTTPClient, error) { 31 | if socketPath == "" { 32 | err := errors.New("control server not loading due to missing config") 33 | return nil, err 34 | } 35 | 36 | client := &HTTPClient{} 37 | client.Transport = &http.Transport{ 38 | Dial: socketDialer(socketPath), 39 | } 40 | 41 | return client, nil 42 | } 43 | 44 | // Reload makes a request to the reload endpoint of a ContainerPilot process. 45 | func (c HTTPClient) Reload() error { 46 | resp, err := c.Post("http://control/v3/reload", "application/json", nil) 47 | if err != nil { 48 | return err 49 | } 50 | defer resp.Body.Close() 51 | return nil 52 | } 53 | 54 | // SetMaintenance makes a request to either the enable or disable maintenance 55 | // endpoint of a ContainerPilot process. 56 | func (c HTTPClient) SetMaintenance(isEnabled bool) error { 57 | flag := "disable" 58 | if isEnabled { 59 | flag = "enable" 60 | } 61 | 62 | resp, err := c.Post("http://control/v3/maintenance/"+flag, "application/json", nil) 63 | if err != nil { 64 | return err 65 | } 66 | defer resp.Body.Close() 67 | return nil 68 | } 69 | 70 | // PutEnv makes a request to the environ endpoint of a ContainerPilot process 71 | // for setting environ variable pairs. 72 | func (c HTTPClient) PutEnv(body string) error { 73 | resp, err := c.Post("http://control/v3/environ", "application/json", 74 | strings.NewReader(body)) 75 | if err != nil { 76 | return err 77 | } 78 | defer resp.Body.Close() 79 | 80 | if resp.StatusCode == http.StatusUnprocessableEntity { 81 | return fmt.Errorf("unprocessable entity received by control server") 82 | } 83 | return nil 84 | } 85 | 86 | // PutMetric makes a request to the metric endpoint of a ContainerPilot process 87 | // for setting custom metrics. 88 | func (c HTTPClient) PutMetric(body string) error { 89 | resp, err := c.Post("http://control/v3/metric", "application/json", 90 | strings.NewReader(body)) 91 | if err != nil { 92 | return err 93 | } 94 | defer resp.Body.Close() 95 | 96 | if resp.StatusCode == http.StatusUnprocessableEntity { 97 | return fmt.Errorf("unprocessable entity received by control server") 98 | } 99 | return nil 100 | } 101 | 102 | // GetPing make a request to the ping endpoint of the ContainerPilot control 103 | // socket, to verify it's listening 104 | func (c HTTPClient) GetPing() error { 105 | resp, err := c.Get("http://control/v3/ping") 106 | if err != nil { 107 | return err 108 | } 109 | defer resp.Body.Close() 110 | 111 | if resp.StatusCode == http.StatusUnprocessableEntity { 112 | return fmt.Errorf("unprocessable entity received by control server") 113 | } 114 | return nil 115 | } 116 | -------------------------------------------------------------------------------- /commands/README.md: -------------------------------------------------------------------------------- 1 | ## commands 2 | 3 | [![GoDoc](https://godoc.org/github.com/joyent/containerpilot?status.svg)](https://godoc.org/github.com/joyent/containerpilot/commands) 4 | -------------------------------------------------------------------------------- /commands/args.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import ( 4 | "errors" 5 | "strings" 6 | 7 | "github.com/joyent/containerpilot/config/decode" 8 | ) 9 | 10 | // ParseArgs parses the executable and its arguments from supported 11 | // types. 12 | func ParseArgs(raw interface{}) (executable string, args []string, err error) { 13 | switch t := raw.(type) { 14 | case string: 15 | if t != "" { 16 | args = strings.Split(strings.TrimSpace(t), " ") 17 | } 18 | default: 19 | args, err = decode.ToStrings(raw) 20 | } 21 | if len(args) == 0 { 22 | err = errors.New("received zero-length argument") 23 | } else if len(args) == 1 { 24 | executable = args[0] 25 | args = nil 26 | } else { 27 | executable = args[0] 28 | args = args[1:] 29 | } 30 | return executable, args, err 31 | } 32 | -------------------------------------------------------------------------------- /commands/args_test.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import ( 4 | "errors" 5 | "reflect" 6 | "testing" 7 | ) 8 | 9 | func TestParseArgs(t *testing.T) { 10 | 11 | // nil args should return error 12 | exec, args, err := ParseArgs(nil) 13 | validateParsing(t, exec, "", args, nil, 14 | err, errors.New("received zero-length argument")) 15 | 16 | // string args ok 17 | exec, args, err = ParseArgs("/testdata/test.sh arg1") 18 | validateParsing(t, exec, "/testdata/test.sh", args, []string{"arg1"}, err, nil) 19 | 20 | // array args ok 21 | exec, args, err = ParseArgs([]string{"/testdata/test.sh", "arg2"}) 22 | validateParsing(t, exec, "/testdata/test.sh", args, []string{"arg2"}, err, nil) 23 | 24 | // interface args ok 25 | exec, args, err = ParseArgs([]interface{}{"/testdata/test.sh", "arg3"}) 26 | validateParsing(t, exec, "/testdata/test.sh", args, []string{"arg3"}, err, nil) 27 | 28 | // map of bools args return error 29 | exec, args, err = ParseArgs([]bool{true}) 30 | validateParsing(t, exec, "", args, nil, 31 | err, errors.New("received zero-length argument")) 32 | } 33 | 34 | func validateParsing(t *testing.T, exec, expectedExec string, 35 | args, expectedArgs []string, err, expectedErr error) { 36 | if !reflect.DeepEqual(err, expectedErr) { //}err != expectedErr { 37 | t.Fatalf("expected %s but got %s", expectedErr, err) 38 | } 39 | if exec != expectedExec { 40 | t.Fatalf("executable not parsed: %s != %s", exec, expectedExec) 41 | } 42 | if !reflect.DeepEqual(args, expectedArgs) { 43 | t.Fatalf("args not parsed: %s != %s", args, expectedArgs) 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /commands/testdata/test.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | trap 'exit 2' SIGTERM 4 | 5 | usage() { 6 | cat < 0 { 12 | t.Errorf("Expected no strings, but got %s", interfaces) 13 | } 14 | 15 | test1 := "eth0" 16 | expected1 := []string{test1} 17 | if interfaces, err := ToStrings(test1); err != nil { 18 | t.Errorf("Unexpected parse error: %s", err.Error()) 19 | } else if !reflect.DeepEqual(interfaces, expected1) { 20 | t.Errorf("Expected %s, got: %s", expected1, interfaces) 21 | } 22 | 23 | test2 := []interface{}{"ethwe", "eth0"} 24 | expected2 := []string{"ethwe", "eth0"} 25 | if interfaces, err := ToStrings(test2); err != nil { 26 | t.Errorf("Unexpected parse error: %s", err.Error()) 27 | } else if !reflect.DeepEqual(interfaces, expected2) { 28 | t.Errorf("Expected %s, got: %s", expected2, interfaces) 29 | } 30 | 31 | test3 := map[string]interface{}{"a": true} 32 | if _, err := ToStrings(test3); err == nil { 33 | t.Errorf("Expected parse error for json3") 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /config/logger/README.md: -------------------------------------------------------------------------------- 1 | ## logger 2 | 3 | [![GoDoc](https://godoc.org/github.com/joyent/containerpilot?status.svg)](https://godoc.org/github.com/joyent/containerpilot/config/logger) 4 | -------------------------------------------------------------------------------- /config/logger/logging.go: -------------------------------------------------------------------------------- 1 | // Package logger manages the configuration of logging 2 | package logger 3 | 4 | import ( 5 | "bytes" 6 | "fmt" 7 | "io" 8 | "log" 9 | "os" 10 | "os/signal" 11 | "strings" 12 | "syscall" 13 | "time" 14 | 15 | "github.com/client9/reopen" 16 | "github.com/sirupsen/logrus" 17 | ) 18 | 19 | // Config configures the log levels 20 | type Config struct { 21 | Level string `json:"level"` 22 | Format string `json:"format"` 23 | Output string `json:"output"` 24 | } 25 | 26 | var defaultLog = &Config{ 27 | Level: "INFO", 28 | Format: "default", 29 | Output: "stdout", 30 | } 31 | 32 | func init() { 33 | if err := defaultLog.Init(); err != nil { 34 | log.Println(err) 35 | } 36 | } 37 | 38 | // Init initializes the logger and sets default values if not provided 39 | func (l *Config) Init() error { 40 | // Set defaults 41 | if l.Level == "" { 42 | l.Level = defaultLog.Level 43 | } 44 | if l.Format == "" { 45 | l.Format = defaultLog.Format 46 | } 47 | if l.Output == "" { 48 | l.Output = defaultLog.Output 49 | } 50 | level, err := logrus.ParseLevel(strings.ToLower(l.Level)) 51 | if err != nil { 52 | return fmt.Errorf("Unknown log level '%s': %s", l.Level, err) 53 | } 54 | var formatter logrus.Formatter 55 | var output io.Writer 56 | switch strings.ToLower(l.Format) { 57 | case "text": 58 | formatter = &logrus.TextFormatter{} 59 | case "json": 60 | formatter = &logrus.JSONFormatter{ 61 | TimestampFormat: time.RFC3339Nano, 62 | } 63 | case "default": 64 | formatter = &DefaultLogFormatter{ 65 | TimestampFormat: time.RFC3339Nano, 66 | } 67 | default: 68 | return fmt.Errorf("Unknown log format '%s'", l.Format) 69 | } 70 | switch strings.ToLower(l.Output) { 71 | case "stderr": 72 | output = os.Stderr 73 | case "stdout": 74 | output = os.Stdout 75 | case "": 76 | return fmt.Errorf("Unknown output type '%s'", l.Output) 77 | default: 78 | f, err := reopen.NewFileWriter(l.Output) 79 | if err != nil { 80 | return fmt.Errorf("Error initializing log file '%s': %s", l.Output, err) 81 | } 82 | initializeSignal(f) 83 | output = f 84 | } 85 | logrus.SetLevel(level) 86 | logrus.SetFormatter(formatter) 87 | logrus.SetOutput(output) 88 | return nil 89 | } 90 | 91 | // DefaultLogFormatter delegates formatting to standard go log package 92 | type DefaultLogFormatter struct { 93 | TimestampFormat string 94 | } 95 | 96 | // Format formats the logrus entry by passing it to the "log" package 97 | func (f *DefaultLogFormatter) Format(entry *logrus.Entry) ([]byte, error) { 98 | b := &bytes.Buffer{} 99 | logger := log.New(b, "", 0) 100 | 101 | fields := "" 102 | if len(entry.Data) != 0 { 103 | if jobName := entry.Data["job"]; jobName != nil { 104 | fields = fmt.Sprintf("%s %s", fields, jobName) 105 | } 106 | if pidID := entry.Data["pid"]; pidID != nil { 107 | fields = fmt.Sprintf("%s %d", fields, pidID) 108 | } 109 | } 110 | 111 | logger.Println(time.Now().Format(f.TimestampFormat) + fields + " " + string(entry.Message)) 112 | // Panic and Fatal are handled by logrus automatically 113 | return b.Bytes(), nil 114 | } 115 | 116 | func initializeSignal(f *reopen.FileWriter) { 117 | // Handle SIGUSR1 118 | // 119 | // channel is number of signals needed to catch (more or less) 120 | // we only are working with one here, SIGUSR1 121 | sighup := make(chan os.Signal, 1) 122 | signal.Notify(sighup, syscall.SIGUSR1) 123 | go func() { 124 | for { 125 | <-sighup 126 | f.Reopen() 127 | } 128 | }() 129 | } 130 | -------------------------------------------------------------------------------- /config/logger/logging_test.go: -------------------------------------------------------------------------------- 1 | package logger 2 | 3 | import ( 4 | "io/ioutil" 5 | "os" 6 | "reflect" 7 | "strings" 8 | "testing" 9 | 10 | "github.com/sirupsen/logrus" 11 | ) 12 | 13 | func TestLoggingBootstrap(t *testing.T) { 14 | defaultLog.Init() 15 | std := logrus.StandardLogger() 16 | if std.Level != logrus.InfoLevel { 17 | t.Errorf("Expected INFO level logs, but got: %s", std.Level) 18 | } 19 | if std.Out != os.Stdout { 20 | t.Errorf("Expected output to Stdout") 21 | } 22 | if _, ok := std.Formatter.(*DefaultLogFormatter); !ok { 23 | t.Errorf("Expected *containerpilot.DefaultLogFormatter got: %v", reflect.TypeOf(std.Formatter)) 24 | } 25 | } 26 | 27 | func TestLoggingConfigInit(t *testing.T) { 28 | testLog := &Config{ 29 | Level: "DEBUG", 30 | Format: "text", 31 | Output: "stderr", 32 | } 33 | testLog.Init() 34 | std := logrus.StandardLogger() 35 | if std.Level != logrus.DebugLevel { 36 | t.Errorf("Expected 'debug' level logs, but got: %s", std.Level) 37 | } 38 | if std.Out != os.Stderr { 39 | t.Errorf("Expected output to Stderr") 40 | } 41 | if _, ok := std.Formatter.(*logrus.TextFormatter); !ok { 42 | t.Errorf("Expected *logrus.TextFormatter got: %v", reflect.TypeOf(std.Formatter)) 43 | } 44 | // Reset to defaults 45 | defaultLog.Init() 46 | } 47 | 48 | func TestDefaultFormatterEmptyMessage(t *testing.T) { 49 | formatter := &DefaultLogFormatter{} 50 | _, err := formatter.Format(logrus.WithFields( 51 | logrus.Fields{ 52 | "level": "info", 53 | "msg": "something", 54 | }, 55 | )) 56 | if err != nil { 57 | t.Errorf("Did not expect error: %v", err) 58 | } 59 | } 60 | 61 | func TestDefaultFormatterPanic(t *testing.T) { 62 | defaultLog.Init() 63 | defer func() { 64 | if r := recover(); r == nil { 65 | t.Errorf("Expected panic but did not") 66 | } 67 | }() 68 | logrus.Panicln("Panic Test") 69 | } 70 | 71 | func TestFileLogger(t *testing.T) { 72 | // initialize logger 73 | filename := "/tmp/test_log" 74 | testLog := &Config{ 75 | Level: "DEBUG", 76 | Format: "text", 77 | Output: filename, 78 | } 79 | err := testLog.Init() 80 | if err != nil { 81 | t.Errorf("Did not expect error: %v", err) 82 | } 83 | 84 | // write a log message 85 | logMsg := "this is a test" 86 | logrus.Info(logMsg) 87 | content, err := ioutil.ReadFile(filename) 88 | if err != nil { 89 | t.Errorf("Did not expect error: %v", err) 90 | } 91 | if len(content) == 0 { 92 | t.Error("could not write log to file") 93 | } 94 | logs := string(content) 95 | if !strings.Contains(logs, logMsg) { 96 | t.Errorf("expected log file to contain '%s', got '%s'", logMsg, logs) 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /config/services/README.md: -------------------------------------------------------------------------------- 1 | ## services 2 | 3 | [![GoDoc](https://godoc.org/github.com/joyent/containerpilot?status.svg)](https://godoc.org/github.com/joyent/containerpilot/config/services) 4 | -------------------------------------------------------------------------------- /config/services/docs.go: -------------------------------------------------------------------------------- 1 | // Package services contains miscellaneous configuration validation for 2 | // service names and IP addresses registered with Consul 3 | package services 4 | -------------------------------------------------------------------------------- /config/services/names.go: -------------------------------------------------------------------------------- 1 | package services 2 | 3 | import ( 4 | "fmt" 5 | "regexp" 6 | ) 7 | 8 | var validName = regexp.MustCompile(`^[a-z][a-zA-Z0-9\-]+$`) 9 | 10 | // ValidateName checks if the service name passed as an argument 11 | // is is alpha-numeric with dashes. This ensures compliance with both DNS 12 | // and discovery backends. 13 | func ValidateName(name string) error { 14 | if name == "" { 15 | return fmt.Errorf("'name' must not be blank") 16 | } 17 | if ok := validName.MatchString(name); !ok { 18 | return fmt.Errorf("service names must be alphanumeric with dashes to comply with service discovery") 19 | } 20 | return nil 21 | } 22 | -------------------------------------------------------------------------------- /config/services/names_test.go: -------------------------------------------------------------------------------- 1 | package services 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestValidateName(t *testing.T) { 8 | 9 | var validNames = []string{ 10 | "myService", 11 | "my-service", 12 | "my-service-123", 13 | } 14 | for _, name := range validNames { 15 | if err := ValidateName(name); err != nil { 16 | t.Errorf("expected no error for name '%v' but got %v", name, err) 17 | } 18 | } 19 | 20 | var invalidNames = []string{ 21 | "my_service", 22 | "-my-service", 23 | "my%service", 24 | } 25 | for _, name := range invalidNames { 26 | if err := ValidateName(name); err == nil { 27 | t.Errorf("expected error for name '%v' but got nil", name) 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /config/template/README.md: -------------------------------------------------------------------------------- 1 | ## template 2 | 3 | [![GoDoc](https://godoc.org/github.com/joyent/containerpilot?status.svg)](https://godoc.org/github.com/joyent/containerpilot/config/template) 4 | -------------------------------------------------------------------------------- /config/template/template_test.go: -------------------------------------------------------------------------------- 1 | package template 2 | 3 | import ( 4 | "os" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestParseEnvironment(t *testing.T) { 11 | parsed := parseEnvironment([]string{}) 12 | assert.Equal(t, Environment{}, parsed) 13 | 14 | parsed = parseEnvironment([]string{ 15 | "VAR1=test", 16 | "VAR2=test2", 17 | }) 18 | assert.Equal(t, Environment{ 19 | "VAR1": "test", 20 | "VAR2": "test2", 21 | }, parsed) 22 | } 23 | 24 | func TestTemplate(t *testing.T) { 25 | env := parseEnvironment([]string{ 26 | "NAME=Template", 27 | "USER=pilot", 28 | "PARTS=a:b:c", 29 | "COUNT=3", 30 | }) 31 | // To test env function 32 | os.Setenv("NAME_1", "Template") 33 | defer os.Unsetenv("NAME_1") 34 | 35 | testTemplate := func(name string, template string, expected string) { 36 | tmpl, err := NewTemplate([]byte(template)) 37 | if err != nil { 38 | t.Fatalf("%s - error parsing template: %s", name, err) 39 | } 40 | tmpl.Env = env 41 | res, err2 := tmpl.Execute() 42 | if err2 != nil { 43 | t.Fatalf("%s - error executing template: %s", name, err2) 44 | } 45 | strRes := string(res) 46 | if strRes != expected { 47 | t.Fatalf("%s - expected %s but got: %s", name, expected, strRes) 48 | } 49 | } 50 | 51 | testTemplate("One var", `Hello, {{.NAME}}!`, "Hello, Template!") 52 | testTemplate("Var undefined", `Hello, {{.NONAME}}!`, "Hello, !") 53 | testTemplate("Loop double", `{{ loop 2 5 }}`, "[2 3 4]") 54 | testTemplate("Loop inverse", `{{ loop 10 1 }}`, "[10 9 8 7 6 5 4 3 2]") 55 | testTemplate("Loop single", `{{ loop 5 }}`, "[0 1 2 3 4]") 56 | testTemplate("Loop string", `{{ loop .COUNT }}`, "[0 1 2]") 57 | testTemplate("Loop string range", `{{ loop 1 .COUNT }}`, "[1 2]") 58 | testTemplate("Loop range", 59 | `{{ range $i := loop 2 5 -}}i={{$i}},{{ end }}`, "i=2,i=3,i=4,") 60 | testTemplate("ENV", `Hello, {{ env (printf "NA%s" "ME_1") }}!`, "Hello, Template!") 61 | testTemplate("Default", `Hello, {{.NONAME | default "World" }}!`, "Hello, World!") 62 | testTemplate("Default", `Hello, {{.NONAME | default 100 }}!`, "Hello, 100!") 63 | testTemplate("Default", `Hello, {{.NONAME | default 10.1 }}!`, "Hello, 10.1!") 64 | testTemplate("Split and Join", 65 | `Hello, {{.PARTS | split ":" | join "." }}!`, "Hello, a.b.c!") 66 | testTemplate("Replace All", 67 | `Hello, {{.NAME | replaceAll "e" "_" }}!`, "Hello, T_mplat_!") 68 | testTemplate("Regex Replace All", 69 | `Hello, {{.NAME | regexReplaceAll "[epa]+" "_" }}!`, "Hello, T_m_l_t_!") 70 | } 71 | -------------------------------------------------------------------------------- /config/testdata/test.json5: -------------------------------------------------------------------------------- 1 | { 2 | consul: "consul:8500", 3 | stopTimeout: 5, 4 | jobs: [ 5 | { 6 | // although these are all jobs, we're naming these jobs "services", 7 | // "coprocess", "task", "prestart", etc. to make their role clear 8 | name: "serviceA", 9 | port: 8080, 10 | interfaces: ["inet", "lo0"], 11 | exec: "/bin/serviceA", 12 | when: { 13 | source: "preStart", 14 | once: "exitSuccess" 15 | }, 16 | health: { 17 | exec: "/bin/to/healthcheck/for/service/A.sh", 18 | interval: 19, 19 | ttl: 30, 20 | }, 21 | tags: ["tag1","tag2"] 22 | }, 23 | { 24 | name: "serviceB", 25 | port: 5000, 26 | interfaces: ["ethwe","eth0", "inet", "lo0"], 27 | exec: ["/bin/serviceB", "B"], 28 | health:{ 29 | exec: ["/bin/to/healthcheck/for/service/B.sh", "B"], 30 | timeout: "2s", 31 | interval: 20, 32 | ttl: "103" 33 | } 34 | }, 35 | { 36 | name: "coprocessC", 37 | exec: "/bin/coprocessC", 38 | restarts: "unlimited" 39 | }, 40 | { 41 | name: "periodicTaskD", 42 | exec: "/bin/taskD", 43 | when: { 44 | interval: "1s" 45 | } 46 | }, 47 | { 48 | name: "preStart", 49 | exec: "/bin/to/preStart.sh arg1 arg2" 50 | }, 51 | { 52 | name: "preStop", 53 | exec: ["/bin/to/preStop.sh","arg1","arg2"], 54 | when: { 55 | source: "serviceA", 56 | once: "stopping" 57 | } 58 | }, 59 | { 60 | name: "postStop", 61 | exec: ["/bin/to/postStop.sh"], 62 | when: { 63 | source: "serviceA", 64 | once: "stopped" 65 | } 66 | }, 67 | { 68 | name: "onChange-upstreamA", 69 | exec: ["/bin/onChangeA.sh"], 70 | when: { 71 | source: "watch.upstreamA", 72 | each: "changed" 73 | } 74 | }, 75 | { 76 | name: "onChange-upstreamB", 77 | exec: ["/bin/onChangeB.sh"], 78 | when: { 79 | source: "watch.upstreamB", 80 | each: "healthy" 81 | } 82 | } 83 | ], 84 | watches: [ 85 | { 86 | name: "upstreamA", 87 | interval: 11, 88 | tag: "dev" 89 | }, 90 | { 91 | name: "upstreamB", 92 | interval: 79 93 | } 94 | ], 95 | telemetry: { 96 | port: 9000, 97 | interfaces: ["inet", "lo0"], 98 | tags: ["dev"], 99 | metrics: [ 100 | { 101 | namespace: "org", 102 | subsystem: "app", 103 | name: "zed", 104 | help: "gauge of zeds in org app", 105 | type: "gauge" 106 | } 107 | ] 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /config/timing/README.md: -------------------------------------------------------------------------------- 1 | ## timing 2 | 3 | [![GoDoc](https://godoc.org/github.com/joyent/containerpilot?status.svg)](https://godoc.org/github.com/joyent/containerpilot/config/timing) 4 | -------------------------------------------------------------------------------- /config/timing/duration.go: -------------------------------------------------------------------------------- 1 | // Package timing provides functions for parsing time configurations 2 | // into time.Duration instances 3 | package timing 4 | 5 | import ( 6 | "fmt" 7 | "strconv" 8 | "time" 9 | ) 10 | 11 | // GetTimeout converts a properly formatted string to a Duration, 12 | // returning an error if the Duration can't be parsed 13 | func GetTimeout(timeoutFmt string) (time.Duration, error) { 14 | if timeoutFmt != "" { 15 | timeout, err := ParseDuration(timeoutFmt) 16 | if err != nil { 17 | return time.Duration(0), err 18 | } 19 | return timeout, nil 20 | } 21 | return time.Duration(0), nil 22 | } 23 | 24 | // ParseDuration parses the given duration with multiple type support 25 | // int (defaults to seconds) 26 | // string with units 27 | // string without units (default to seconds) 28 | func ParseDuration(duration interface{}) (time.Duration, error) { 29 | switch t := duration.(type) { 30 | default: 31 | return time.Second, fmt.Errorf("unexpected duration of type %T", t) 32 | case int64: 33 | return time.Duration(t) * time.Second, nil 34 | case int32: 35 | return time.Duration(t) * time.Second, nil 36 | case int16: 37 | return time.Duration(t) * time.Second, nil 38 | case int8: 39 | return time.Duration(t) * time.Second, nil 40 | case int: 41 | return time.Duration(t) * time.Second, nil 42 | case uint64: 43 | return time.Duration(t) * time.Second, nil 44 | case uint32: 45 | return time.Duration(t) * time.Second, nil 46 | case uint16: 47 | return time.Duration(t) * time.Second, nil 48 | case uint8: 49 | return time.Duration(t) * time.Second, nil 50 | case uint: 51 | return time.Duration(t) * time.Second, nil 52 | case string: 53 | if i, err := strconv.Atoi(t); err == nil { 54 | return time.ParseDuration(fmt.Sprintf("%ds", i)) 55 | } 56 | return time.ParseDuration(t) 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /config/timing/duration_test.go: -------------------------------------------------------------------------------- 1 | package timing 2 | 3 | import ( 4 | "errors" 5 | "strings" 6 | "testing" 7 | "time" 8 | ) 9 | 10 | func TestGetTimeout(t *testing.T) { 11 | var ( 12 | dur time.Duration 13 | err error 14 | ) 15 | dur, err = GetTimeout("1s") 16 | expectDurationCompare(t, dur, time.Duration(time.Second), err, nil) 17 | 18 | dur, err = GetTimeout("") 19 | expectDurationCompare(t, dur, time.Duration(0), err, nil) 20 | 21 | dur, err = GetTimeout("x") 22 | expectDurationCompare(t, dur, time.Duration(0), 23 | err, errors.New("time: invalid duration x")) 24 | 25 | dur, err = GetTimeout("0") 26 | expectDurationCompare(t, dur, time.Duration(0), err, nil) 27 | 28 | dur, err = GetTimeout("1h") 29 | expectDurationCompare(t, dur, time.Duration(time.Hour), err, nil) 30 | 31 | // TODO: we can't really do this in the GetTimeout b/c of the need 32 | // to support commands without timeout. In v3 we should consider 33 | // forcing this requirement. 34 | // dur, err = GetTimeout("1ns") 35 | // expectDurationCompare(t, dur, time.Duration(0), 36 | // err, errors.New("timeout 1ns cannot be less that 1ms")) 37 | 38 | } 39 | 40 | func TestParseDuration(t *testing.T) { 41 | 42 | // Bare ints 43 | expectDuration(t, 1, time.Second) 44 | expectDuration(t, 10, 10*time.Second) 45 | 46 | // Other ints 47 | expectDuration(t, int64(10), 10*time.Second) 48 | expectDuration(t, int32(10), 10*time.Second) 49 | expectDuration(t, int16(10), 10*time.Second) 50 | expectDuration(t, int8(10), 10*time.Second) 51 | expectDuration(t, uint64(10), 10*time.Second) 52 | expectDuration(t, uint32(10), 10*time.Second) 53 | expectDuration(t, uint16(10), 10*time.Second) 54 | expectDuration(t, uint8(10), 10*time.Second) 55 | 56 | // Without Units 57 | expectDuration(t, "1", time.Second) 58 | expectDuration(t, "10", 10*time.Second) 59 | 60 | // With Units 61 | expectDuration(t, "10ns", 10*time.Nanosecond) 62 | expectDuration(t, "10us", 10*time.Microsecond) 63 | expectDuration(t, "10ms", 10*time.Millisecond) 64 | expectDuration(t, "10s", 10*time.Second) 65 | expectDuration(t, "10m", 10*time.Minute) 66 | expectDuration(t, "10h", 10*time.Hour) 67 | 68 | // Some parse errors 69 | expectError(t, "asf", "invalid duration") 70 | expectError(t, "20yy", "unknown unit yy") 71 | 72 | // Fractional 73 | expectError(t, 10.10, "unexpected duration of type float") 74 | } 75 | 76 | func expectDurationCompare(t *testing.T, actual, expected time.Duration, 77 | err, expectedErr error) { 78 | 79 | if expectedErr == nil && err != nil { 80 | t.Fatalf("got unexpected error '%s'", err) 81 | } 82 | if expectedErr != nil && err == nil { 83 | t.Fatalf("did not get expected error '%s'", expectedErr) 84 | } 85 | if expectedErr != nil && err.Error() != expectedErr.Error() { 86 | t.Fatalf("expected error '%s' but got '%s'", expectedErr, err) 87 | } 88 | if expected != actual { 89 | t.Errorf("expected duration %v but got %v", expected, actual) 90 | } 91 | } 92 | 93 | func expectDuration(t *testing.T, in interface{}, expected time.Duration) { 94 | actual, err := ParseDuration(in) 95 | if err != nil { 96 | t.Errorf("Expected %v but got error: %v", expected, err) 97 | } 98 | if actual != expected { 99 | t.Errorf("Expected %v but got %v", expected, actual) 100 | } 101 | } 102 | 103 | func expectError(t *testing.T, in interface{}, errContains string) { 104 | actual, err := ParseDuration(in) 105 | if err == nil { 106 | t.Errorf("Expected error but got: %v", actual) 107 | } 108 | if !strings.Contains(err.Error(), errContains) { 109 | t.Errorf("Expected error '*%s*' but got: %v", errContains, err) 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /control/README.md: -------------------------------------------------------------------------------- 1 | ## control 2 | 3 | [![GoDoc](https://godoc.org/github.com/joyent/containerpilot?status.svg)](https://godoc.org/github.com/joyent/containerpilot/control) 4 | -------------------------------------------------------------------------------- /control/config.go: -------------------------------------------------------------------------------- 1 | package control 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/joyent/containerpilot/config/decode" 7 | ) 8 | 9 | // DefaultSocket is the default location of the unix domain socket file 10 | var DefaultSocket = "/var/run/containerpilot.socket" 11 | 12 | // Config represents the location on the file system which serves the Unix 13 | // control socket file. 14 | type Config struct { 15 | SocketPath string `mapstructure:"socket"` 16 | } 17 | 18 | // NewConfig parses a json config into a validated Config used by control 19 | // Server. 20 | func NewConfig(raw interface{}) (*Config, error) { 21 | cfg := &Config{SocketPath: DefaultSocket} // defaults 22 | if raw == nil { 23 | return cfg, nil 24 | } 25 | 26 | if err := decode.ToStruct(raw, cfg); err != nil { 27 | return nil, fmt.Errorf("control config parsing error: %v", err) 28 | } 29 | 30 | return cfg, nil 31 | } 32 | -------------------------------------------------------------------------------- /control/config_test.go: -------------------------------------------------------------------------------- 1 | package control 2 | 3 | import ( 4 | "strings" 5 | "testing" 6 | 7 | "github.com/joyent/containerpilot/tests" 8 | ) 9 | 10 | func TestControlConfigDefault(t *testing.T) { 11 | cfg, err := NewConfig(nil) 12 | if err != nil { 13 | t.Fatalf("could not parse control config JSON: %s", err) 14 | } 15 | 16 | if strings.Compare(cfg.SocketPath, DefaultSocket) != 0 { 17 | t.Fatal("parsed socket does not match default socket") 18 | } 19 | } 20 | 21 | func TestControlConfigParse(t *testing.T) { 22 | testSocket := "/var/run/cp3.sock" 23 | testRaw := tests.DecodeRaw(`{ "socket": "/var/run/cp3.sock" }`) 24 | if testRaw == nil { 25 | t.Fatal("parsed empty control config JSON") 26 | } 27 | 28 | cfg, err := NewConfig(testRaw) 29 | if err != nil { 30 | t.Fatalf("could not parse control config JSON: %s", err) 31 | } 32 | 33 | if strings.Compare(cfg.SocketPath, testSocket) != 0 { 34 | t.Fatal("parsed socket does not match custom socket") 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /control/control_test.go: -------------------------------------------------------------------------------- 1 | package control 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "math/rand" 7 | "net" 8 | "net/http" 9 | "os" 10 | "path/filepath" 11 | "testing" 12 | "time" 13 | 14 | "github.com/stretchr/testify/assert" 15 | 16 | "github.com/joyent/containerpilot/events" 17 | "github.com/joyent/containerpilot/tests" 18 | ) 19 | 20 | func init() { 21 | rand.Seed(time.Now().UTC().UnixNano()) 22 | } 23 | 24 | func socketDialer(tempSocketPath string) func(string, string) (net.Conn, error) { 25 | return func(_, _ string) (net.Conn, error) { 26 | return net.Dial(SocketType, tempSocketPath) 27 | } 28 | } 29 | 30 | func tempSocketPath() string { 31 | filename := fmt.Sprintf("containerpilot-test-socket-%d", rand.Int()) 32 | return filepath.Join(os.TempDir(), filename) 33 | } 34 | 35 | func SetupHTTPServer(t *testing.T, raw string) *HTTPServer { 36 | testRaw := tests.DecodeRaw(raw) 37 | cfg, err := NewConfig(testRaw) 38 | if err != nil { 39 | t.Fatal("parsed empty control config JSON") 40 | } 41 | 42 | s, err := NewHTTPServer(cfg) 43 | s.Bus = events.NewEventBus() 44 | s.Register(s.Bus) 45 | 46 | if err != nil { 47 | t.Fatalf("Could not init control server: %s", err) 48 | } 49 | return s 50 | } 51 | 52 | func TestNewHTTPServer(t *testing.T) { 53 | s := SetupHTTPServer(t, `{}`) 54 | defer os.Remove(DefaultSocket) 55 | assert.Equal(t, s.Addr, DefaultSocket, "expected server addr to ref default socket") 56 | 57 | tempSocketPath := tempSocketPath() 58 | defer os.Remove(tempSocketPath) 59 | s = SetupHTTPServer(t, fmt.Sprintf(`{ "socket": %q }`, tempSocketPath)) 60 | assert.Equal(t, s.Addr, tempSocketPath, "expected server addr to ref default socket") 61 | } 62 | 63 | func TestValidate(t *testing.T) { 64 | srv := &HTTPServer{ 65 | Addr: "", 66 | } 67 | if err := srv.Validate(); assert.NotNil(t, err) { 68 | assert.Equal(t, ErrMissingAddr, err, "expected missing addr error") 69 | } 70 | 71 | socketPath := tempSocketPath() 72 | srv = &HTTPServer{ 73 | Addr: socketPath, 74 | } 75 | defer os.Remove(socketPath) 76 | if _, err := os.Create(socketPath); err != nil { 77 | assert.Nil(t, err, "expected test socket to be created") 78 | } 79 | if err := srv.Validate(); assert.Nil(t, err) { 80 | _, err := os.Stat(socketPath) 81 | assert.True(t, os.IsNotExist(err), "expected test socket to no longer exist") 82 | } 83 | } 84 | 85 | func TestServerSmokeTest(t *testing.T) { 86 | tempSocketPath := tempSocketPath() 87 | defer os.Remove(tempSocketPath) 88 | _, cancel := context.WithCancel(context.Background()) 89 | 90 | s := SetupHTTPServer(t, fmt.Sprintf(`{ "socket": %q}`, tempSocketPath)) 91 | defer s.Stop() 92 | s.Start(cancel) 93 | 94 | client := &http.Client{ 95 | Transport: &http.Transport{ 96 | Dial: socketDialer(tempSocketPath), 97 | }, 98 | } 99 | 100 | // note the host name 'control' is meaningless here but the client 101 | // requires it for the connection string 102 | resp, err := client.Get("http://control/v3/xxxx") 103 | if err != nil { 104 | t.Fatal(err) 105 | } 106 | defer resp.Body.Close() 107 | if resp.StatusCode != http.StatusNotFound { 108 | t.Fatalf("expected 404 but got %v\n%+v", resp.StatusCode, resp) 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /core/README.md: -------------------------------------------------------------------------------- 1 | ## core 2 | 3 | [![GoDoc](https://godoc.org/github.com/joyent/containerpilot?status.svg)](https://godoc.org/github.com/joyent/containerpilot/core) 4 | -------------------------------------------------------------------------------- /core/flags.go: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | "os" 7 | "strings" 8 | 9 | "github.com/joyent/containerpilot/subcommands" 10 | "github.com/joyent/containerpilot/version" 11 | ) 12 | 13 | // MultiFlag provides a custom CLI flag that stores its unique values into a 14 | // simple map. 15 | type MultiFlag struct { 16 | Values map[string]string 17 | } 18 | 19 | // String satisfies the flag.Value interface by joining together the flag values 20 | // map into a single String. 21 | func (f MultiFlag) String() string { 22 | return fmt.Sprintf("%v", f.Values) 23 | } 24 | 25 | // Set satisfies the flag.Value interface by creating a map of all unique CLI 26 | // flag values. 27 | func (f *MultiFlag) Set(value string) error { 28 | if f.Len() == 0 { 29 | f.Values = make(map[string]string, 1) 30 | } 31 | pair := strings.SplitN(value, "=", 2) 32 | if len(pair) < 2 { 33 | return fmt.Errorf("flag value '%v' was not in the format 'key=val'", value) 34 | } 35 | f.Values[pair[0]] = pair[1] 36 | return nil 37 | } 38 | 39 | // Len is the length of the slice of values for this MultiFlag. 40 | func (f MultiFlag) Len() int { 41 | return len(f.Values) 42 | } 43 | 44 | // GetArgs parses the command line flags and returns the subcommand 45 | // we need and its parameters (if any) 46 | func GetArgs() (subcommands.Handler, subcommands.Params) { 47 | 48 | var versionFlag bool 49 | var templateFlag bool 50 | var reloadFlag bool 51 | var pingFlag bool 52 | 53 | var configPath string 54 | var renderFlag string 55 | var maintFlag string 56 | 57 | var putMetricFlags MultiFlag 58 | var putEnvFlags MultiFlag 59 | 60 | if !flag.Parsed() { 61 | flag.BoolVar(&versionFlag, "version", false, 62 | "Show version identifier and quit.") 63 | 64 | flag.BoolVar(&templateFlag, "template", false, 65 | "Render template and quit.") 66 | 67 | flag.BoolVar(&reloadFlag, "reload", false, 68 | "Reload a ContainerPilot process through its control socket.") 69 | 70 | flag.StringVar(&configPath, "config", "", 71 | "File path to JSON5 configuration file. Defaults to CONTAINERPILOT env var.") 72 | 73 | flag.StringVar(&renderFlag, "out", "", 74 | `File path where to save rendered config file when '-template' is used. 75 | Defaults to stdout ('-').`) 76 | 77 | flag.StringVar(&maintFlag, "maintenance", "", 78 | `Toggle maintenance mode for a ContainerPilot process through its control socket. 79 | Options: '-maintenance enable' or '-maintenance disable'`) 80 | 81 | flag.Var(&putMetricFlags, "putmetric", 82 | `Update metrics of a ContainerPilot process through its control socket. 83 | Pass metrics in the format: 'key=value'`) 84 | 85 | flag.Var(&putEnvFlags, "putenv", 86 | `Update environ of a ContainerPilot process through its control socket. 87 | Pass environment in the format: 'key=value'`) 88 | 89 | flag.BoolVar(&pingFlag, "ping", false, 90 | "Check that the ContainerPilot control socket is up.") 91 | 92 | flag.Parse() 93 | } 94 | 95 | if versionFlag { 96 | return subcommands.VersionHandler, subcommands.Params{ 97 | Version: version.Version, 98 | GitHash: version.GitHash, 99 | } 100 | } 101 | if configPath == "" { 102 | configPath = os.Getenv("CONTAINERPILOT") 103 | } 104 | if templateFlag { 105 | return subcommands.RenderHandler, subcommands.Params{ 106 | ConfigPath: configPath, 107 | RenderFlag: renderFlag, 108 | } 109 | } 110 | if reloadFlag { 111 | return subcommands.ReloadHandler, subcommands.Params{ 112 | ConfigPath: configPath, 113 | } 114 | } 115 | if maintFlag != "" { 116 | return subcommands.MaintenanceHandler, subcommands.Params{ 117 | ConfigPath: configPath, 118 | MaintenanceFlag: maintFlag, 119 | } 120 | } 121 | if putEnvFlags.Len() != 0 { 122 | return subcommands.PutEnvHandler, subcommands.Params{ 123 | ConfigPath: configPath, 124 | Env: putEnvFlags.Values, 125 | } 126 | } 127 | if putMetricFlags.Len() != 0 { 128 | return subcommands.PutMetricsHandler, subcommands.Params{ 129 | ConfigPath: configPath, 130 | Metrics: putMetricFlags.Values, 131 | } 132 | } 133 | if pingFlag { 134 | return subcommands.GetPingHandler, subcommands.Params{ 135 | ConfigPath: configPath, 136 | } 137 | } 138 | 139 | return nil, subcommands.Params{ConfigPath: configPath} 140 | } 141 | -------------------------------------------------------------------------------- /core/flags_test.go: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | "os" 7 | "testing" 8 | 9 | "github.com/stretchr/testify/assert" 10 | ) 11 | 12 | func TestInvalidConfigNoConfigFlag(t *testing.T) { 13 | defer argTestCleanup(argTestSetup()) 14 | os.Args = []string{"this", "/testdata/test.sh", "invalid1", "--debug"} 15 | _, p := GetArgs() 16 | if _, err := NewApp(p.ConfigPath); err != nil && err.Error() != "-config flag is required" { 17 | t.Errorf("expected error but got %s", err) 18 | } 19 | } 20 | 21 | func TestInvalidConfigParseNoDiscovery(t *testing.T) { 22 | defer argTestCleanup(argTestSetup()) 23 | f1 := testCfgToTempFile(t, "{}") 24 | defer os.Remove(f1.Name()) 25 | os.Args = []string{"this", "-config", f1.Name()} 26 | _, p := GetArgs() 27 | _, err := NewApp(p.ConfigPath) 28 | assert.Error(t, err, "no discovery backend defined") 29 | } 30 | 31 | func TestInvalidConfigMissingFile(t *testing.T) { 32 | defer argTestCleanup(argTestSetup()) 33 | os.Args = []string{"this", "-config", "/xxxx"} 34 | _, p := GetArgs() 35 | _, err := NewApp(p.ConfigPath) 36 | assert.Error(t, err, 37 | "could not read config file: open /xxxx: no such file or directory") 38 | } 39 | 40 | func TestInvalidConfigParseNotJson(t *testing.T) { 41 | defer argTestCleanup(argTestSetup()) 42 | f1 := testCfgToTempFile(t, "<>") 43 | defer os.Remove(f1.Name()) 44 | os.Args = []string{"this", "-config", f1.Name()} 45 | _, p := GetArgs() 46 | _, err := NewApp(p.ConfigPath) 47 | assert.Error(t, fmt.Errorf("%s", err.Error()[:29]), 48 | "parse error at line:col [1:1]") 49 | } 50 | 51 | func TestInvalidConfigParseTemplateError(t *testing.T) { 52 | defer argTestCleanup(argTestSetup()) 53 | // this config is missing quotes around the template 54 | f1 := testCfgToTempFile(t, `{"test": {{ .NO_SUCH_KEY }}, "test2": "hello"}`) 55 | defer os.Remove(f1.Name()) 56 | os.Args = []string{"this", "-config", f1.Name()} 57 | _, p := GetArgs() 58 | _, err := NewApp(p.ConfigPath) 59 | assert.Error(t, fmt.Errorf("%s", err.Error()[:30]), 60 | "parse error at line:col [1:10]") 61 | } 62 | 63 | func TestControlServerCreation(t *testing.T) { 64 | f1 := testCfgToTempFile(t, `{"consul": "consul:8500"}`) 65 | defer os.Remove(f1.Name()) 66 | app, err := NewApp(f1.Name()) 67 | if err != nil { 68 | t.Fatalf("got error while initializing config: %v", err) 69 | } 70 | if app.ControlServer == nil { 71 | t.Error("expected control server to not be nil") 72 | } 73 | } 74 | 75 | func TestPidEnvVar(t *testing.T) { 76 | defer argTestCleanup(argTestSetup()) 77 | os.Args = []string{"this", "-config", "{}", "/testdata/test.sh"} 78 | _, p := GetArgs() 79 | NewApp(p.ConfigPath) 80 | if pid := os.Getenv("CONTAINERPILOT_PID"); pid == "" { 81 | t.Errorf("expected CONTAINERPILOT_PID to be set even on error") 82 | } 83 | } 84 | 85 | func TestSetEqual(t *testing.T) { 86 | defer argTestCleanup(argTestSetup()) 87 | os.Args = []string{"this", "-config", "{}", "-putenv", "ENV_VALUE=PART1=PART2"} 88 | _, p := GetArgs() 89 | if value, ok := p.Env["ENV_VALUE"]; !ok || value != "PART1=PART2" { 90 | t.Errorf("expected ENV_VALUE to be set to 'PART1=PART2'") 91 | } 92 | } 93 | 94 | // ---------------------------------------------------- 95 | // test helpers 96 | 97 | func argTestSetup() []string { 98 | flag.CommandLine = flag.NewFlagSet(os.Args[0], flag.ContinueOnError) 99 | flag.Usage = nil 100 | return os.Args 101 | } 102 | 103 | func argTestCleanup(oldArgs []string) { 104 | os.Args = oldArgs 105 | } 106 | -------------------------------------------------------------------------------- /core/signals.go: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | import ( 4 | "os" 5 | "os/signal" 6 | "syscall" 7 | ) 8 | 9 | // HandleSignals listens for and captures signals used for orchestration 10 | func (a *App) handleSignals() { 11 | recvSig := make(chan os.Signal, 1) 12 | signal.Notify(recvSig, 13 | syscall.SIGTERM, 14 | syscall.SIGINT, 15 | syscall.SIGHUP, 16 | syscall.SIGUSR2, 17 | ) 18 | go func() { 19 | for { 20 | sig := <-recvSig 21 | switch sig { 22 | case syscall.SIGINT, syscall.SIGTERM: 23 | a.Terminate() 24 | case syscall.SIGHUP, syscall.SIGUSR2: 25 | if s := toString(sig); s != "" { 26 | a.SignalEvent(s) 27 | } 28 | } 29 | } 30 | }() 31 | } 32 | 33 | func toString(sig os.Signal) string { 34 | switch sig { 35 | case syscall.SIGHUP: 36 | return "SIGHUP" 37 | case syscall.SIGUSR2: 38 | return "SIGUSR2" 39 | default: 40 | return "" 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /core/testdata/test.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | trap 'exit 2' SIGTERM 4 | 5 | usage() { 6 | cat <= EventCode(len(eventCodeindex)-1) { 13 | return fmt.Sprintf("EventCode(%d)", i) 14 | } 15 | return eventCodename[eventCodeindex[i]:eventCodeindex[i+1]] 16 | } 17 | -------------------------------------------------------------------------------- /events/events.go: -------------------------------------------------------------------------------- 1 | // Package events contains the internal message bus used to broadcast 2 | // events between goroutines representing jobs, watches, etc. 3 | package events 4 | 5 | import ( 6 | "fmt" 7 | ) 8 | 9 | // Event represents a single message in the EventBus 10 | type Event struct { 11 | Code EventCode 12 | Source string 13 | } 14 | 15 | // go:generate stringer -type EventCode 16 | 17 | // EventCode is an enum for Events 18 | type EventCode int 19 | 20 | // EventCode enum 21 | const ( 22 | None EventCode = iota // placeholder nil-event 23 | ExitSuccess // emitted when a Runner's exec completes with 0 exit code 24 | ExitFailed // emitted when a Runner's exec completes with non-0 exit code 25 | Stopping // emitted when a Runner is about to stop 26 | Stopped // emitted when a Runner has stopped 27 | StatusHealthy 28 | StatusUnhealthy 29 | StatusChanged 30 | TimerExpired 31 | EnterMaintenance 32 | ExitMaintenance 33 | Error 34 | Quit 35 | Metric 36 | Startup // fired once after events are set up and event loop is started 37 | Shutdown // fired once after all jobs exit or on receiving SIGTERM 38 | Signal // fired when a UNIX signal hits a CP process/supervisor 39 | ) 40 | 41 | // global events 42 | var ( 43 | GlobalStartup = Event{Code: Startup, Source: "global"} 44 | GlobalShutdown = Event{Code: Shutdown, Source: "global"} 45 | NonEvent = Event{Code: None, Source: ""} 46 | GlobalEnterMaintenance = Event{Code: EnterMaintenance, Source: "global"} 47 | GlobalExitMaintenance = Event{Code: ExitMaintenance, Source: "global"} 48 | QuitByTest = Event{Code: Quit, Source: "closed"} 49 | ) 50 | 51 | // FromString parses a string as an EventCode enum 52 | func FromString(codeName string) (EventCode, error) { 53 | switch codeName { 54 | case "exitSuccess": 55 | return ExitSuccess, nil 56 | case "exitFailed": 57 | return ExitFailed, nil 58 | case "stopping": 59 | return Stopping, nil 60 | case "stopped": 61 | return Stopped, nil 62 | case "healthy": 63 | return StatusHealthy, nil 64 | case "unhealthy": 65 | return StatusUnhealthy, nil 66 | case "changed": 67 | return StatusChanged, nil 68 | case "timerExpired": 69 | return TimerExpired, nil // end-users shouldn't use this in configs 70 | case "enterMaintenance": 71 | return EnterMaintenance, nil 72 | case "exitMaintenance": 73 | return ExitMaintenance, nil 74 | case "error": 75 | return Error, nil // end-users shouldn't use this in configs 76 | case "quit": 77 | return Quit, nil // end-users shouldn't use this in configs 78 | case "startup": 79 | return Startup, nil 80 | case "shutdown": 81 | return Shutdown, nil 82 | case "SIGHUP", "SIGUSR2": 83 | return Signal, nil 84 | } 85 | return None, fmt.Errorf("%s is not a valid event code", codeName) 86 | } 87 | -------------------------------------------------------------------------------- /events/events_test.go: -------------------------------------------------------------------------------- 1 | package events 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "reflect" 7 | "sync" 8 | "testing" 9 | 10 | "github.com/stretchr/testify/assert" 11 | ) 12 | 13 | type TestPublisher struct { 14 | Publisher 15 | } 16 | 17 | func NewTestPublisher(bus *EventBus) *TestPublisher { 18 | pub := &TestPublisher{} 19 | pub.Register(bus) 20 | return pub 21 | } 22 | 23 | type TestSubscriber struct { 24 | results []Event 25 | lock *sync.RWMutex 26 | 27 | Subscriber 28 | } 29 | 30 | func NewTestSubscriber() *TestSubscriber { 31 | sub := &TestSubscriber{ 32 | lock: &sync.RWMutex{}, 33 | results: []Event{}, 34 | } 35 | sub.Rx = make(chan Event, 100) 36 | return sub 37 | } 38 | 39 | func (ts *TestSubscriber) Run(ctx context.Context, bus *EventBus) { 40 | ts.Subscribe(bus) 41 | go func() { 42 | defer func() { 43 | ts.Unsubscribe() 44 | ts.Wait() 45 | close(ts.Rx) 46 | }() 47 | for { 48 | select { 49 | case event, ok := <-ts.Rx: 50 | if !ok { 51 | return 52 | } 53 | ts.lock.Lock() 54 | ts.results = append(ts.results, event) 55 | ts.lock.Unlock() 56 | case <-ctx.Done(): 57 | return 58 | } 59 | } 60 | }() 61 | } 62 | 63 | // Plumb a basic pub/sub interaction to test out the choreography between them. 64 | func TestPubSubInterfaces(t *testing.T) { 65 | bus := NewEventBus() 66 | tp := NewTestPublisher(bus) 67 | defer tp.Unregister() 68 | ts := NewTestSubscriber() 69 | ctx, cancel := context.WithCancel(context.Background()) 70 | ts.Run(ctx, bus) 71 | 72 | expected := []Event{ 73 | Event{Startup, "serviceA"}, 74 | } 75 | for _, event := range expected { 76 | tp.Publish(event) 77 | } 78 | cancel() 79 | results := bus.DebugEvents() 80 | 81 | if !reflect.DeepEqual(expected, results) { 82 | t.Fatalf("expected: %v\ngot: %v", expected, results) 83 | } 84 | 85 | for n, found := range results { 86 | mesg := fmt.Sprintf("expected: %v\ngot: %v", expected, results) 87 | assert.Equal(t, expected[n], found, mesg) 88 | } 89 | } 90 | 91 | func TestPublishSignal(t *testing.T) { 92 | bus := NewEventBus() 93 | ts := NewTestSubscriber() 94 | ctx, cancel := context.WithCancel(context.Background()) 95 | ts.Run(ctx, bus) 96 | 97 | signals := []string{"SIGHUP", "SIGUSR2"} 98 | expected := make([]Event, len(signals)) 99 | for n, sig := range signals { 100 | expected[n] = Event{Code: Signal, Source: sig} 101 | bus.PublishSignal(sig) 102 | } 103 | cancel() 104 | results := bus.DebugEvents() 105 | 106 | if !reflect.DeepEqual(expected, results) { 107 | t.Fatalf("expected: %v\ngot: %v", expected, ts.results) 108 | } 109 | 110 | for n, found := range results { 111 | assert.Equal(t, found, expected[n]) 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /events/publisher.go: -------------------------------------------------------------------------------- 1 | package events 2 | 3 | // EventPublisher is an interface for publishers that register/unregister from 4 | // the EventBus and publish Events. 5 | type EventPublisher interface { 6 | Publish(Event) 7 | Register(*EventBus) 8 | Unregister() 9 | } 10 | 11 | // Publisher represents an object with a Bus that implements the EventPublisher 12 | // interface. 13 | type Publisher struct { 14 | Bus *EventBus 15 | } 16 | 17 | // Publish publishes an Event across the Publisher's EventBus 18 | func (pub *Publisher) Publish(event Event) { 19 | pub.Bus.Publish(event) 20 | } 21 | 22 | // Register registers the Publisher with the EventBus. 23 | func (pub *Publisher) Register(bus *EventBus) { 24 | pub.Bus = bus 25 | bus.Register(pub) 26 | } 27 | 28 | // Unregister unregisters the Publisher from the EventBus. 29 | func (pub *Publisher) Unregister() { 30 | pub.Bus.Unregister(pub) 31 | } 32 | 33 | // Wait blocks for the EventBus wait group counter to count down to zero. 34 | func (pub *Publisher) Wait() { 35 | pub.Bus.done.Wait() 36 | } 37 | -------------------------------------------------------------------------------- /events/subscriber.go: -------------------------------------------------------------------------------- 1 | package events 2 | 3 | // EventSubscriber is an interface for subscribers that subscribe/unsubscribe 4 | // from the EventBus and receive Events. 5 | type EventSubscriber interface { 6 | Subscribe(*EventBus) 7 | Unsubscribe() 8 | Receive(Event) 9 | } 10 | 11 | // Subscriber represents an object which recieves events through the Event bus 12 | // through its receive channel. 13 | type Subscriber struct { 14 | Rx chan Event 15 | Bus *EventBus 16 | } 17 | 18 | // Subscribe subscribes a subscriber to the EventBus 19 | func (sub *Subscriber) Subscribe(bus *EventBus) { 20 | sub.Bus = bus 21 | bus.Subscribe(sub) 22 | } 23 | 24 | // Unsubscribe unsubscribes the subscriber from the EventBus. 25 | func (sub *Subscriber) Unsubscribe() { 26 | sub.Bus.Unsubscribe(sub) 27 | } 28 | 29 | // Receive receives an Event through the receive channel. 30 | func (sub *Subscriber) Receive(event Event) { 31 | sub.Rx <- event 32 | } 33 | 34 | // Wait waits for the subscriber's EventBus to complete its wait group. 35 | func (sub *Subscriber) Wait() { 36 | sub.Bus.done.Wait() 37 | } 38 | -------------------------------------------------------------------------------- /events/timer.go: -------------------------------------------------------------------------------- 1 | package events 2 | 3 | import ( 4 | "context" 5 | "time" 6 | 7 | log "github.com/sirupsen/logrus" 8 | ) 9 | 10 | // NewEventTimeout starts a goroutine on a timer that will send a 11 | // TimerExpired event when the timer expires 12 | func NewEventTimeout( 13 | ctx context.Context, 14 | rx chan Event, 15 | tick time.Duration, 16 | name string, 17 | ) { 18 | go func() { 19 | timeout := time.After(tick) 20 | select { 21 | case <-ctx.Done(): 22 | return 23 | case <-timeout: 24 | // sending the timeout event potentially races with a closing 25 | // rx channel, so just recover from the panic and exit 26 | defer func() { 27 | if r := recover(); r != nil { 28 | return 29 | } 30 | }() 31 | event := Event{Code: TimerExpired, Source: name} 32 | log.Debugf("timeout: %v", event) 33 | rx <- event 34 | } 35 | }() 36 | } 37 | 38 | // NewEventTimer starts a goroutine with a timer that will send a 39 | // TimerExpired event every time the timer expires 40 | func NewEventTimer( 41 | ctx context.Context, 42 | rx chan Event, 43 | tick time.Duration, 44 | name string, 45 | ) { 46 | go func() { 47 | ticker := time.NewTicker(tick) 48 | // sending the timeout event potentially races with a closing 49 | // rx channel, so just recover from the panic and exit 50 | defer func() { 51 | if r := recover(); r != nil { 52 | return 53 | } 54 | }() 55 | for { 56 | select { 57 | case <-ctx.Done(): 58 | return 59 | case <-ticker.C: 60 | event := Event{Code: TimerExpired, Source: name} 61 | // do not log the telemetry health check timer ticks since this 62 | // log statement is called once for every internal heartbeat 63 | // check, which is a bit excessive under DEBUG logging [GH-556] 64 | if event.Source != "containerpilot.heartbeat" { 65 | log.Debugf("timer: %v", event) 66 | } 67 | rx <- event 68 | } 69 | } 70 | }() 71 | } 72 | -------------------------------------------------------------------------------- /glide.lock: -------------------------------------------------------------------------------- 1 | hash: c13286385a0d957eb3b4e47af1f85b6cd8d8b084c846737477f46ac04d2c44f2 2 | updated: 2017-11-13T21:22:43.229954534-05:00 3 | imports: 4 | - name: github.com/beorn7/perks 5 | version: 4c0e84591b9aa9e6dcfdf3e020114cd81f89d5f9 6 | subpackages: 7 | - quantile 8 | - name: github.com/client9/reopen 9 | version: 1a6ccbeaae3f56aa0058f5491382cb21726e214e 10 | - name: github.com/flynn/json5 11 | version: 7620272ed63390e979cf5882d2fa0506fe2a8db5 12 | - name: github.com/golang/protobuf 13 | version: 6a1fa9404c0aebf36c879bc50152edcc953910d2 14 | subpackages: 15 | - proto 16 | - name: github.com/hashicorp/consul 17 | version: 783a405d781ffc5edaf2d6b5eff41e22df96644f 18 | subpackages: 19 | - api 20 | - testutil/retry 21 | - name: github.com/hashicorp/go-cleanhttp 22 | version: 3573b8b52aa7b37b9358d966a898feb387f62437 23 | - name: github.com/hashicorp/go-rootcerts 24 | version: 6bb64b370b90e7ef1fa532be9e591a81c3493e00 25 | - name: github.com/hashicorp/serf 26 | version: 91fd53b1d3e624389ed9a295a3fa380e5c7b9dfc 27 | subpackages: 28 | - coordinate 29 | - name: github.com/matttproud/golang_protobuf_extensions 30 | version: c12348ce28de40eed0136aa2b644d0ee0650e56c 31 | subpackages: 32 | - pbutil 33 | - name: github.com/mitchellh/go-homedir 34 | version: b8bc1bf767474819792c23f32d8286a45736f1c6 35 | - name: github.com/mitchellh/mapstructure 36 | version: d2dd0262208475919e1a362f675cfc0e7c10e905 37 | - name: github.com/prometheus/client_golang 38 | version: c5b7fccd204277076155f10851dad72b76a49317 39 | subpackages: 40 | - prometheus 41 | - name: github.com/prometheus/client_model 42 | version: 6f3806018612930941127f2a7c6c453ba2c527d2 43 | subpackages: 44 | - go 45 | - name: github.com/prometheus/common 46 | version: 0866df4b85a18d652b6965be022d007cdf076822 47 | subpackages: 48 | - expfmt 49 | - internal/bitbucket.org/ww/goautoneg 50 | - model 51 | - name: github.com/prometheus/procfs 52 | version: e645f4e5aaa8506fc71d6edbc5c4ff02c04c46f2 53 | subpackages: 54 | - xfs 55 | - name: github.com/sirupsen/logrus 56 | version: 202f25545ea4cf9b191ff7f846df5d87c9382c2b 57 | - name: golang.org/x/sys 58 | version: 94b76065f2d2081d0fef24a6e67c571f51a6408a 59 | subpackages: 60 | - unix 61 | testImports: 62 | - name: github.com/davecgh/go-spew 63 | version: 6d212800a42e8ab5c146b8ace3490ee17e5225f9 64 | subpackages: 65 | - spew 66 | - name: github.com/pmezard/go-difflib 67 | version: d8ed2627bdf02c080bf22230dbb337003b7aba2d 68 | subpackages: 69 | - difflib 70 | - name: github.com/stretchr/testify 71 | version: 69483b4bd14f5845b5a1e55bca19e954e827f1d0 72 | subpackages: 73 | - assert 74 | -------------------------------------------------------------------------------- /glide.yaml: -------------------------------------------------------------------------------- 1 | package: github.com/joyent/containerpilot 2 | homepage: https://www.joyent.com/containerpilot 3 | license: MPL-2.0 4 | import: 5 | - package: github.com/sirupsen/logrus 6 | version: 1.0.0 7 | - package: github.com/hashicorp/consul 8 | version: ~1.0.0 9 | subpackages: 10 | - api 11 | - package: github.com/mitchellh/mapstructure 12 | version: d2dd0262208475919e1a362f675cfc0e7c10e905 13 | - package: github.com/prometheus/client_golang 14 | version: 0.8.0 15 | subpackages: 16 | - prometheus 17 | - package: github.com/flynn/json5 18 | version: 7620272ed63390e979cf5882d2fa0506fe2a8db5 19 | testImport: 20 | - package: github.com/stretchr/testify 21 | version: v1.1.4 22 | subpackages: 23 | - assert 24 | - package: github.com/client9/reopen 25 | -------------------------------------------------------------------------------- /integration_tests/README.md: -------------------------------------------------------------------------------- 1 | # Integration Tests 2 | 3 | ## Running Tests 4 | 5 | - To run integration tests: 6 | 7 | `make integration` or `./scripts/test.sh test` 8 | 9 | - To run a single integration test: 10 | 11 | `./scripts/test.sh test test_name` 12 | 13 | - To clean fixtures. Next time tests are run they will be rebuilt. 14 | 15 | `make clean` or `./scripts/test.sh clean` 16 | 17 | ## Making Tests 18 | 19 | ### 1. Fixtures 20 | 21 | Fixtures Directory: `integration_tests/fixtures` 22 | 23 | - Folders in the fixtures directory will be built as docker images in alphabetical order 24 | - Each folder should contain a `Dockerfile` and any resources necessary to build the image 25 | - The resulting image will be named `cpfix_fixture_name` 26 | 27 | *Note*: Since fixtures are created in alpha order, they can have FROM directives for previously created images 28 | 29 | ### 2. Tests 30 | 31 | Tests Directory: `integration_tests/tests` 32 | 33 | - Folders in the tests directory will be run as tests 34 | - Each folder must contain `run.sh` 35 | - The test folder can also contain `docker-compose.yml` for setting up the test environment and other resources it might need 36 | - If `run.sh` returns success: `0` then the test passed, otherwise it failed 37 | 38 | This script can make some assumptions: 39 | 40 | - It's current directory PWD is the same as the script. 41 | - Following environment variables are set: 42 | - `COMPOSE_FILE` 43 | - `COMPOSE_PROJECT_NAME` - The name of the test folder 44 | - `CONTAINERPILOT_BIN` - Absolute path to containerpilot binary on the host. 45 | - all fixtures in `integration_tests/fixtures` are created and are available as images 46 | 47 | ## How tests are executed 48 | 49 | ### Setup Test Fixtures 50 | - Scan through all folders in `integration_tests/fixtures` in alpha order 51 | - For each fixture: 52 | - Copy `build` into `integration_test/fixtures/fixture_name/build` so it can easily be sourced by the `Dockerfile` 53 | - `cd integration_tests/fixtures/fixture_name` 54 | - `docker build -t cpfix_fixture_name .` 55 | 56 | ### Run tests 57 | - Scan through all folders in `integration_tests/tests` in alpha order 58 | - For each test: 59 | - `cd integration_test/tests/test_name` 60 | - Run `docker-compose build` 61 | - Run `run.sh` 62 | - Stop/kill the compose environment 63 | 64 | If *any* test fails, the test script will return a non-zero exit code. 65 | -------------------------------------------------------------------------------- /integration_tests/fixtures/app/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:slim 2 | 3 | RUN apt-get update && \ 4 | apt-get install -y \ 5 | curl netcat-openbsd && \ 6 | rm -rf /var/lib/apt/lists/* 7 | 8 | RUN npm install -g json http-server 9 | 10 | COPY build/containerpilot /bin/containerpilot 11 | COPY containerpilot.json5 /etc/containerpilot.json5 12 | COPY containerpilot-with-coprocess.json5 /etc/containerpilot-with-coprocess.json5 13 | COPY containerpilot-with-file-log.json5 /etc/containerpilot-with-file-log.json5 14 | COPY reload-app.sh /reload-app.sh 15 | COPY reload-containerpilot.sh /reload-containerpilot.sh 16 | COPY sensor.sh /sensor.sh 17 | 18 | ENV CONTAINERPILOT=/etc/containerpilot.json5 19 | 20 | # default port, allows us to override in docker-compose and also test 21 | # env var interpolation in the command args 22 | ENV APP_PORT=8000 23 | 24 | ENTRYPOINT [ "/bin/containerpilot" ] 25 | -------------------------------------------------------------------------------- /integration_tests/fixtures/app/containerpilot-with-coprocess.json5: -------------------------------------------------------------------------------- 1 | { 2 | consul: "consul:8500", 3 | logging: { 4 | level: "DEBUG", 5 | format: "text" 6 | }, 7 | jobs: [ 8 | { 9 | name: "app", 10 | port: 8000, 11 | exec: [ 12 | "/usr/local/bin/node", 13 | "/usr/local/bin/http-server", "/srv", "-p", "8000"], 14 | health: { 15 | exec: "/usr/bin/curl --fail -s -o /dev/null http://localhost:8000", 16 | interval: 1, 17 | ttl: 5 18 | } 19 | }, 20 | { 21 | name: "coprocess", 22 | exec: ["/bin/coprocess.sh", "arg1"], 23 | restarts: 1 24 | } 25 | ] 26 | } 27 | -------------------------------------------------------------------------------- /integration_tests/fixtures/app/containerpilot-with-file-log.json5: -------------------------------------------------------------------------------- 1 | { 2 | consul: "consul:8500", 3 | logging: { 4 | level: "DEBUG", 5 | format: "text", 6 | output: "/tmp/containerpilot.log" 7 | }, 8 | jobs: [ 9 | { 10 | name: "app", 11 | exec: "echo 'hello world!'", 12 | when: { 13 | interval: 1 14 | } 15 | } 16 | ] 17 | } 18 | -------------------------------------------------------------------------------- /integration_tests/fixtures/app/containerpilot.json5: -------------------------------------------------------------------------------- 1 | { 2 | consul: "consul:8500", 3 | logging: { 4 | level: "DEBUG", 5 | format: "text" 6 | }, 7 | jobs: [ 8 | { 9 | name: "app", 10 | port: 8000, 11 | when: { 12 | source: "preStart", 13 | once: "exitSuccess" 14 | }, 15 | exec: [ 16 | "/usr/local/bin/node", 17 | "/usr/local/bin/http-server", "/srv", "-p", "{{ .APP_PORT }}"], 18 | health: { 19 | exec: "/usr/bin/curl --fail -s -o /dev/null http://localhost:8000", 20 | interval: 1, 21 | ttl: 5, 22 | }, 23 | tags: ["application"] 24 | }, 25 | { 26 | name: "preStart", 27 | exec: "/reload-app.sh" 28 | }, 29 | { 30 | name: "reload-for-nginx", 31 | when: { 32 | source: "watch.nginx", 33 | each: "changed" 34 | }, 35 | exec: "/reload-app.sh" 36 | }, 37 | { 38 | name: "reload-for-app", 39 | when: { 40 | source: "watch.app", 41 | each: "changed" 42 | }, 43 | exec: "/reload-app.sh" 44 | } 45 | ], 46 | watches: [ 47 | { 48 | name: "nginx", 49 | interval: 7, 50 | }, 51 | { 52 | name: "app", 53 | interval: 5, 54 | tag: "application" 55 | } 56 | ], 57 | telemetry: { 58 | port: 9090, 59 | metrics: [] 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /integration_tests/fixtures/app/reload-app.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # wait a few seconds for the Consul container to become available 4 | n=0 5 | while true 6 | do 7 | if [ n == 10 ]; then 8 | echo "Timed out waiting for Consul" 9 | exit 1; 10 | fi 11 | curl -Ls --fail http://consul:8500/v1/status/leader | grep 8300 && break 12 | n=$((n+1)) 13 | sleep 1 14 | done 15 | 16 | # get all the healthy application servers and write the json to file 17 | curl -s consul:8500/v1/health/service/app?passing | json > /tmp/lastQuery.json 18 | 19 | cat < /srv/index.html 20 | 21 | 22 | ContainerPilot Demo 23 | 28 | 29 | 30 |

ContainerPilot Demo

31 |

This page served by app server: $(hostname)

32 | Last service health check changed at $(date): 33 |
34 | $(cat /tmp/lastQuery.json)
35 | 
36 |