diff --git a/.github/workflows/release_body.md b/.github/workflows/release_body.md index cce870b..b904e86 100644 --- a/.github/workflows/release_body.md +++ b/.github/workflows/release_body.md @@ -1,3 +1,36 @@ -# Adding in more supported CPU architectures +# Stability and bug fixes -This can now be used on arm64 chips. This enables use on apple silicon and other arm64 chips. \ No newline at end of file +This release resolves a comprehensive set of bugs identified in a static analysis and manual code review. All critical, high, medium, and low severity issues have been addressed. + +## Critical fixes +- Fixed infinite recursion in `rotateWriter.Close()` — was calling itself instead of `w.fp.Close()`, crashing on every graceful shutdown +- Fixed nil map panic in `FileLogManager` — `filetracker` map was never initialized, making file logging completely non-functional +- Fixed nil map access in `FileLogManager.RegisterConfig()` — same root cause as above +- Fixed data race in signal replicator — `listen()` iterated over `signalChannels` without holding the read lock +- Fixed potential deadlock in signal replicator — sends to process channels are now non-blocking (`select` with `default`) +- Fixed multiple timer callbacks on repeated signals — `time.AfterFunc` is now guarded with `sync.Once` + +## High severity fixes +- Fixed STDERR logging — was logging the full unsplit buffer for every line instead of the individual line +- Removed always-false nil guards on struct fields — `&field == nil` is never true; removed four dead code blocks and the unused `createLoggingConfig` helper +- Fixed watchdog goroutine never being started in `newRW()` — file rotation was silently not working +- Fixed panic on empty write in `BytePipe.Write()` — added length guard before accessing the last byte +- Fixed implicit nil error return in `BytePipe.Write()` — now returns explicit `nil` on success + +## Medium severity fixes +- Fixed inverted logic in `Process.running()` — was returning `p.exited` (true when stopped) instead of `!p.exited` +- Fixed nil file handle after rename failure in `rotate()` — error is now logged and execution falls through to create a fresh file rather than returning with `w.fp = nil` +- Removed unused global variable `fileLogManager` +- Fixed goroutine leak in `LogManager.Submit()` — sending to a nil channel (missing map key) now guarded with an ok check +- Fixed `log.Fatalf` format string — was using string concatenation instead of a format argument (missing comma) +- Fixed data race on `p.exited` — assignment now wrapped in `Lock()`/`Unlock()` to match the `RLock()` in `running()` + +## Low severity fixes +- Renamed misleading `rotateWriter.panic()` method to `logError()` — it never panicked +- Added safe ok-check for `*exec.ExitError` type assertion in `RunSecretProcess` +- Added comments in `Console.Submit()` explaining why write errors are intentionally not reported + +## Tests +- Added regression test for `rotateWriter.Close()` to guard against infinite recursion +- Added regression test for `FileLogManager` nil map to guard against uninitialized map panics +- Added concurrent regression test for signal replicator to catch the data race under `-race` diff --git a/Dockerfile.test b/Dockerfile.test index a75b3f0..7066af3 100644 --- a/Dockerfile.test +++ b/Dockerfile.test @@ -1,4 +1,4 @@ -FROM gcr.io/distroless/base +FROM gcr.io/distroless/static-debian13 ADD ./passwd /etc/passwd diff --git a/README.md b/README.md index 58e82aa..0a99f99 100644 --- a/README.md +++ b/README.md @@ -1,53 +1,68 @@ # Launch -A simple runtime-agnostic process manager that eases Docker services startup and logging. -Consider it a half way house between Kubernetes and Docker. +A lightweight process manager designed to run as PID 1 in Docker containers. -Launch is expected to be process 1 in a container. It allows you to watch other processes in your containers and when they are all finished it will finish allowing containers to stop correctly. +Launch lets you run multiple processes in a single container with centralized logging, ordered startup hooks, and graceful signal propagation. -## What can Launch help with +## Features -Launch is designed to be a process manager with simple but powerful logging. It borrows some ideas from kubernetes without having to deploy a kubernetes stack. +- Run **multiple processes** in one container. +- **Secret collection** runs first and injects output as environment variables for all later processes. +- **Init processes** run sequentially after secrets — any failure aborts startup. +- **Main processes** run in parallel; if one exits, Launch sends SIGTERM to all remaining ones and waits for shutdown. +- Ship logs to **console, file, syslog, or /dev/null** independently per process. +- **Templated YAML config** — inject environment variables and use built-in helper functions. -* You can run multiple processes in a single container. -* You can ship logs from processes to different logging engines. -* You can run init processes that run before your main processes. This allows you to collect artifacts, secrets or just setup an environment. -* A single main process dying will bring down a container, gracefully shutting down the other applications. +## How it works -## Configuration - -More details can be found in the [README Folder](./READMEs/) +``` +Startup sequence: + 1. Read and render config file (first pass — container env vars available) + 2. Run secret processes → capture stdout JSON and inject as env vars + 3. Re-render config file (second pass — secret env vars now available) + 4. Run init processes sequentially — any non-zero exit aborts startup + 5. Start main processes in parallel + 6. Wait — when any main process exits, send SIGTERM to all others and shut down +``` ## Processes -Launch has 3 processes types when running. +Launch supports three types of processes, each with a distinct role. ### Secret processes -These are used to collect secret data like username and passwords at startup. +Run before anything else. They collect credentials or other secrets and expose them as environment variables. Because full logging is not yet available at this stage, output goes to the console. ### Init processes -These are used to configure the state of the container or collect more resources that don't need to be exported to environment variables. +Run sequentially after secrets. Use these for setup tasks — running migrations, fetching config, preparing the filesystem. All init processes must succeed before main processes start. -### Main Processes +### Main processes -These are the long running processes in your containers. +The long-running processes in your container. They start in parallel and are expected to run for the lifetime of the container. If any one of them exits, Launch sends SIGTERM to all the others. ## Configuration -The configuration YAML file is the driving force behind Launch. The configuration file will tell Launch what processes to run with what arguments, where to send logs. +The configuration is driven by a YAML file. Run the following to generate an annotated example: + +```bash +./launch -example-config +``` -The configuration file has a templating feature that allows you to make the configuration dynamic. +Full documentation: -The configuration file has many sections that are documented in the -[README dedicated to configuration](./READMEs/ConfigurationFile.MD). +- [Configuration reference](./READMEs/ConfigurationFile.md) — all sections and options +- [Logging engines](./READMEs/Logging.md) — console, file, syslog, `/dev/null` +- [Secrets](./READMEs/Secrets.md) — how secret collection works ## Contributing -This project is still very much in active development. Expect changes and improvements. +This project is still in active development. There is a simple build script for integration testing. -There is a simple build script that does the current integration testing. +```bash +# Run tests only +./build_and_test.sh -Use `./build_and_test.sh` to run only the tests -Use `./build_and_test.sh` gobuild" to also rebuild the go project +# Rebuild the binary and run tests +./build_and_test.sh gobuild +``` diff --git a/READMEs/ConfigrationFile.md b/READMEs/ConfigurationFile.md similarity index 90% rename from READMEs/ConfigrationFile.md rename to READMEs/ConfigurationFile.md index 21c85d2..31a1a40 100755 --- a/READMEs/ConfigrationFile.md +++ b/READMEs/ConfigurationFile.md @@ -11,16 +11,16 @@ An example of the configuration file can be obtained by running: ./launch -example-config ``` -Below is each section of the configuration with the relevant values and details. If you would like more information regarding the configuration all data is layed out in the [configfile folder](../configfile). +Below is each section of the configuration with the relevant values and details. If you would like more information regarding the configuration all data is laid out in the [configfile folder](../configfile). ## Understanding double rendering Configuration files have templating built in, see later templating section. This allows for environment variables to be used in the configuration file. However many of those environment variables will be collected during the secrets and parameters collection phase. -The configuration file is rendered twice when the Launch is started. -The first render will be done on start up, it will produce the configuration with the available environment variables as the container starts. -The Launch will then proceed to collect secrets and parameters. Once complete the configuration is rendered a second time. +The configuration file is rendered twice when Launch starts. +The first render runs on startup and produces the configuration with the environment variables available at that point. +Launch then proceeds to collect secrets and parameters. Once complete, the configuration is rendered a second time. This allows the configuration to make use of parameters and secrets as part of the configuration. Previously blanked environment settings will now be filled in. An example use case is using an override for hostname in the syslog logging configuration, or setting a environment variable. @@ -32,7 +32,7 @@ Secrets are only collected once at startup. ## process_manager -`process_manager` configures the Launch process itself. It needs to know where to send it's logs and also if it needs to enable debug logging. +`process_manager` configures Launch itself. It needs to know where to send its logs and whether to enable debug logging. Example: @@ -54,7 +54,7 @@ process_manager: ## processes -`processes` tells the Launch what start and where to send the logs. There is 3 sections here: secret_processes, init_processes and main_processes +`processes` tells Launch what to start and where to send the logs. There are 3 sections: `secret_processes`, `init_processes`, and `main_processes`. Example: @@ -142,7 +142,7 @@ default_logger_config: ``` ## Logging -Logging is used to tell the Launch to send logs to a logging engine eg. syslog, console, etc... +Logging is used to tell Launch where to send logs — e.g. syslog, console, etc. Below is the logging configuration. However this is a slightly different one to the rest. diff --git a/READMEs/Logging.md b/READMEs/Logging.md index 4ff7c12..f5ebcd0 100755 --- a/READMEs/Logging.md +++ b/READMEs/Logging.md @@ -4,13 +4,13 @@ A more detailed description of the loggers that are available is documented belo ## Startup logging -The Launch needs to start logging before it is actually capable of logging fully. This is because some loggers require authentication that is expected to be collected by the secrets. -To overcome this the Launch will log to the console until after the secrets have been collected. +Launch needs to start logging before it is fully configured to do so. This is because some loggers require credentials that are expected to be collected by the secrets phase. +To overcome this, Launch logs to the console until after secrets have been collected. At which point it will read the configuration file and use the specified loggers. ## Default logging -Default values for logging can be set. These values will be used if there is not value present for a process or they will be merged. This allows most the configuration to be setup here, and only specifics to be set at the process level. +Default values for logging can be set. These values are used when no value is present for a process, or they are merged with per-process values. This allows most of the configuration to be set here, with only the specifics overridden at the process level. Processes will still need to select a logging engine. @@ -32,11 +32,11 @@ File logging is only really useful in development environments. In most producti ## Syslog -Syslog is a pretty standard linux way of sending logs. These logs are sent as lines and multiline logs are unfortunetly split. +Syslog is a standard Linux way of sending logs. Messages are sent line-by-line; multiline logs are unfortunately split across multiple entries. -The logger allows you to override the name that you see in syslog. By default it will use the process name. If you set the `program_name` key under the syslog logging configuration it will use that in its place. +The logger allows you to override the name that you see in syslog. By default it uses the process name. If you set the `program_name` key under the syslog logging configuration, that value is used instead. -Due to the logs being presented to Launch via stdout we are not able to know if the log is critical, warning or informational. This information might be available in the text, however the Launch will simply forward on the message with out inspecting its contents by default. +Because logs are presented to Launch via stdout, it cannot determine whether a log line is critical, a warning, or informational. By default, Launch forwards the message without inspecting its contents. If you set `extract_log_level: true` the logger will attempt to detect the level from your message. There are limitations here and your messages need to be structured correctly. @@ -70,6 +70,6 @@ You can combine this with the configuration file templating to make names that r The normal rules for host names will apply. No special chars, no space, etc... -For easier tracking of the instances themselves you can append the containers hostname to either the process or to the hostname. If you do this you need to include any _ or - as it will simple tack on the hostname. -The can be set at the default configuration OR the process configuration. +For easier tracking you can append the container hostname to either the process tag or the syslog hostname. If you do this you need to include any `_` or `-` separator yourself, as Launch will simply append the hostname directly. +This can be set at the default configuration level or the process configuration level. Use `append_container_name_to_tag` and `append_container_name_to_hostname` to control these features. diff --git a/READMEs/Secrets.md b/READMEs/Secrets.md index 98ab97b..0d50130 100755 --- a/READMEs/Secrets.md +++ b/READMEs/Secrets.md @@ -15,7 +15,7 @@ The idea is that you can create your own binary to use to collect secrets and ha Because Launch is the parent process, child processes CAN NOT update the environment of the parent. Therefore your process can not expose environment variables for later processes to see. ``` -To over come this, Launch will read a key value pair in JSON as the stdout of your process and expose those as environment variables for you. +To overcome this, Launch reads the stdout of your process as a JSON key/value object and exposes those as environment variables. Output expected: @@ -23,13 +23,13 @@ Output expected: {"key":"value", "key2":"value2"} ``` -It is also possible that you process writes to files that other processes can collect. It is expected that no output to STDOUT is given in this case. +It is also possible that your process writes to files that other processes can read. In this case no output to stdout is expected. -With this being said here are the requirements for Secret Processes: +Requirements for secret processes: -1. They must complete successfully to continue execution. -1. They are executed sequentially as shown in the configuration file. -1. output must be valid JSON and the only output on STDOUT. +1. They must exit successfully (exit code 0) to allow execution to continue. +1. They are executed sequentially in the order listed in the configuration file. +1. If they produce stdout, it must be a single valid JSON object and nothing else. Secrets might not always need to be collected. Consider if you are using in a Dev environment. Make use of the `skip` field to stop a process from running. diff --git a/build_and_test.sh b/build_and_test.sh index 28629ec..8a5d6b0 100755 --- a/build_and_test.sh +++ b/build_and_test.sh @@ -8,18 +8,19 @@ if [[ $1 = "gobuild" ]]; then echo "Building the go project!" curdir=$(pwd) echo "Building launch" - CGO_ENABLED=0 go build -a -installsuffix cgo -o launch . + CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o launch . cd testbin echo "Building testbin" - CGO_ENABLED=0 go build -a -installsuffix cgo -o testbin . + CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o testbin . cd $curdir echo "Finished building" else echo "Skipping go build" fi -docker build -t morfien101/launch-test:latest -f Dockerfile.test . -docker build -t morfien101/launch-test:debug -f Dockerfile.debug . +# It's worth noting that distroless only supports linux, so we need to make sure we build for linux/amd64. +docker build --no-cache --platform linux/amd64 -t morfien101/launch-test:latest -f Dockerfile.test . +docker build --no-cache --platform linux/amd64 -t morfien101/launch-test:debug -f Dockerfile.debug . echo "#########################" echo "## Running full config ##" diff --git a/bytepipe/bytepipe.go b/bytepipe/bytepipe.go index 2d94e45..8d9662f 100644 --- a/bytepipe/bytepipe.go +++ b/bytepipe/bytepipe.go @@ -13,12 +13,15 @@ func New() *BytePipe { } func (bp *BytePipe) Write(p []byte) (n int, err error) { + if len(p) == 0 { + return 0, nil + } if p[len(p)-1] != '\n' { p = append(p, '\n') } bp.Ready <- string(p) - return len(p), err + return len(p), nil } func (bp *BytePipe) Close() { diff --git a/configfile/configfile.go b/configfile/configfile.go index 785945c..ed91583 100644 --- a/configfile/configfile.go +++ b/configfile/configfile.go @@ -66,9 +66,6 @@ func (cf *Config) setDefaultSecretTimeout() { // NOTE: setDefaultLoggerConfig should be called first // func (cf *Config) setDefaultProcessLogger() { - createLoggingConfig := func(proc *Process) { - proc.LoggerConfig = LoggingConfig{} - } setName := func(proc *Process) { proc.LoggerConfig.ProcessName = proc.Name } @@ -77,10 +74,6 @@ func (cf *Config) setDefaultProcessLogger() { } f := func(procList []*Process) { for _, proc := range procList { - if &proc.LoggerConfig == nil { - // Create a logging config - createLoggingConfig(proc) - } if proc.LoggerConfig.ProcessName == "" { setName(proc) } @@ -95,21 +88,12 @@ func (cf *Config) setDefaultProcessLogger() { } func (cf *Config) setDefaultProcessManager() { - if &cf.ProcessManager == nil { - cf.ProcessManager = defaultProcessManager - } - if &cf.ProcessManager.LoggerConfig == nil { - cf.ProcessManager.LoggerConfig = defaultProcessManager.LoggerConfig - } if len(cf.ProcessManager.LoggerConfig.Engine) == 0 { cf.ProcessManager.LoggerConfig.Engine = defaultProcessManager.LoggerConfig.Engine } // Set defaults for logging engines under process manager context if cf.ProcessManager.LoggerConfig.Engine == "syslog" { - if &cf.ProcessManager.LoggerConfig.Syslog == nil { - cf.ProcessManager.LoggerConfig.Syslog = defaultProcessManagerSyslog - } if cf.ProcessManager.LoggerConfig.Syslog.ProgramName == "" { cf.ProcessManager.LoggerConfig.Syslog.ProgramName = defaultProcessManagerSyslog.ProgramName } diff --git a/go.mod b/go.mod index 8609b7d..d3dda18 100644 --- a/go.mod +++ b/go.mod @@ -7,10 +7,10 @@ require ( github.com/c2h5oh/datasize v0.0.0-20171227191756-4eba002a5eae github.com/silverstagtech/gotracer v0.2.0 github.com/silverstagtech/srslog v0.2.1 - gopkg.in/yaml.v2 v2.2.2 + gopkg.in/yaml.v2 v2.2.8 ) require ( github.com/spf13/afero v1.9.4 // indirect - golang.org/x/text v0.3.4 // indirect + golang.org/x/text v0.3.8 // indirect ) diff --git a/go.work.sum b/go.work.sum new file mode 100644 index 0000000..e924f9b --- /dev/null +++ b/go.work.sum @@ -0,0 +1,72 @@ +cloud.google.com/go v0.75.0 h1:XgtDnVJRCPEUG21gjFiRPz4zI1Mjg16R+NYQjfmU4XY= +cloud.google.com/go/bigquery v1.8.0 h1:PQcPefKFdaIzjQFbiyOgAqyx8q5djaE7x9Sqe712DPA= +cloud.google.com/go/datastore v1.1.0 h1:/May9ojXjRkPBNVrq+oWLqmWCkr4OU5uRY29bu0mRyQ= +cloud.google.com/go/pubsub v1.3.1 h1:ukjixP1wl0LpnZ6LWtZJ0mX5tBmjp1f8Sqer8Z2OMUU= +cloud.google.com/go/storage v1.14.0 h1:6RRlFMv1omScs6iq2hfE3IvgE+l6RfJPampq8UZc5TU= +dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9 h1:VpgP7xuJadIUuKccphEpTJnWhS2jkQyMt6Y7pJCD7fY= +github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ= +github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802 h1:1BDTz0u9nC3//pOCMdNH+CiXJVYJh5UQNCOBG7jbELc= +github.com/census-instrumentation/opencensus-proto v0.2.1 h1:glEXhBS5PSLLv4IXzLA5yPRVX4bilULVyxxbrfOtDAk= +github.com/chzyer/logex v1.1.10 h1:Swpa1K6QvQznwJRcfTfQJmTE72DqScAa40E+fbHEXEE= +github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e h1:fY5BOSpyZCqRo5OhCuC+XN+r/bBCmeuuJtjz+bCNIf8= +github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1 h1:q763qf9huN11kDQavWsoZXJNW3xEE4JJyHa5Q25/sd8= +github.com/client9/misspell v0.3.4 h1:ta993UF76GwbvJcIo3Y68y/M3WxlpEHPWIGDkJYwzJI= +github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403 h1:cqQfy1jclcSy/FwLjemeg3SR1yaINm74aQyupQ0Bl8M= +github.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad h1:EmNYJhPYy0pOFjCx2PrgtaBXmee0iUX9hLlxE1xHOJE= +github.com/envoyproxy/protoc-gen-validate v0.1.0 h1:EQciDnbrYxy13PgWoY8AqoxGiPrpgBZ1R8UNe3ddc+A= +github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1 h1:QbL/5oDUmRBzO9/Z7Seo6zf912W/a6Sr4Eu0G/3Jho0= +github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4 h1:WtGNWLvXpe6ZudgnXrq0barxBImvnnJoMEhXAzcbM0I= +github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b h1:VKtxabqXZkF25pY9ekfRL6a582T4P37/31XEstQ5p58= +github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e h1:1r7pUrabqp18hOBcwBwiTsbnFeTZHV9eER/QT5JVZxY= +github.com/golang/mock v1.4.4 h1:l75CXGRSwbaYNpl/Z2X1XIIAMSCquvXgpVZDhwEIJsc= +github.com/golang/protobuf v1.4.3 h1:JjCZWpVbqXDqFVmTfYWEVTMIYrL/NPdPSCHPJ0T/raM= +github.com/google/btree v1.0.0 h1:0udJVsspx3VBr5FwtLhQQtuAsVc79tTq0ocGIPAU6qo= +github.com/google/go-cmp v0.5.4 h1:L8R9j+yAqZuZjsqh/z+F1NCffTKKLShY6zXTItVIZ8M= +github.com/google/martian v2.1.0+incompatible h1:/CP5g8u/VJHijgedC/Legn3BAbAaWPgecwXBIDzw5no= +github.com/google/martian/v3 v3.1.0 h1:wCKgOCHuUEVfsaQLpPSJb7VdYCdTVZQAuOdYm1yc/60= +github.com/google/pprof v0.0.0-20201218002935-b9804c9f04c2 h1:LR89qFljJ48s990kEKGsk213yIJDPI4205OKOzbURK8= +github.com/google/renameio v0.1.0 h1:GOZbcHa3HfsPKPlmyPyN2KEohoMXOhdMbHrvbpl2QaA= +github.com/google/uuid v1.1.2 h1:EVhdT+1Kseyi1/pUmXKaFxYsDNy9RQYkMWRH68J/W7Y= +github.com/googleapis/gax-go/v2 v2.0.5 h1:sjZBwGj9Jlw33ImPtvFviGYvseOtDM7hkSKB7+Tv3SM= +github.com/googleapis/google-cloud-go-testing v0.0.0-20200911160855-bcd43fbb19e8 h1:tlyzajkF3030q6M8SvmJSemC9DTHL/xaMa18b65+JM4= +github.com/hashicorp/golang-lru v0.5.1 h1:0hERBMJE1eitiLkihrMvRVBYAkpHzc/J3QdDN+dAcgU= +github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639 h1:mV02weKRL81bEnm8A0HT1/CAelMQDBuQIfLw8n+d6xI= +github.com/jstemmer/go-junit-report v0.9.1 h1:6QPYqodiu3GuPL+7mfx+NwDdp2eTkp9IfEUpgAwUN0o= +github.com/kisielk/gotool v1.0.0 h1:AV2c/EiW3KqPNT9ZKl07ehoAGi4C5/01Cfbblndcapg= +github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8= +github.com/kr/pty v1.1.1 h1:VkoXIwSboBpnk99O/KFauAEILuNHv5DVFKZMBN/gUgw= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/sftp v1.13.1 h1:I2qBYMChEhIjOgazfJmV3/mZM256btk6wkCDRmW7JYs= +github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4 h1:gQz4mCbXsO+nc9n1hCxHcGA3Zx3Eo+UHZoInFGUIXNM= +github.com/rogpeppe/go-internal v1.3.0 h1:RR9dF3JtopPvtkroDZuVD7qquD0bnHlKSqaQhgwt8yk= +github.com/stretchr/objx v0.1.0 h1:4G4v2dO3VZwixGIRoQ5Lfboy6nUhCyYzaqnIAPPhYs4= +github.com/yuin/goldmark v1.2.1 h1:ruQGxdhGHe7FWOJPT0mKs5+pD2Xs1Bm/kdGlHO04FmM= +go.opencensus.io v0.22.5 h1:dntmOdLpSpHlVqbW5Eay97DelsZHe+55D+xC6i0dDS0= +golang.org/x/crypto v0.0.0-20211108221036-ceb1ce70b4fa h1:idItI2DDfCokpg0N51B2VtiLdJ4vAuXC9fnCb2gACo4= +golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6 h1:QE6XYQK6naiK1EPAe1g/ILLxN5RBoH5xkJk3CqlMI/Y= +golang.org/x/image v0.0.0-20190802002840-cff245a6509b h1:+qEpEAPhDZ1o0x3tHzZTQDArnOixOzGD9HUJfcg0mb4= +golang.org/x/lint v0.0.0-20201208152925-83fdc39ff7b5 h1:2M3HP5CCK1Si9FQhwnzYhXdG6DXeebvUHFpre8QvbyI= +golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028 h1:4+4C/Iv2U4fMZBiMCc98MG1In4gJY5YRhtpDNeDeHWs= +golang.org/x/mod v0.4.1 h1:Kvvh58BN8Y9/lBi7hTekvtMpm07eUZ0ck5pRHpsMWrY= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110 h1:qWPm9rbaAMKs8Bq/9LRpbMqxWRVUAQwMI9fVrssnTfw= +golang.org/x/oauth2 v0.0.0-20210218202405-ba52d332ba99 h1:5vD4XjIc0X5+kHZjx4UecYdjA6mJo+XXNoaW0EjU5Os= +golang.org/x/sync v0.0.0-20201207232520-09787c993a3a h1:DcqTD9SDLc+1P/r1EmRBwnVsrOwW+kk2vWf9n+1sGhs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1 h1:SrN+KX8Art/Sf4HNj6Zcz06G7VEz+7w9tdXTPOZ7+l4= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1 h1:v+OssWQX+hTHEmOBgwxdZxK4zHq3yOs8F9J7mk0PY8E= +golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= +golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= +golang.org/x/time v0.0.0-20191024005414-555d28b269f0 h1:/5xXl8Y5W96D+TtHSlonuFqGHIWVuyCkGJLwGh9JJFs= +golang.org/x/tools v0.1.0 h1:po9/4sTYwZU9lPhi1tOrb4hCv3qrhiQ77LZfGa2OjwY= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE= +google.golang.org/api v0.40.0 h1:uWrpz12dpVPn7cojP82mk02XDgTJLDPc2KbVTxrWb4A= +google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c= +google.golang.org/genproto v0.0.0-20210226172003-ab064af71705 h1:PYBmACG+YEv8uQPW0r1kJj8tR+gkF0UWq7iFdUezwEw= +google.golang.org/grpc v1.35.0 h1:TwIQcH3es+MojMVojxxfQ3l3OF2KzlRxML2xZq0kRo8= +google.golang.org/protobuf v1.25.0 h1:Ejskq+SyPohKW+1uil0JJMtmHCgJPJ/qWTxr8qp+R4c= +gopkg.in/errgo.v2 v2.1.0 h1:0vLT13EuvQ0hNvakwLuFZ/jYrLp5F3kcWHXdRggjCE8= +gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10= +gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +honnef.co/go/tools v0.0.1-2020.1.4 h1:UoveltGrhghAA7ePc+e+QYDHXrBps2PqFZiHkGR/xK8= +rsc.io/binaryregexp v0.2.0 h1:HfqmD5MEmC0zvwBuF187nq9mdnXjXsSivRiXN7SmRkE= +rsc.io/quote/v3 v3.1.0 h1:9JKUTTIUgS6kzR9mK1YuGKv6Nl+DijDNIc0ghT58FaY= +rsc.io/sampler v1.3.0 h1:7uVkIFmeBqHfdjD+gZwtXXI+RODJ2Wc4O7MPEh/QiW4= diff --git a/llms.txt b/llms.txt new file mode 100644 index 0000000..c96e8f9 --- /dev/null +++ b/llms.txt @@ -0,0 +1,175 @@ +# Launch — LLM Quick Reference + +## What is Launch? + +Launch is a Go binary that runs as PID 1 in a Docker container. It manages multiple child processes, collects secrets, handles ordered init tasks, routes per-process logs to configurable engines, and propagates OS signals to all children. When any main process exits for any reason, Launch sends SIGTERM to all remaining main processes and waits for them to stop, then exits itself. + +--- + +## Startup sequence + +1. Config file is read and rendered (Go template syntax; container env vars are available). +2. Secret processes run sequentially — each must exit 0. Stdout is parsed as JSON and injected as environment variables. +3. Config file is re-rendered (secret env vars are now available for use in config). +4. Init processes run sequentially — any non-zero exit aborts startup. +5. Main processes start in parallel. +6. Launch waits. The first main process to exit triggers SIGTERM to all others. + +--- + +## Configuration file + +YAML format. Generate an annotated example: + +```bash +./launch -example-config +``` + +### Top-level keys + +```yaml +process_manager: # Launch's own logging and debug settings +processes: # All processes to run +default_logger_config: # Default logging values shared across all processes +``` + +### process_manager + +```yaml +process_manager: + logging_config: + engine: console # Where Launch sends its own log output + process_name: launch + debug_logging: false # Enable verbose debug output + debug_options: + show_generated_config: false # Print final config after second render +``` + +### processes + +```yaml +processes: + secret_processes: + - name: fetch-secrets + command: /usr/local/bin/fetch-secrets + arguments: + - --region + - us-east-1 + termination_timeout_seconds: 60 + skip: false # Can use template: {{ zerolen (env "SKIP_SECRETS") "true" "false" }} + working_dir: /app + + init_processes: + - name: run-migrations + command: /app/migrate + arguments: + - --up + termination_timeout_seconds: 30 + working_dir: /app + logging_config: + engine: console + process_name: migrations + + main_processes: + - name: web-server + command: /app/server + arguments: + - --port + - "8080" + termination_timeout_seconds: 30 + start_delay_seconds: 0 + working_dir: /app + logging_config: + engine: syslog + process_name: web-server + syslog: + program_name: web-server + override_hostname: my-app +``` + +### logging_config (used inside any process or process_manager) + +```yaml +logging_config: + engine: console # console | devnull | syslog | logfile + process_name: my-process # Label attached to log lines + + # Required only when engine: syslog + syslog: + program_name: my-process + extract_log_level: false # Parse JSON logs and extract "level" field + override_hostname: "" # Override container hostname in syslog + append_container_name_to_tag: false + append_container_name_to_hostname: false + + # Required only when engine: logfile + file_config: + filepath: /var/log/my-process.log + size_limit: 100mb + historical_files_limit: 3 +``` + +### default_logger_config + +```yaml +default_logger_config: + logging_config: + engine: syslog + syslog: + override_hostname: my-app +``` + +Values here are used as defaults and merged with per-process config. Per-process values take precedence. + +--- + +## Template functions + +The config file uses Go template syntax. Available functions: + +| Function | Description | Example | +|-----------|---------------------------------------------------|------------------------------------------------------| +| `env` | Read an environment variable | `{{ env "MY_VAR" }}` | +| `default` | Fallback value if the input is empty | `{{ default (env "OPT_VAR") "fallback" }}` | +| `required`| Fail startup if the value is empty | `{{ required (env "MUST_EXIST") }}` | +| `zerolen` | Branch on whether a string is zero-length | `{{ zerolen (env "FLAG") "true" "false" }}` | + +The config file is rendered **twice**: once at container startup, and once after secrets are collected. This lets you reference secret-injected env vars inside your logging or process configuration. + +--- + +## Logging engines + +| Engine | Description | +|-----------|----------------------------------------------------------------------| +| `console` | Forwards stdout/stderr to Launch's own stdout/stderr. Good for dev. | +| `devnull` | Discards all log output. | +| `syslog` | Ships log lines to a syslog daemon (local or remote, e.g. Papertrail). Multiline logs are split. | +| `logfile` | Writes to a size-limited, rotating log file on disk. | + +--- + +## Secret processes — rules + +- Run before init and main processes, sequentially. +- Must exit 0 to allow startup to continue. +- If they produce stdout, it must be a single JSON object: `{"KEY": "value", "KEY2": "value2"}`. + Those keys are injected as environment variables for all subsequent processes. +- If the process writes to files instead, produce no stdout. +- Use `skip: true` (or a template expression) to conditionally disable a secret process. + +--- + +## Signal handling + +Launch forwards SIGTERM, SIGINT, and SIGKILL to all child processes. If a child does not exit within `termination_timeout_seconds` after receiving the signal, Launch sends SIGKILL to it. The default timeout is 30 seconds if not configured. + +--- + +## Key behaviours to know + +- **Double render**: config is templated twice — before and after secrets — so secret values can be referenced in logging config. +- **Init fail-fast**: a failing init process stops the entire container immediately. +- **Main process cascade**: when any main process exits, all others receive SIGTERM. This prevents containers where a critical process dies silently. +- **Per-process logging**: each process can use a different logging engine and configuration. +- **Secret processes run before logging starts**: credentials needed by loggers are available by the time main logging is configured. diff --git a/main.go b/main.go index 871dfd8..b65036b 100644 --- a/main.go +++ b/main.go @@ -219,7 +219,7 @@ func terminate(exitcode int, loggers *processlogger.LogManager) { } return strings.Join(es, ",") } - log.Fatalf("Error shutting down loggers. Errors: %s" + errString()) + log.Fatalf("Error shutting down loggers. Errors: %s", errString()) } os.Exit(exitcode) diff --git a/processlogger/console/console.go b/processlogger/console/console.go index c68430c..f146b8f 100644 --- a/processlogger/console/console.go +++ b/processlogger/console/console.go @@ -55,10 +55,13 @@ func (c *Console) Shutdown() chan error { func (c *Console) Submit(msg processlogger.LogMessage) { m := fmt.Sprintf("%s: %s", msg.Source, msg.Message) if msg.Pipe == processlogger.STDERR { - c.stdErr(m) + // Error intentionally ignored: if stderr is unavailable there is nowhere + // meaningful to report the failure — logging it would use the same broken pipe. + c.stdErr(m) //nolint:errcheck } if msg.Pipe == processlogger.STDOUT { - c.stdOut(m) + // Error intentionally ignored: same reasoning as stderr above. + c.stdOut(m) //nolint:errcheck } } diff --git a/processlogger/filelogger/filelogger.go b/processlogger/filelogger/filelogger.go index 40e2cac..0b7e006 100644 --- a/processlogger/filelogger/filelogger.go +++ b/processlogger/filelogger/filelogger.go @@ -24,11 +24,9 @@ type FileLogManager struct { filetracker map[string]*rotateWriter } -var fileLogManager *FileLogManager - func init() { processlogger.RegisterLogger(LoggerTag, func() processlogger.Logger { - return &FileLogManager{} + return &FileLogManager{filetracker: make(map[string]*rotateWriter)} }) } diff --git a/processlogger/filelogger/filelogger_test.go b/processlogger/filelogger/filelogger_test.go index a76ed88..68e8746 100644 --- a/processlogger/filelogger/filelogger_test.go +++ b/processlogger/filelogger/filelogger_test.go @@ -4,10 +4,12 @@ import ( "fmt" "io/ioutil" "os" + "path/filepath" "testing" "github.com/c2h5oh/datasize" "github.com/morfien101/launch/configfile" + "github.com/morfien101/launch/processlogger" ) func TestDeleteOldFiles(t *testing.T) { @@ -72,3 +74,58 @@ logline3 } } } + +// TestRotateWriterClose guards against regression of the infinite recursion bug +// where Close() called itself instead of w.fp.Close(). +func TestRotateWriterClose(t *testing.T) { + dir, err := ioutil.TempDir("", "rw_close_test") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(dir) + + config := configfile.FileLogger{ + Filename: filepath.Join(dir, "test.log"), + SizeLimit: 1024 * 1024, + HistoricalFiles: 1, + } + rw, err := newRW(config) + if err != nil { + t.Fatal(err) + } + if err := rw.Close(); err != nil { + t.Fatalf("Close() returned an error: %v", err) + } +} + +// TestFileLogManagerRegisterAndSubmit guards against regression of the nil map +// panic where FileLogManager was constructed without initialising filetracker. +func TestFileLogManagerRegisterAndSubmit(t *testing.T) { + dir, err := ioutil.TempDir("", "flm_test") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(dir) + + logConf := configfile.LoggingConfig{ + Logfile: configfile.FileLogger{ + Filename: filepath.Join(dir, "test.log"), + SizeLimit: 1024 * 1024, + HistoricalFiles: 1, + }, + } + + flm := &FileLogManager{filetracker: make(map[string]*rotateWriter)} + if err := flm.RegisterConfig(logConf, configfile.DefaultLoggerDetails{}); err != nil { + t.Fatalf("RegisterConfig() returned an error: %v", err) + } + + flm.Submit(processlogger.LogMessage{ + Config: logConf, + Message: "regression test message\n", + }) + + if err := <-flm.Shutdown(); err != nil { + t.Fatalf("Shutdown() returned an error: %v", err) + } +} diff --git a/processlogger/filelogger/rotationwriter.go b/processlogger/filelogger/rotationwriter.go index d385124..6c04e26 100644 --- a/processlogger/filelogger/rotationwriter.go +++ b/processlogger/filelogger/rotationwriter.go @@ -33,6 +33,7 @@ func newRW(conf configfile.FileLogger) (*rotateWriter, error) { if err != nil { return nil, err } + go w.watchDog() return w, nil } @@ -54,7 +55,7 @@ func (w *rotateWriter) watchDog() { if !ok { err := w.rotate() if err != nil { - w.panic(err) + w.logError(err) } w.deleteOldFiles() } @@ -62,12 +63,9 @@ func (w *rotateWriter) watchDog() { } } -// Loggers should not terminate service. -// Should we need to panic we should handle the situation as best we can -// Trying to keep service running. -func (w *rotateWriter) panic(err error) { - // we have an err that we need to recover from - // The best we can do here is print it to the console +// logError prints errors that cannot be propagated without terminating the logger. +// The best we can do is surface them to the console and keep running. +func (w *rotateWriter) logError(err error) { fmt.Println(err) } @@ -90,7 +88,7 @@ func (w *rotateWriter) deleteOldFiles() { } err := os.Remove(filename) if err != nil { - w.panic(err) + w.logError(err) } } w.historicalFilePaths = keep @@ -111,7 +109,7 @@ func (w *rotateWriter) Close() error { defer w.lock.Unlock() close(w.watchDogSignals) w.running = false - return w.Close() + return w.fp.Close() } // Rotate Perform the actual act of rotating and reopening file. @@ -133,9 +131,10 @@ func (w *rotateWriter) rotate() error { newFileName := w.config.Filename + "." + time.Now().Format(time.RFC3339) err = os.Rename(w.config.Filename, newFileName) if err != nil { - return err + w.logError(err) // log and fall through to create a fresh file + } else { + w.updateHistoricalFileNames(newFileName) } - w.updateHistoricalFileNames(newFileName) } // Create a file. diff --git a/processlogger/logManager.go b/processlogger/logManager.go index 61d3c51..ca14f9b 100644 --- a/processlogger/logManager.go +++ b/processlogger/logManager.go @@ -142,7 +142,9 @@ func (lm *LogManager) Submit(log LogMessage) { if lm.terminated { return } - lm.activeLoggerQ[log.Config.Engine] <- &log + if q, ok := lm.activeLoggerQ[log.Config.Engine]; ok { + q <- &log + } } // Shutdown is used to gracefully shutdown all the loggers and log routers. diff --git a/processmanager/process.go b/processmanager/process.go index cf53267..c983a04 100644 --- a/processmanager/process.go +++ b/processmanager/process.go @@ -31,11 +31,7 @@ type Process struct { func (p *Process) running() bool { p.RLock() defer p.RUnlock() - if p.exited { - return p.exited - } - - return p.exiting + return !p.exited } func (p *Process) processStartDelay() { @@ -71,6 +67,7 @@ func (p *Process) runProcess(processType string) *processEnd { p.closePipesChan <- true }() var timeoutError error = nil + var timerOnce sync.Once // Wait for signals go func() { @@ -104,8 +101,10 @@ func (p *Process) runProcess(processType string) *processEnd { p.config.TermTimeout = 30 } - time.AfterFunc(time.Duration(p.config.TermTimeout)*time.Second, func() { - exitTimeout <- p.running() + timerOnce.Do(func() { + time.AfterFunc(time.Duration(p.config.TermTimeout)*time.Second, func() { + exitTimeout <- p.running() + }) }) } case timeout := <-exitTimeout: @@ -133,7 +132,9 @@ func (p *Process) runProcess(processType string) *processEnd { finalState.Error = timeoutError } + p.Lock() p.exited = true + p.Unlock() finalState.ExitCode = readExitError(finalState.Error) return finalState @@ -176,7 +177,9 @@ func RunSecretProcess(secretConfig configfile.SecretProcess, logger internallogg if err != nil { stdout := []byte{} if exitCode := readExitError(err); exitCode != 0 { - stdout = err.(*exec.ExitError).Stderr + if exitErr, ok := err.(*exec.ExitError); ok { + stdout = exitErr.Stderr + } } return "", string(stdout), err } diff --git a/processmanager/process_test.go b/processmanager/process_test.go index c859914..1e05c10 100644 --- a/processmanager/process_test.go +++ b/processmanager/process_test.go @@ -1,6 +1,11 @@ package processmanager import ( + "encoding/json" + "errors" + "os" + "os/exec" + "syscall" "testing" "time" @@ -44,3 +49,262 @@ func TestProcessNoStartDelay(t *testing.T) { t.Fail() } } + +// makeTestProcess builds a Process wired for testing. closePipesChan is buffered +// so the send inside runProcess never blocks even when no pipe drainer is running. +func makeTestProcess(t *testing.T, cmd string, args ...string) *Process { + t.Helper() + return &Process{ + config: &configfile.Process{ + Name: "test", + CMD: cmd, + Args: args, + }, + pmlogger: internallogger.NewFakeLogger(), + sigChan: make(chan os.Signal, 1), + closePipesChan: make(chan bool, 1), + proc: exec.Command(cmd, args...), + } +} + +// TestRunning guards that running() returns !p.exited, not p.exited. +func TestRunning(t *testing.T) { + p := &Process{config: &configfile.Process{}} + if !p.running() { + t.Error("running() should be true before process exits") + } + p.exited = true + if p.running() { + t.Error("running() should be false after exited is set") + } +} + +// TestReadExitError verifies the exit-code extraction logic for several cases: +// nil, non-exit errors, specific codes, and a signal-killed process. +func TestReadExitError(t *testing.T) { + tests := []struct { + name string + errFn func() error + wantCode int + }{ + { + name: "nil error returns 0", + errFn: func() error { return nil }, + wantCode: 0, + }, + { + name: "plain error returns 0", + errFn: func() error { return errors.New("some error") }, + wantCode: 0, + }, + { + name: "exit code 42", + errFn: func() error { + return exec.Command("sh", "-c", "exit 42").Run() + }, + wantCode: 42, + }, + { + name: "exit code 1", + errFn: func() error { + return exec.Command("sh", "-c", "exit 1").Run() + }, + wantCode: 1, + }, + { + name: "signal-killed process is clamped to 1", + errFn: func() error { + cmd := exec.Command("sleep", "30") + if err := cmd.Start(); err != nil { + t.Fatal("failed to start process:", err) + } + _ = cmd.Process.Kill() + return cmd.Wait() + }, + wantCode: 1, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if got := readExitError(tc.errFn()); got != tc.wantCode { + t.Errorf("readExitError() = %d, want %d", got, tc.wantCode) + } + }) + } +} + +// TestRunProcessCleanExit verifies that runProcess returns exit code 0 and no +// error for a process that exits successfully, and marks the process as stopped. +func TestRunProcessCleanExit(t *testing.T) { + p := makeTestProcess(t, "true") + state := p.runProcess("test") + + if state.ExitCode != 0 { + t.Errorf("expected exit code 0, got %d", state.ExitCode) + } + if state.Error != nil { + t.Errorf("expected no error, got %v", state.Error) + } + if p.running() { + t.Error("running() should be false after runProcess returns") + } +} + +// TestRunProcessNonZeroExit verifies that runProcess captures a non-zero exit +// code and returns a non-nil error. +func TestRunProcessNonZeroExit(t *testing.T) { + p := makeTestProcess(t, "sh", "-c", "exit 3") + state := p.runProcess("test") + + if state.ExitCode != 3 { + t.Errorf("expected exit code 3, got %d", state.ExitCode) + } + if state.Error == nil { + t.Error("expected a non-nil error for non-zero exit") + } +} + +// TestRunProcessSigterm verifies that forwarding SIGTERM via sigChan causes the +// child process to exit and runProcess to return promptly. +func TestRunProcessSigterm(t *testing.T) { + p := makeTestProcess(t, "sleep", "30") + // Use a short TermTimeout so the internal timer goroutine cleans up quickly + // after the process exits rather than leaking for the default 30 seconds. + p.config.TermTimeout = 2 + + go func() { + time.Sleep(50 * time.Millisecond) + p.sigChan <- syscall.SIGTERM + }() + + start := time.Now() + state := p.runProcess("test") + elapsed := time.Since(start) + + if elapsed > 5*time.Second { + t.Errorf("runProcess took too long after SIGTERM: %v", elapsed) + } + if state.ExitCode == 0 { + t.Errorf("expected non-zero exit code after SIGTERM, got 0") + } +} + +// TestRunProcessKillOnTimeout verifies that a process which ignores SIGTERM is +// forcibly killed once TermTimeout elapses. +func TestRunProcessKillOnTimeout(t *testing.T) { + p := makeTestProcess(t, "sh", "-c", "trap '' TERM; sleep 30") + p.config.TermTimeout = 1 + + go func() { + time.Sleep(50 * time.Millisecond) + p.sigChan <- syscall.SIGTERM + }() + + start := time.Now() + state := p.runProcess("test") + elapsed := time.Since(start) + + if elapsed < time.Second { + t.Errorf("expected runProcess to take at least 1s for the kill timeout, took %v", elapsed) + } + if elapsed > 10*time.Second { + t.Errorf("runProcess took too long waiting for forced kill: %v", elapsed) + } + if state.ExitCode == 0 { + t.Errorf("expected non-zero exit code after being killed, got 0") + } +} + +// TestRunSecretProcessSuccess verifies that RunSecretProcess captures stdout +// from a process that exits cleanly. +func TestRunSecretProcessSuccess(t *testing.T) { + conf := configfile.SecretProcess{ + Name: "echo", + CMD: "echo", + Args: []string{"hello"}, + TermTimeout: 5, + } + stdout, stderr, err := RunSecretProcess(conf, internallogger.NewFakeLogger()) + + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if stdout != "hello\n" { + t.Errorf("expected stdout %q, got %q", "hello\n", stdout) + } + if stderr != "" { + t.Errorf("expected empty stderr, got %q", stderr) + } +} + +// TestRunSecretProcessNonZeroExit verifies that RunSecretProcess returns an +// error on non-zero exit and surfaces stderr as the second return value. +func TestRunSecretProcessNonZeroExit(t *testing.T) { + conf := configfile.SecretProcess{ + Name: "failing", + CMD: "sh", + Args: []string{"-c", "echo 'oops' >&2; exit 1"}, + TermTimeout: 5, + } + stdout, stderr, err := RunSecretProcess(conf, internallogger.NewFakeLogger()) + + if err == nil { + t.Fatal("expected a non-nil error for non-zero exit") + } + if stdout != "" { + t.Errorf("expected empty stdout on failure, got %q", stdout) + } + if stderr != "oops\n" { + t.Errorf("expected stderr %q, got %q", "oops\n", stderr) + } +} + +// TestRunSecretProcessTimeout verifies that RunSecretProcess returns an error +// when the child process exceeds TermTimeout. +func TestRunSecretProcessTimeout(t *testing.T) { + conf := configfile.SecretProcess{ + Name: "slow", + CMD: "sleep", + Args: []string{"30"}, + TermTimeout: 1, + } + + start := time.Now() + _, _, err := RunSecretProcess(conf, internallogger.NewFakeLogger()) + elapsed := time.Since(start) + + if err == nil { + t.Fatal("expected a non-nil error due to context timeout") + } + if elapsed > 5*time.Second { + t.Errorf("timeout test ran too long: %v", elapsed) + } +} + +// TestExitStatusFormatter verifies that exitStatusFormatter produces valid JSON +// containing the expected name and exit_code fields. +func TestExitStatusFormatter(t *testing.T) { + pm := &ProcessManger{ + EndList: []*processEnd{ + {Name: "init-step", ProcessType: "init", ExitCode: 0}, + {Name: "main-app", ProcessType: "main", ExitCode: 1}, + }, + pmlogger: internallogger.NewFakeLogger(), + } + + result := pm.exitStatusFormatter() + + var parsed []map[string]interface{} + if err := json.Unmarshal([]byte(result), &parsed); err != nil { + t.Fatalf("exitStatusFormatter returned invalid JSON: %v\ngot: %s", err, result) + } + if len(parsed) != 2 { + t.Fatalf("expected 2 entries in JSON output, got %d", len(parsed)) + } + if parsed[0]["name"] != "init-step" { + t.Errorf("expected name 'init-step', got %v", parsed[0]["name"]) + } + if parsed[1]["exit_code"] != float64(1) { + t.Errorf("expected exit_code 1 for second entry, got %v", parsed[1]["exit_code"]) + } +} diff --git a/processmanager/processmanager.go b/processmanager/processmanager.go index 529c389..e24dcd7 100644 --- a/processmanager/processmanager.go +++ b/processmanager/processmanager.go @@ -265,7 +265,7 @@ func (pm *ProcessManger) redirectOutput(stdout, stderr *bytepipe.BytePipe, confi for stderrData := range stderr.Ready { for _, s := range strings.Split(stderrData, "\n") { if len(s) != 0 { - pm.logger.Submit(newLog(processlogger.STDERR, stderrData)) + pm.logger.Submit(newLog(processlogger.STDERR, s+"\n")) } } } diff --git a/signalreplicator/signal.go b/signalreplicator/signal.go index 6e820b5..1e1694d 100755 --- a/signalreplicator/signal.go +++ b/signalreplicator/signal.go @@ -66,8 +66,13 @@ func (r *replicator) remove(ch chan os.Signal) { func (r *replicator) listen() { for s := range r.input { + r.RLock() for _, procChan := range r.signalChannels { - procChan <- s + select { + case procChan <- s: + default: + } } + r.RUnlock() } } diff --git a/signalreplicator/signal_test.go b/signalreplicator/signal_test.go index 9fd9f9d..af179b4 100755 --- a/signalreplicator/signal_test.go +++ b/signalreplicator/signal_test.go @@ -2,6 +2,7 @@ package signalreplicator import ( "os" + "sync" "syscall" "testing" "time" @@ -45,3 +46,57 @@ func TestReplicator(t *testing.T) { t.Fail() } } + +// TestReplicatorConcurrentSendAndRemove guards against regression of the data +// race between listen() and remove(). Run with -race to detect any violation. +func TestReplicatorConcurrentSendAndRemove(t *testing.T) { + const numChannels = 10 + const numSignals = 50 + + channels := make([]chan os.Signal, numChannels) + for i := range channels { + channels[i] = make(chan os.Signal, numSignals) + Register(channels[i]) + } + + var wg sync.WaitGroup + + // Drain all channels in the background so sends never block. + for _, ch := range channels { + ch := ch + wg.Add(1) + go func() { + defer wg.Done() + for range ch { // drain intentionally empty + } + }() + } + + // Send signals concurrently with removals. + var senderWg sync.WaitGroup + senderWg.Add(1) + go func() { + defer senderWg.Done() + for i := 0; i < numSignals; i++ { + Send(syscall.SIGHUP) + } + }() + + senderWg.Add(1) + go func() { + defer senderWg.Done() + for _, ch := range channels { + Remove(ch) + } + }() + + senderWg.Wait() + + // Close all channels so the drain goroutines exit cleanly. + // Re-remove to ensure any already-removed channels don't panic. + for _, ch := range channels { + Remove(ch) + close(ch) + } + wg.Wait() +} diff --git a/testbin/main.go b/testbin/main.go index e961d05..0df458c 100644 --- a/testbin/main.go +++ b/testbin/main.go @@ -33,7 +33,7 @@ var ( ignoreSignalsFlag = flag.Bool("ignore-signals", false, "Ignore the signals that the process gets.") logJSONFlag = flag.Int("log-json", 0, "Log some random json messages. The number says how many logs you want.") - addTestEnv = flag.Bool("send-env", false, "returns a test environment variable: 'LUANCH_TEST=LIFTOFF'") + addTestEnv = flag.Bool("send-env", false, "returns a test environment variable: 'LAUNCH_TEST=LIFTOFF'") helpFlag = flag.Bool("h", false, "Show the help menu") versionFlag = flag.Bool("v", false, "Displays a version number.")