diff --git a/.gitignore b/.gitignore index 8ff0afd..6579b63 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,7 @@ /socketmaster /examples/childserver/childserver /.vagrant + +# Nix +result +result-* diff --git a/CHANGELOG.md b/CHANGELOG.md index 2cecfcc..21550d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,7 +37,7 @@ =================== * Prefix output with the [pid] - * Set EINHORN_FDS to be eninhorn-compatible + * Set EINHORN_FDS to be einhorn-compatible * Adding license and changelog * Add a note about other related projects diff --git a/config.go b/config.go new file mode 100644 index 0000000..7b51cfa --- /dev/null +++ b/config.go @@ -0,0 +1,74 @@ +package main + +import ( + "errors" + "flag" + "io/ioutil" + + yaml "gopkg.in/yaml.v3" +) + +/* + flag.StringVar(&command, "command", "", "Program to start") + flag.StringVar(&addr, "listen", "tcp://:8080", "Port on which to bind") + flag.IntVar(&startTime, "start", 3000, "How long the new process takes to boot in millis") + flag.BoolVar(&useSyslog, "syslog", false, "Log to syslog") + flag.StringVar(&username, "user", "", "run the command as this user") +*/ + +// The mutable configuration items +type Config struct { + Command string `yaml:"command"` + Environment map[string]string `yaml:"environment"` +} + +func emptyConfig() Config { + return Config{ + Command: "", + Environment: make(map[string]string), + } +} + +func (config *Config) LoadFile(path string) error { + yamlFile, err := ioutil.ReadFile(path) + if err != nil { + return err + } + return config.LoadBytes(yamlFile) +} + +func (config *Config) LoadBytes(yamlString []byte) error { + return yaml.Unmarshal(yamlString, &config) +} + +func (config *Config) LoadString(yamlString string) error { + return config.LoadBytes([]byte(yamlString)) +} + +// Some of the mutable flags can be set on the command line as well. +func (config *Config) LinkToFlagSet(flags *flag.FlagSet) { + flags.StringVar(&config.Command, "command", config.Command, "Program to start") +} + +func (config *Config) Merge(other Config) error { + empty := emptyConfig() + + if other.Command != empty.Command { + if config.Command == empty.Command { + config.Command = other.Command + } else { + return errors.New("command can only be set once.") + } + } + + if len(other.Environment) != 0 { + if len(config.Environment) == 0 { + config.Environment = other.Environment + } else { + // Can't be set via args, so impossible + return errors.New("environment can only be set once.") + } + } + + return nil +} diff --git a/config_test.go b/config_test.go new file mode 100644 index 0000000..cc54b7f --- /dev/null +++ b/config_test.go @@ -0,0 +1,72 @@ +package main + +import ( + "testing" + + yaml "gopkg.in/yaml.v3" +) + +func Test_Config_Empty(t *testing.T) { + var config Config + err := config.LoadBytes( + []byte(""), + ) + if err != nil { + t.Fatal("unexpected error", err) + } + if config.Command != "" { + t.Fatalf("wrong Command value '%s'", config.Command) + } +} + +// func Test_Config_Marshal(t *testing.T) { +// var config Config +// config.Command = "hi" +// config.Environment = make(map[string]string) +// config.Environment["greeting"] = "hello" + +// out, err := yaml.Marshal(config) + +// if err != nil { +// t.Fatal("unexpected error", err) +// } + +// fmt.Println(string(out)) +// t.Fatal("???") +// } + +func Test_Config_Command(t *testing.T) { + var config Config + err := yaml.Unmarshal([]byte(`command: hi`), &config) + // err := config.LoadString(`command: hi`) + if err != nil { + t.Fatal("unexpected error", err) + } + if config.Command != "hi" { + t.Fatalf("wrong command value '%s'", config.Command) + } +} + +// environment +func Test_Config_Environment(t *testing.T) { + var config Config + err := yaml.Unmarshal([]byte("command: hi\nenvironment:\n hello: world"), &config) + // err := config.LoadString(`command: hi`) + if err != nil { + t.Fatal("unexpected error", err) + } + if config.Environment["hello"] != "world" { + t.Fatalf("wrong environment.hello value '%s'", config.Environment["hello"]) + } +} + +func Test_Config_Merge_Environment(t *testing.T) { + a := Config{Environment: map[string]string{}} + b := Config{Environment: map[string]string{"foo": "bar"}} + + a.Merge(b) + + if a.Environment["foo"] != "bar" { + t.Fatal("foo isn't bar") + } +} diff --git a/environment.go b/environment.go new file mode 100644 index 0000000..af92c9f --- /dev/null +++ b/environment.go @@ -0,0 +1,33 @@ +package main + +import "strings" + +func EnvironmentToMap(envStrings []string) map[string]string { + envMap := make(map[string]string) + for _, envLine := range envStrings { + + // Get the variable name + var name string + + var silly = strings.Split(envLine, "=") + if len(silly) == 0 { + // pathological, skip + continue + } + + name = silly[0] + + value := envLine[len(name)+1:] + + envMap[name] = value + } + return envMap +} + +func MapToEnvironment(envMap map[string]string) []string { + var envStrings []string + for k, v := range envMap { + envStrings = append(envStrings, k+"="+v) + } + return envStrings +} diff --git a/environment_test.go b/environment_test.go new file mode 100644 index 0000000..f39f75e --- /dev/null +++ b/environment_test.go @@ -0,0 +1,23 @@ +package main + +import "testing" + +func Test_EnvironmentToMap(t *testing.T) { + m := EnvironmentToMap([]string{"a=b=c"}) + if len(m) != 1 { + t.Fatal("len") + } + if m["a"] != "b=c" { + t.Fatal("a != b=c") + } +} + +func Test_MapToEnvironment(t *testing.T) { + m := MapToEnvironment(map[string]string{"a": "b=c"}) + if len(m) != 1 { + t.Fatal("len") + } + if m[0] != "a=b=c" { + t.Fatal("a=b=c") + } +} diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..a316c1a --- /dev/null +++ b/flake.lock @@ -0,0 +1,42 @@ +{ + "nodes": { + "flake-utils": { + "locked": { + "lastModified": 1649676176, + "narHash": "sha256-OWKJratjt2RW151VUlJPRALb7OU2S5s+f0vLj4o1bHM=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "a4b154ebbdc88c8498a5c7b01589addc9e9cb678", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1650022871, + "narHash": "sha256-0DhWgQN5v9TIKu+iVt5FH6V+gZvvp+UCX5cnQ2uiQ2g=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "a7cf9372e97725eaa6da1e72698af9d23a3ea083", + "type": "github" + }, + "original": { + "owner": "NixOS", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..fc9b2e8 --- /dev/null +++ b/flake.nix @@ -0,0 +1,56 @@ +{ + description = "A very basic flake"; + + inputs.nixpkgs.url = "github:NixOS/nixpkgs"; + inputs.flake-utils.url = "github:numtide/flake-utils"; + + outputs = { self, nixpkgs, flake-utils }: + let + inherit (flake-utils.lib) eachDefaultSystem; + inherit (nixpkgs) lib; + + flakeAttrs = { + nixosModules.default = { lib, pkgs, ... }: + let + inherit (pkgs.stdenv.hostPlatform) system; + in + { + imports = [ ./nix/nixos/module.nix ]; + config = { + socketmaster.package = + lib.mkDefault self.packages.${system}.default; + }; + }; + }; + + perSystem = system: + let + pkgs = nixpkgs.legacyPackages.${system}; + in + { + devShell = self.devShells.${system}.default; + devShells.default = pkgs.mkShell { + nativeBuildInputs = [ + pkgs.go + pkgs.gopls + pkgs.go-outline + pkgs.nixpkgs-fmt + ]; + }; + + defaultPackage = self.packages.${system}.default; + packages.default = pkgs.callPackage ./nix/package.nix { }; + checks = + lib.optionalAttrs pkgs.stdenv.isLinux { + nixos = pkgs.callPackage ./nix/nixos/test.nix { + extraModules = [ self.nixosModules.default ]; + }; + }; + }; + + systemAttrs = eachDefaultSystem perSystem; + + in + systemAttrs // flakeAttrs; + +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..340f51a --- /dev/null +++ b/go.mod @@ -0,0 +1,17 @@ +module github.com/zimbatm/socketmaster + +go 1.17 + +require ( + google.golang.org/grpc v1.45.0 + gopkg.in/yaml.v3 v3.0.0-20220512140231-539c8e751b99 +) + +require ( + github.com/golang/protobuf v1.5.2 // indirect + golang.org/x/net v0.0.0-20200822124328-c89045814202 // indirect + golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd // indirect + golang.org/x/text v0.3.0 // indirect + google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013 // indirect + google.golang.org/protobuf v1.26.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..dcd2990 --- /dev/null +++ b/go.sum @@ -0,0 +1,123 @@ +cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY= +github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= +github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= +github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= +github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= +github.com/cncf/udpa/go v0.0.0-20210930031921-04548b0d99d4/go.mod h1:6pvJx4me5XPnfI9Z40ddWsdw2W/uZgQLFXToKeRcDiI= +github.com/cncf/xds/go v0.0.0-20210805033703-aa0b78936158/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= +github.com/cncf/xds/go v0.0.0-20210922020428-25de7278fc84/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= +github.com/cncf/xds/go v0.0.0-20211011173535-cb28da3451f1/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= +github.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk= +github.com/envoyproxy/go-control-plane v0.9.10-0.20210907150352-cf90f659a021/go.mod h1:AFq3mo9L8Lqqiid3OhADV3RfLJnjiw63cSpi+fDTRC0= +github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= +github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= +github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= +github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= +github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= +github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= +github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= +github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= +github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= +github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= +github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/golang/protobuf v1.5.2 h1:ROPKBNFfQgOUMifHyP+KYbvpjbdoFNs+aK7DXlji0Tw= +github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= +github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +go.opentelemetry.io/proto/otlp v0.7.0/go.mod h1:PqfVotwruBrMGOCsRd/89rSnXhoiJIqeYNgFYFoEGnI= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= +golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20200822124328-c89045814202 h1:VvcQYSHwXgi7W+TpUR6A9g6Up98WAHf3f/ulnJ62IyA= +golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= +golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= +golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd h1:xhmwyvizuTgC2qz7ZlMluP20uW+C3Rm0FD/WLDX8884= +golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= +golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= +google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= +google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= +google.golang.org/genproto v0.0.0-20200513103714-09dca8ec2884/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013 h1:+kGHl1aib/qcwaRi1CbqBZ1rk19r85MNUf8HaBghugY= +google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= +google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= +google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= +google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= +google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= +google.golang.org/grpc v1.33.1/go.mod h1:fr5YgcSWrqhRRxogOsw7RzIpsmvOZ6IcH4kBYTpR3n0= +google.golang.org/grpc v1.36.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU= +google.golang.org/grpc v1.45.0 h1:NEpgUqV3Z+ZjkqMsxMg11IaDrXY4RY6CQukSGK0uI1M= +google.golang.org/grpc v1.45.0/go.mod h1:lN7owxKUQEqMfSyQikvvk5tf/6zMPsrK+ONuO11+0rQ= +google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= +google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= +google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= +google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= +google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= +google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.26.0 h1:bxAC2xTBsZGibn2RTntX0oH50xLsqy1OxA9tTL3p/lk= +google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20220512140231-539c8e751b99 h1:dbuHpmKjkDzSOMKAWl10QNlgaZUd3V1q99xc81tt2Kc= +gopkg.in/yaml.v3 v3.0.0-20220512140231-539c8e751b99/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= diff --git a/inputs.go b/inputs.go new file mode 100644 index 0000000..5dff18b --- /dev/null +++ b/inputs.go @@ -0,0 +1,60 @@ +package main + +import ( + "errors" + "flag" + "fmt" +) + +type Inputs struct { + commandlineConfig Config + + configFile string + + addr string + startTime int + useSyslog bool + username string +} + +func ParseInputs(args []string) (*Inputs, error) { + var inputs Inputs + flags := flag.NewFlagSet("socketmaster", flag.ExitOnError) + + inputs.commandlineConfig.LinkToFlagSet(flags) + flags.StringVar(&inputs.configFile, "config-file", "", "Configuration file to load on start and reload") + flags.StringVar(&inputs.addr, "listen", "tcp://:8080", "Port on which to bind") + flags.IntVar(&inputs.startTime, "start", 3000, "How long the new process takes to boot in millis") + flags.BoolVar(&inputs.useSyslog, "syslog", false, "Log to syslog") + flags.StringVar(&inputs.username, "user", "", "run the command as this user") + + err := flags.Parse(args) + if err != nil { + return nil, err + } + + return &inputs, err +} + +func (inputs *Inputs) LoadConfig() (*Config, error) { + + if inputs.configFile == "" { + return &inputs.commandlineConfig, nil + } + + config := inputs.commandlineConfig + + var fileConfig Config + err := fileConfig.LoadFile(inputs.configFile) + if err != nil { + return nil, err + } + + err = config.Merge(fileConfig) + if err != nil { + return nil, errors.New(fmt.Sprintf( + "Between the command line and the config file '%s', %s", inputs.configFile, err.Error())) + } + + return &config, nil +} diff --git a/inputs_test.go b/inputs_test.go new file mode 100644 index 0000000..7f05ded --- /dev/null +++ b/inputs_test.go @@ -0,0 +1,56 @@ +package main + +import ( + "testing" +) + +func Test_ParseInputs_Empty(t *testing.T) { + inputs, err := ParseInputs([]string{}) + if err != nil { + t.Fatal("unexpected error", err) + } + if inputs.commandlineConfig.Command != "" { + t.Fatalf("wrong command value '%s'", inputs.commandlineConfig.Command) + } + if inputs.configFile != "" { + t.Fatalf("wrong configFile value '%s'", inputs.configFile) + } +} + +func Test_ParseInputs_Command(t *testing.T) { + inputs, err := ParseInputs([]string{"--command", "hello"}) + if err != nil { + t.Fatal("unexpected error", err) + } + if inputs.commandlineConfig.Command != "hello" { + t.Fatalf("wrong command value '%s'", inputs.commandlineConfig.Command) + } +} + +func Test_ParseInputs_ConfigFile(t *testing.T) { + inputs, err := ParseInputs([]string{"--config-file", "testdata/command.yaml"}) + if err != nil { + t.Fatal("unexpected error", err) + } + if inputs.configFile != "testdata/command.yaml" { + t.Fatalf("wrong command value '%s'", inputs.commandlineConfig.Command) + } +} + +func Test_ParseInputs_ConfigFile_Command_Error(t *testing.T) { + inputs, err := ParseInputs([]string{"--config-file", "testdata/command.yaml", "--command", "would-be-forgotten"}) + if err != nil { + t.Fatal("unexpected error", err) + } + + unused, err := inputs.LoadConfig() + + if unused != nil { + t.Fatal("LoadConfig must fail") + } + + if err.Error() != "Between the command line and the config file 'testdata/command.yaml', command can only be set once." { + t.Fatalf("Wrong error, was '%s'", err.Error()) + } + +} diff --git a/nix/nixos/module.nix b/nix/nixos/module.nix new file mode 100644 index 0000000..45b9f8f --- /dev/null +++ b/nix/nixos/module.nix @@ -0,0 +1,51 @@ +{ config, lib, pkgs, ... }: +let + inherit (lib) + mapAttrs + attrValues + mkIf + mkOption + mkMerge + types + ; + inherit (lib.types) + attrsOf + submoduleWith + ; + + cfg = config.socketmaster; + +in +{ + options.socketmaster = { + package = mkOption { + description = '' + The socketmaster package to use. + + Note that services are not automatically restarted + when this value changes. + ''; + type = types.package; + default = pkgs.socketmaster; + defaultText = "socketmaster"; + }; + services = mkOption { + description = '' + A collection of socketmaster-driven services. + + Each entry will be mapped to a systemd service + with the same name. + ''; + type = attrsOf (submoduleWith { + modules = [ ./socketmaster-service.nix ]; + specialArgs.systemConfig = config; + specialArgs.pkgs = pkgs; + }); + default = { }; + }; + }; + config = mkIf (cfg.services != { }) { + systemd.services = mapAttrs (k: v: v.systemdServiceModule) cfg.services; + environment = mkMerge (attrValues (mapAttrs (k: v: v.environmentConfig) cfg.services)); + }; +} diff --git a/nix/nixos/socketmaster-service.nix b/nix/nixos/socketmaster-service.nix new file mode 100644 index 0000000..cfbcdba --- /dev/null +++ b/nix/nixos/socketmaster-service.nix @@ -0,0 +1,82 @@ +{ config, lib, name, pkgs, systemConfig, ... }: +let + inherit (lib) + mkOption + types + ; + inherit (types) + str + ; + + etcPath = "/etc/${etcSubPath}"; + etcSubPath = "socketmaster/services/${name}.yaml"; + + format = pkgs.formats.yaml { }; + + settingsModule = { + # Forward compatibility, supporting newer `package`'s config items. + freeformType = format.type; + + options = { + command = mkOption { + description = '' + Program to start + ''; + type = str; + # no default + }; + + }; + }; + + configFile = format.generate "socketmaster-service-${name}.yaml" config.settings; + +in +{ + options = { + + settings = mkOption { + type = types.submodule settingsModule; + }; + + startMillis = mkOption { + description = '' + How long the new process takes to boot in milliseconds. + ''; + default = 3000; + }; + + ### Internal ### + + systemdServiceModule = mkOption { + internal = true; + # https://github.com/NixOS/nixpkgs/pull/163617 + type = types.deferredModule or types.raw or types.unspecified; + }; + + environmentConfig = mkOption { + internal = true; + # https://github.com/NixOS/nixpkgs/pull/163617 + type = types.deferredModule or types.raw or types.unspecified; + }; + }; + + config = { + systemdServiceModule = { ... }: { + # Avoid automatic restarts. + stopIfChanged = false; + restartIfChanged = false; + # Reload when the config changes. + reloadTriggers = [ configFile ]; + reload = "kill -HUP $MAINPID"; + serviceConfig.ExecStart = [ + "${systemConfig.socketmaster.package}/bin/socketmaster -config-file ${lib.escapeShellArg etcPath} -start ${toString config.startMillis} -listen fd://3" + ]; + }; + environmentConfig = { + etc."${etcSubPath}" = { + source = configFile; + }; + }; + }; +} diff --git a/nix/nixos/test.nix b/nix/nixos/test.nix new file mode 100644 index 0000000..1f0a856 --- /dev/null +++ b/nix/nixos/test.nix @@ -0,0 +1,126 @@ +{ nixosTest, pkgs, extraModules ? [ ] }: + +let + socket-server = pkgs.callPackage ./test/socket-server { }; +in + +nixosTest { + nodes.machine = { config, lib, ... }: { + imports = extraModules; + config = { + environment.systemPackages = [ + # Used by testScript + pkgs.socat + ]; + systemd.sockets.socket-server = { + wantedBy = [ "sockets.target" ]; + listenStreams = [ "0.0.0.0:2022" ]; + }; + systemd.services.socket-server = { + wantedBy = [ "multi-user.target" ]; + }; + + socketmaster.services.socket-server = { + settings.command = lib.mkDefault "${socket-server}/bin/socket-server"; + settings.environment.ECHO_VALUE = lib.mkDefault "v1"; + }; + + specialisation.system-v2 = { + inheritParentConfig = true; + configuration = { config, lib, pkgs, ... }: { + + socketmaster.services.socket-server = { + # Require both environment and command replacement to set it to "v2" + settings.environment.ECHO_VALUE = "v2-please"; + settings.command = "${pkgs.writeScript "socket-server-wrapped" '' + #!${pkgs.runtimeShell} + ECHO_VALUE=''${ECHO_VALUE/-please/} + exec ${socket-server}/bin/socket-server + ''}"; + }; + + }; + }; + + # The activation script runs after outdated services were stopped, but + # before the updated ones start. + system.activationScripts.etc.deps = [ "check-socket" ]; # run before etc + system.activationScripts.check-socket.text = '' + # # check that the service has been stopped + # ${config.systemd.package}/bin/systemctl status socket-server.service >/dev/console + # r=$? + # if [[ $r != 3 ]]; then + # echo >&2 systemctl returned unexpected exit code $r >/dev/console + # exit 1 + # fi + + # # The above trips the activation script error checker. Undo that. + # _localstatus=0 + # _status=0 + + # detach a client + ( (echo '{"cmd":"echo"}'; while ! [[ -e /tmp/client.stop ]]; do sleep 0.1; done; echo '{"cmd":"echo"}') \ + | ${pkgs.socat}/bin/socat - TCP4:localhost:2022 \ + | tee /tmp/client.out \ + | while read ln; do + echo "client got response: $ln" + done >/dev/console + ) /tmp/client.out 2>/tmp/client.err & + clientpid=$! + echo $clientpid >/tmp/client.pid + disown %% + + # delay to make sure client starts while service was down + sleep 1 + + # Fail fast if something is wrong with the client + if ! [[ -d /proc/$clientpid ]]; then + echo >/dev/console "client exited unexpectedly. It shouldn't be able to complete while the service is not running." + exit 1 + fi + ''; + + }; + }; + testScript = '' + machine.wait_for_unit("sockets.target") + + with subtest("Service v1 works"): + machine.succeed(""" + echo '{"cmd":"echo"}' \ + | socat - TCP4:localhost:2022 \ + | grep -E '^"v1"$' + """) + + machine.succeed(""" + /run/booted-system/specialisation/system-v2/bin/switch-to-configuration test + """) + + # Wait for the configured startup time to elapse + machine.succeed("sleep 4") + + with subtest("Service v2 works"): + machine.succeed(""" + echo '{"cmd":"echo"}' \ + | socat - TCP4:localhost:2022 \ + | tee /dev/console \ + | grep -E '^"v2"$' + """) + + with subtest("Service v1 still serves a client that connected during NixOS switching"): + machine.succeed(""" + ( + touch /tmp/client.stop + while kill -0 "$(cat /tmp/client.pid)"; do + echo Waiting for client to exit + sleep 0.1; + done + echo out: + cat /tmp/client.out + echo err: + cat /tmp/client.err + ) >/dev/console + """) + + ''; +} diff --git a/nix/nixos/test/socket-server/.gitignore b/nix/nixos/test/socket-server/.gitignore new file mode 100644 index 0000000..48b8bf9 --- /dev/null +++ b/nix/nixos/test/socket-server/.gitignore @@ -0,0 +1 @@ +vendor/ diff --git a/nix/nixos/test/socket-server/default.nix b/nix/nixos/test/socket-server/default.nix new file mode 100644 index 0000000..de7e5ac --- /dev/null +++ b/nix/nixos/test/socket-server/default.nix @@ -0,0 +1,8 @@ +{ buildGoModule }: + +buildGoModule { + pname = "socket-server"; + version = "1.0.0"; + src = ./.; + vendorSha256 = "sha256-wivLZzDVpLPi+RgVti03Se0TQ0kco7CBnPFOITLFwx8="; +} diff --git a/nix/nixos/test/socket-server/go.mod b/nix/nixos/test/socket-server/go.mod new file mode 100644 index 0000000..409e053 --- /dev/null +++ b/nix/nixos/test/socket-server/go.mod @@ -0,0 +1,5 @@ +module example.com/socket-server + +go 1.17 + +require github.com/coreos/go-systemd v0.0.0-20191104093116-d3cd4ed1dbcf diff --git a/nix/nixos/test/socket-server/go.sum b/nix/nixos/test/socket-server/go.sum new file mode 100644 index 0000000..7c6c1bb --- /dev/null +++ b/nix/nixos/test/socket-server/go.sum @@ -0,0 +1,2 @@ +github.com/coreos/go-systemd v0.0.0-20191104093116-d3cd4ed1dbcf h1:iW4rZ826su+pqaw19uhpSCzhj44qo35pNgKFGqzDKkU= +github.com/coreos/go-systemd v0.0.0-20191104093116-d3cd4ed1dbcf/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= diff --git a/nix/nixos/test/socket-server/server.go b/nix/nixos/test/socket-server/server.go new file mode 100644 index 0000000..e2e649e --- /dev/null +++ b/nix/nixos/test/socket-server/server.go @@ -0,0 +1,91 @@ +package main + +import ( + "bufio" + "encoding/json" + "fmt" + "net" + "os" + + "github.com/coreos/go-systemd/activation" +) + +func main() { + echoValue := os.Getenv("ECHO_VALUE") + Log("Starting; ECHO_VALUE=", echoValue) + + Log("LISTEN_PID", os.Getenv("LISTEN_PID")) + Log("LISTEN_FDS", os.Getenv("LISTEN_FDS")) + Log("LISTEN_FDNAMES", os.Getenv("LISTEN_FDNAMES")) + + listeners, err := activation.Listeners() + if err != nil { + panic(err) + } + + if listeners == nil { + panic("listeners == nil") + } + if len(listeners) != 1 { + panic(fmt.Sprintf("Unexpected number of socket activation fds: %d", len(listeners))) + } + l := listeners[0] + + Log("Ready") + for { + conn, err := l.Accept() + if err != nil { + Log("Error accepting: ", err.Error()) + os.Exit(1) + } + go handleRequest(conn, echoValue) + } +} + +func handleRequest(conn net.Conn, echoValue string) { + Log("Handling connection") + r := bufio.NewReader(conn) + w := bufio.NewWriter(conn) + for { + Log("Reading command from connection") + ln, err := r.ReadBytes('\n') + if err != nil { + Log("Error reading connection: ", err.Error()) + break + } + var result map[string]interface{} + err = json.Unmarshal(ln, &result) + if err != nil { + Log("Error parsing message: ", err.Error()) + break + } + if result["cmd"] == "echo" { + bytes, err := json.Marshal(echoValue) + if err != nil { + Log("Error marshalling echo response: ", err.Error()) + break + } + n, err := w.Write(bytes) + if err != nil { + Log("Error writing to socket; wrote ", n, " bytes; error: ", err.Error()) + break + } + err = w.WriteByte('\n') + if err != nil { + Log("Error writing endline to socket: ", err.Error()) + break + } + err = w.Flush() + if err != nil { + Log("Error flushing socket: ", err.Error()) + break + } + } + } + Log("Closing connection") + conn.Close() // Ignoring Close() error +} + +func Log(args ...interface{}) { + fmt.Fprintln(os.Stderr, args...) +} diff --git a/nix/package.nix b/nix/package.nix new file mode 100644 index 0000000..84fd3a5 --- /dev/null +++ b/nix/package.nix @@ -0,0 +1,24 @@ +{ buildGoModule, lib }: + +buildGoModule { + name = "socketmaster"; + src = lib.cleanSourceWith { + src = lib.cleanSource ../.; + filter = path: type: + baseNameOf path != "nix" && ( + lib.hasSuffix ".go" path + || lib.hasSuffix "/go.mod" path + || lib.hasSuffix "/go.sum" path + || lib.hasPrefix (toString ../testdata) (toString path) + ); + }; + + vendorSha256 = "sha256-dlDSa6UT3c/sLzPYgWnBt4PINk7JhTlC6ZFMKXBkUGw="; + + meta = with lib; { + description = "Restart services without losing connections"; + homepage = "https://github.com/zimbatm/socketmaster"; + license = licenses.mit; + maintainers = with maintainers; [ zimbatm ]; + }; +} diff --git a/process_group.go b/process_group.go index 3a41e83..b553380 100644 --- a/process_group.go +++ b/process_group.go @@ -2,9 +2,12 @@ package main import ( "bufio" + "errors" "flag" + "fmt" "log" "os" + "os/exec" "os/user" "strconv" "sync" @@ -15,22 +18,75 @@ type ProcessGroup struct { set *processSet wg sync.WaitGroup - commandPath string - sockfile *os.File - user *user.User + // For tracking which processes run an up to date config. + // Incremented on SIGHUP. + generation int + + inputs Inputs + sockfile *os.File + user *user.User +} + +// Below is the process life cycle state machine. +// +// It is not just descriptive, but prescriptive, as unexpected interleavings +// of events must not put socketmaster in an unexpected state. +// +// From \ To | Starting Operational Yielding Yielded Stopping Gone +// ------------+---------------------------------------------------------------------------------- +// Starting | id happy early stop startup-fail +// Operational | id happy shutdown operational-fail +// Yielding | id happy yield-timeout operational-fail +// Yielded | id last-call exit a or operational-fail +// Stopping | id exit b or killed or operational-fail +// Gone | id +// +// id: identity aka no-op +// happy: the usual path +// early stop: startup took too long and/or socketmaster wasn't notified that the process was ready +// shutdown: socketmaster is shutting down, not doing a hot reload +// yield-timeout: if the process doesn't respond to the request to yield, it does not know how to yield, or is defunct, so we stop and kill +// last-call: at some point we can't allow the old process to remain, so we stop and kill +// exit a: voluntary exit after being asked to yield +// exit b: graceful exit after being asked to yield and asked to stop +// *-fail: what it says on the tin +// +// Note that the lower triangle is empty, so the state machine has no cycles +// besides id. "Cyclical" behavior only manifests at a higher level, as new +// processes replace old ones. +type ProcessLifecycleState int + +const ( + Starting ProcessLifecycleState = iota + Operational // Accepting connections and handling existing connections + Yielding // Operational, should stop accepting connections + Yielded // Only handling existing connections + Stopping // releasing resources, cleaning up + Gone +) + +type ProcessState struct { + sync.Mutex + generation int + lifecycleState ProcessLifecycleState +} + +func (self *ProcessState) CanStop() bool { + return self.lifecycleState == Operational || self.lifecycleState == Stopping } type processSet struct { sync.Mutex - set map[*os.Process]bool + set map[*os.Process]ProcessState } -func MakeProcessGroup(commandPath string, sockfile *os.File, u *user.User) *ProcessGroup { +func MakeProcessGroup(inputs Inputs, sockfile *os.File, u *user.User) *ProcessGroup { return &ProcessGroup{ - set: newProcessSet(), - commandPath: commandPath, - sockfile: sockfile, - user: u, + set: newProcessSet(), + inputs: inputs, + sockfile: sockfile, + user: u, + generation: 0, } } @@ -42,7 +98,26 @@ func (self *ProcessGroup) StartProcess() (process *os.Process, err error) { return nil, err } - env := append(os.Environ(), "EINHORN_FDS=3") + config, err := self.inputs.LoadConfig() + if err != nil { + return nil, errors.New(fmt.Sprintf("Could not load config '%s'", err)) + } + + // Make sure parent values don't interfere with our child. + // All fds have already been consumed at this stage. + os.Unsetenv("LISTEN_PID") + + envMap := EnvironmentToMap(os.Environ()) + + envMap["EINHORN_FDS"] = "3" + envMap["LISTEN_FDS"] = "1" + envMap["LISTEN_FDNAMES"] = "socket" + + for key, value := range config.Environment { + envMap[key] = value + } + + env := MapToEnvironment(envMap) procAttr := &os.ProcAttr{ Env: env, @@ -60,41 +135,71 @@ func (self *ProcessGroup) StartProcess() (process *os.Process, err error) { } } - args := append([]string{self.commandPath}, flag.Args()...) - log.Println("Starting", self.commandPath, args) - process, err = os.StartProcess(self.commandPath, args, procAttr) + var commandPath string + + commandPath, err = exec.LookPath(config.Command) + if err != nil { + return nil, errors.New(fmt.Sprintf("Could not find executable '%s'", err)) + } + + args := append([]string{LISTEN_PID_HELPER_ARGV0}, commandPath) + args = append(args, flag.Args()...) + log.Println("Starting", args[1:]) + process, err = os.StartProcess(os.Args[0], args, procAttr) if err != nil { return } + state := ProcessState{ + generation: self.generation, + lifecycleState: Starting, + } + // Add to set - self.set.Add(process) + self.set.Add(process, state) // Prefix stdout and stderr lines with the [pid] and send it to the log logOutput(ioReader, process.Pid, &self.wg) // Handle the process death go func() { - state, err := process.Wait() + osProcState, err := process.Wait() - log.Println(process.Pid, state, err) + log.Println(process.Pid, osProcState, err) // Remove from set + self.set.Lock() self.set.Remove(process) + self.set.Unlock() // Process is gone ioReader.Close() self.wg.Done() + + state.Lock() + state.lifecycleState = Gone + state.Unlock() }() return } -func (self *ProcessGroup) SignalAll(signal os.Signal, except *os.Process) { - self.set.Each(func(process *os.Process) { - if process != except { +func (self *ProcessGroup) SignalAll(signal os.Signal, maxGeneration int) { + self.set.Each(func(process *os.Process, state ProcessState) { + if state.generation <= maxGeneration { + process.Signal(signal) + } + }) +} + +func (self *ProcessGroup) TerminateAll(signal os.Signal, maxGeneration int) { + self.set.Each(func(process *os.Process, state ProcessState) { + state.Lock() + if state.generation <= maxGeneration && state.CanStop() { process.Signal(signal) + state.lifecycleState = Stopping } + state.Unlock() }) } @@ -105,23 +210,23 @@ func (self *ProcessGroup) WaitAll() { // A thread-safe process set func newProcessSet() *processSet { set := new(processSet) - set.set = make(map[*os.Process]bool) + set.set = make(map[*os.Process]ProcessState) return set } -func (self *processSet) Add(process *os.Process) { +func (self *processSet) Add(process *os.Process, state ProcessState) { self.Lock() defer self.Unlock() - self.set[process] = true + self.set[process] = state } -func (self *processSet) Each(fn func(*os.Process)) { +func (self *processSet) Each(fn func(*os.Process, ProcessState)) { self.Lock() defer self.Unlock() - for process, _ := range self.set { - fn(process) + for process, state := range self.set { + fn(process, state) } } diff --git a/shell.nix b/shell.nix new file mode 100644 index 0000000..4ab395c --- /dev/null +++ b/shell.nix @@ -0,0 +1,2 @@ +# shell.nix is still required for vscode Nix Env Selector (2022-04) +(builtins.getFlake ("git+file://" + toString ./.)).devShells.${builtins.currentSystem}.default diff --git a/socketmaster.go b/socketmaster.go index 9b91c80..bbf579e 100644 --- a/socketmaster.go +++ b/socketmaster.go @@ -1,12 +1,10 @@ package main import ( - "flag" "fmt" "log" "log/syslog" "os" - "os/exec" "os/signal" "os/user" "syscall" @@ -25,6 +23,7 @@ func handleSignals(processGroup *ProcessGroup, c <-chan os.Signal, startTime int socketMasterFdEnvVar := fmt.Sprintf("SOCKETMASTER_FD=%d", sockfile.Fd()) syscall.Exec(os.Args[0], os.Args, append(os.Environ(), socketMasterFdEnvVar)) case syscall.SIGHUP: + oldGeneration := processGroup.generation process, err := processGroup.StartProcess() if err != nil { log.Printf("Could not start new process: %v\n", err) @@ -34,7 +33,7 @@ func handleSignals(processGroup *ProcessGroup, c <-chan os.Signal, startTime int } if processGroup.set.Len() > 1 { - processGroup.SignalAll(syscall.SIGTERM, process) + processGroup.SignalAll(syscall.SIGTERM, oldGeneration) } else { log.Println("Failed to kill old process, because there's no one left in the group") } @@ -46,22 +45,47 @@ func handleSignals(processGroup *ProcessGroup, c <-chan os.Signal, startTime int } } +// Go won't let us set LISTEN_PID between fork, +// and exec because letting "language users" do +// that is not necessarily safe, because of rts +// issues. Very understandable, but a little annoying. +// +// So, instead we call ourselves with a special +// argv[0] that drops us into this little helper. +// That way we don't have to squeeze it between +// those syscalls, at the cost of an extra exec. +// These aren't hot execs, so performance is not +// really affected. +func setLISTEN_PIDHelper() { + os.Setenv("LISTEN_PID", fmt.Sprint(os.Getpid())) + args := os.Args[1:] + syscall.Exec(args[0], args, os.Environ()) +} + +// See setLISTEN_PIDHelper +var LISTEN_PID_HELPER_ARGV0 = "set-LISTEN_PID-helper" + func main() { - var ( - addr string - command string - err error - startTime int - useSyslog bool - username string - ) - - flag.StringVar(&command, "command", "", "Program to start") - flag.StringVar(&addr, "listen", "tcp://:8080", "Port on which to bind") - flag.IntVar(&startTime, "start", 3000, "How long the new process takes to boot in millis") - flag.BoolVar(&useSyslog, "syslog", false, "Log to syslog") - flag.StringVar(&username, "user", "", "run the command as this user") - flag.Parse() + if os.Args[0] == LISTEN_PID_HELPER_ARGV0 { + setLISTEN_PIDHelper() + } + + inputs, err := ParseInputs(os.Args[1:]) + if err != nil { + log.Fatalf("Options not valid: %s\n", err) + } + + var config *Config + config, err = inputs.LoadConfig() + if err != nil { + log.Fatalf("Could not load config file: %s\n", err) + } + + useSyslog := inputs.useSyslog + command := config.Command + addr := inputs.addr + username := inputs.username + startTime := inputs.startTime if useSyslog { stream, err := syslog.New(syslog.LOG_INFO, PROGRAM_NAME) @@ -81,11 +105,6 @@ func main() { log.Fatalln("Command path is mandatory") } - commandPath, err := exec.LookPath(command) - if err != nil { - log.Fatalln("Could not find executable", err) - } - log.Println("Listening on", addr) sockfile, err := ListenFile(addr) if err != nil { @@ -101,7 +120,7 @@ func main() { } // Run the first process - processGroup := MakeProcessGroup(commandPath, sockfile, targetUser) + processGroup := MakeProcessGroup(*inputs, sockfile, targetUser) _, err = processGroup.StartProcess() if err != nil { log.Fatalln("Could not start process", err) diff --git a/testdata/command.yaml b/testdata/command.yaml new file mode 100644 index 0000000..bcabfe8 --- /dev/null +++ b/testdata/command.yaml @@ -0,0 +1 @@ +command: the-command