├── .gitignore ├── LICENSE ├── README.md ├── TODO.md ├── go.mod ├── go.sum ├── gopher ├── README.md ├── devil_gopher.png └── devil_gopher.svg ├── main.go ├── runner ├── commands.go ├── message.go ├── runner.go └── service.go └── sshcmd └── client.go /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # misc 4 | god 5 | .DS_Store 6 | .god.yml 7 | .env 8 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License 2 | 3 | Copyright 2020 Enrico Pilotto 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # God - Go-daemons 2 | 3 | God (go-daemons) is a tool to deploy and manage daemons in the Go ecosystem on GNU/Linux machines 4 | using [systemd](https://www.freedesktop.org/wiki/Software/systemd/). 5 | 6 | Devil Gopher 7 | 8 | God installs your go binary in the remote machine (server) using `go install`, 9 | create the systemd unit file (.service file) and allows you to 10 | start/stop/restart the process. 11 | 12 | ## Use case 13 | 14 | I often write small micro-services or web-services in Go, and I need to deploy 15 | the service on a server in an easy and fast way, without bothering! Usually I 16 | compile the executable for the server architecture, log in via SSH on the 17 | server, copy the binary, create the configuration file for the new systemd 18 | service, install it and start the process. A series of commands that are always 19 | the same but that I forget every time and have to look in my notes somewhere. 20 | And that's not all: when I make a change to my program, then I have to recompile 21 | it, log in via SSH on the server, copy the binary and restart the service. A 22 | real waste of time! 23 | 24 | So I decided to write God, a tool written in Go. With God I can quickly and 25 | easily deploy and manage one or more services written in Go comfortably from my 26 | laptop, without logging into the server via SSH using a simple 27 | [YAML](https://yaml.org/) configuration file. 28 | 29 | ## Install God 30 | 31 | ``` 32 | go install github.com/pioz/god@latest 33 | god -h 34 | ``` 35 | 36 | ## Usage 37 | 38 | 📒 **To read the list of all commands and options run `god -h`**. 39 | 40 | God uses a simple YAML file like this one to understand what to do: 41 | 42 | ```yaml 43 | my_service_name1: 44 | user: pioz 45 | host: 119.178.21.21 46 | go_install: github.com/pioz/go_hello_world_server@latest 47 | my_service_name2: 48 | user: pioz 49 | host: 119.178.21.22 50 | go_install: github.com/pioz/go_hello_world_server2@latest 51 | ``` 52 | 53 | You can install a new service on a remote server with the following command: 54 | 55 | ``` 56 | god -f conf/file.yml install my_service_name1 57 | ``` 58 | 59 | If you omit the `-f` option, God will try to find the conf file in `.god.yml` 60 | path. 61 | 62 | Now, what happens? 63 | 64 | God will try to connect via SSH to the server `119.178.21.21` with the user 65 | `pioz` on the default SSH port 22 using the private key stored locally in 66 | `~/.ssh/id_rsa`. Currently, only authentication via private key is supported and 67 | authentication via plain password is not planned. Then perform this sequence of 68 | commands: 69 | 70 | 1. Check if Go is installed on the remote host 71 | 2. Check if systemd is installed on the remote host 72 | 3. Check if the user `pioz` is in the [lingering list](https://www.freedesktop.org/software/systemd/man/loginctl.html) 73 | 4. Install the Go package `github.com/pioz/go_hello_world_server@latest` in `$GOBIN` (default `$GOBIN`) 74 | 5. Create the systemd unit service file in `~/.config/systemd/user/` 75 | 6. Reload systemd daemon with `systemctl --user daemon-reload` 76 | 7. Enable the new service with `systemctl --user enable my_service_name1` 77 | 78 | The systemd unit service file will be saved in 79 | `~/.config/systemd/user/my_service_name1.service` and its content is 80 | something like this: 81 | 82 | ``` 83 | [Unit] 84 | Description=my_service_name1 85 | 86 | [Service] 87 | Type=simple 88 | Restart=always 89 | WorkingDirectory=/home/pioz 90 | ExecStart=/home/pioz/go/bin/go_hello_world_server 91 | 92 | [Install] 93 | WantedBy=default.target 94 | ``` 95 | 96 | Now you can start the service with 97 | 98 | ``` 99 | god start my_service_name1 100 | ``` 101 | 102 | that will run in the remote host 103 | 104 | ``` 105 | systemctl --user start my_service_name1 106 | ``` 107 | 108 | If you want rollback and clean your server you can uninstall the service with 109 | `god uninstall my_service_name1`. 110 | 111 | ### Install from private repository 112 | 113 | If your Go service package is located in a private repository, God allows the 114 | remote server to access the repository prepending in the remote 115 | [`~/.netrc`](https://www.gnu.org/software/inetutils/manual/html_node/The-_002enetrc-file.html) 116 | file the login information that you can specify on the God YAML configuration 117 | file (read `god -h` for more details). An example here: 118 | 119 | ```yaml 120 | my_private_service: 121 | user: pioz 122 | host: 119.178.21.21 123 | go_install: github.com/pioz/go_hello_world_server_private@latest 124 | # Private repo access 125 | go_private: github.com/pioz/go_hello_world_server_private 126 | netrc_machine: github.com 127 | netrc_login: githublogin@gmail.com 128 | netrc_password: youRgithubAcce$$tok3n 129 | ``` 130 | 131 | ### Override YAML configuration options with env variables 132 | 133 | All configuration options that you can specify in the `.god.yml` file can be 134 | overridden with environment variables in the form 135 | `_`. For example, the option `netrc_password` can be 136 | overridden with the environment variable `MY_PRIVATE_SERVICE_NETRC_PASSWORD`. 137 | This is really useful if you want to avoid storing sensitive data in the 138 | `.god.yml` file. 139 | 140 | So you can install your private service with this command: 141 | 142 | ``` 143 | MY_PRIVATE_SERVICE_NETRC_PASSWORD=youRgithubAcce$$tok3n god install 144 | ``` 145 | 146 | Notice that God uses [this 147 | function](https://github.com/pioz/god/blob/c6d5e174596d584d348f4c74b69086c13533a01f/runner/runner.go#L282) 148 | to convert the service name in the environment variable. So all characters not 149 | in `[A-Za-z0-9_]` will be replaced by an underscore. 150 | 151 | ### Manage multiple services at the same time 152 | 153 | If you do not specify a service name, all services defined in the YAML file will 154 | be taken into consideration, for example if you run `god restart` it will 155 | restart all services defined in the YAML file in parallel. 🤩 156 | 157 | This is really useful if your infrastructure is made by many microservices. 158 | 159 | ### Copy files 160 | 161 | If you need to upload files to the remote working directory you can use the 162 | configuration options `copy_files` in the YAML conf file. For example you can 163 | upload a `.env` file needed by your service. 164 | 165 | ```yaml 166 | my_service_name: 167 | user: pioz 168 | host: 119.178.21.21 169 | go_install: github.com/pioz/go_hello_world_server@latest 170 | copy_files: 171 | - .env 172 | - WARNING.txt 173 | - /home/pioz/icons/ 174 | ``` 175 | 176 | ### Help 177 | 178 | ``` 179 | god -h 180 | Usage: god [OPTIONS...] {COMMAND} ... 181 | -c Creates the remote service working directory if not exists. With uninstall command, removes log files and the remote working directory if empty. 182 | -f string 183 | Configuration YAML file path. (default ".god.yml") 184 | -h Print this help. 185 | -q Disable printing. 186 | 187 | Commands: 188 | After each command you can specify one or more services. If you do not specify any, all services in the YAML 189 | configuration file will be selected. 190 | 191 | install SERVICE... Install one or more services on the remote host. 192 | uninstall SERVICE... Uninstall one or more services on the remote host. 193 | start SERVICE... Start one or more services. 194 | stop SERVICE... Stop one or more services. 195 | restart SERVICE... Restart one or more services. 196 | status SERVICE... Show runtime status of one or more services. 197 | show-service SERVICE... Print systemd unit service file of one or more services. 198 | 199 | Configuration YAML file options: 200 | user User to log in with on the remote machine. (default current user) 201 | host Hostname to log in for executing commands on the remote host. (required) 202 | port Port to connect to on the remote host. (default 22) 203 | private_key_path Local path of the private key used to authenticate on the remote host. (default 204 | '~/.ssh/id_rsa') 205 | go_exec_path Remote path of the Go binary executable. (default '$GOBIN/go') 206 | go_bin_directory The directory where 'go install' will install the service executable. (default 207 | '$GOBIN') 208 | go_install Go package to install on the remote host. Package path must refer to main packages and 209 | must have the version suffix, ex: @latest. (required) 210 | go_private Set GOPRIVATE environment variable to be used when run 'go install' to install from 211 | private sources. 212 | netrc_machine Add in remote .netrc file the machine name to be used to access private repository. 213 | netrc_login Add in remote .netrc file the login name to be used to access private repository. 214 | netrc_password Add in remote .netrc file the password or access token to be used to access private 215 | repository. 216 | systemd_path Remote path of systemd binary executable. (default 'systemd') 217 | systemd_services_directory Remote directory where to save user instance systemd unit service configuration file. 218 | (default '~/.config/systemd/user/') 219 | systemd_linger_directory Remote directory where to find the lingering user list. If lingering is enabled for a 220 | specific user, a user manager is spawned for the user at boot and kept around after 221 | logouts. (default '/var/lib/systemd/linger/') 222 | exec_start Command with its arguments that are executed when this service is started. 223 | working_directory Sets the remote working directory for executed processes. (default: '~/') 224 | environment Sets environment variables for executed process. Takes a space-separated list of variable 225 | assignments. 226 | log_path Sets the remote file path where executed processes will redirect its standard output and 227 | standard error. 228 | run_after_service Ensures that the service is started after the listed unit finished starting up. 229 | start_limit_burst Configure service start rate limiting. Services which are started more than burst times 230 | within an interval time interval are not permitted to start any more. Use 231 | 'start_limit_interval_sec' to configure the checking interval. 232 | start_limit_interval_sec Configure the checking interval used by 'start_limit_burst'. 233 | restart_sec Configures the time to sleep before restarting a service. Takes a unit-less value in 234 | seconds. 235 | copy_files [Array] Copy files to the remote working directory. 236 | ignore If a command is called without any service name, all services in the YAML configuration 237 | file will be selected, except those with ignore set to true. (default false) 238 | 239 | All previous configuration options can be overridden with environment variables in the form 240 | _. For example, the option netrc_password can be overridden with the environment variable 241 | MY_SERVICE_NAME_NETRC_PASSWORD. 242 | ``` 243 | 244 | ## Contributing 245 | 246 | Bug reports and pull requests are welcome on GitHub at https://github.com/pioz/god/issues. 247 | 248 | ## License 249 | 250 | The package is available as open source under the terms of the [MIT License](http://opensource.org/licenses/MIT). 251 | -------------------------------------------------------------------------------- /TODO.md: -------------------------------------------------------------------------------- 1 | # God TODO list 2 | 3 | * Convert `sshcmd/Client` in a interface, and implement it with a real ssh client and a mock client for test. 4 | * Add test! 🤩 5 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/pioz/god 2 | 3 | go 1.18 4 | 5 | require ( 6 | github.com/charmbracelet/lipgloss v0.5.0 7 | github.com/pkg/errors v0.9.1 8 | github.com/pkg/sftp v1.13.4 9 | golang.org/x/crypto v0.0.0-20220511200225-c6db032c6c88 10 | golang.org/x/exp v0.0.0-20220518171630-0b5c67f07fdf 11 | gopkg.in/yaml.v3 v3.0.0-20220512140231-539c8e751b99 12 | ) 13 | 14 | require ( 15 | github.com/kr/fs v0.1.0 // indirect 16 | github.com/lucasb-eyer/go-colorful v1.2.0 // indirect 17 | github.com/mattn/go-isatty v0.0.14 // indirect 18 | github.com/mattn/go-runewidth v0.0.13 // indirect 19 | github.com/muesli/reflow v0.2.1-0.20210115123740-9e1d0d53df68 // indirect 20 | github.com/muesli/termenv v0.11.1-0.20220204035834-5ac8409525e0 // indirect 21 | github.com/rivo/uniseg v0.2.0 // indirect 22 | golang.org/x/sys v0.0.0-20211019181941-9d821ace8654 // indirect 23 | ) 24 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/charmbracelet/lipgloss v0.5.0 h1:lulQHuVeodSgDez+3rGiuxlPVXSnhth442DATR2/8t8= 2 | github.com/charmbracelet/lipgloss v0.5.0/go.mod h1:EZLha/HbzEt7cYqdFPovlqy5FZPj0xFhg5SaqxScmgs= 3 | github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= 4 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 5 | github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8= 6 | github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= 7 | github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= 8 | github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= 9 | github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y= 10 | github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= 11 | github.com/mattn/go-runewidth v0.0.10/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= 12 | github.com/mattn/go-runewidth v0.0.13 h1:lTGmDsbAYt5DmK6OnoV7EuIF1wEIFAcxld6ypU4OSgU= 13 | github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 14 | github.com/muesli/reflow v0.2.1-0.20210115123740-9e1d0d53df68 h1:y1p/ycavWjGT9FnmSjdbWUlLGvcxrY0Rw3ATltrxOhk= 15 | github.com/muesli/reflow v0.2.1-0.20210115123740-9e1d0d53df68/go.mod h1:Xk+z4oIWdQqJzsxyjgl3P22oYZnHdZ8FFTHAQQt5BMQ= 16 | github.com/muesli/termenv v0.11.1-0.20220204035834-5ac8409525e0 h1:STjmj0uFfRryL9fzRA/OupNppeAID6QJYPMavTL7jtY= 17 | github.com/muesli/termenv v0.11.1-0.20220204035834-5ac8409525e0/go.mod h1:Bd5NYQ7pd+SrtBSrSNoBBmXlcY8+Xj4BMJgh8qcZrvs= 18 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 19 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 20 | github.com/pkg/sftp v1.13.4 h1:Lb0RYJCmgUcBgZosfoi9Y9sbl6+LJgOIgk/2Y4YjMFg= 21 | github.com/pkg/sftp v1.13.4/go.mod h1:LzqnAvaD5TWeNBsZpfKxSYn1MbjWwOsCIAFFJbpIsK8= 22 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 23 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 24 | github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 25 | github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= 26 | github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 27 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 28 | github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= 29 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 30 | golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= 31 | golang.org/x/crypto v0.0.0-20220511200225-c6db032c6c88 h1:Tgea0cVUD0ivh5ADBX4WwuI12DUd2to3nCYe2eayMIw= 32 | golang.org/x/crypto v0.0.0-20220511200225-c6db032c6c88/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= 33 | golang.org/x/exp v0.0.0-20220518171630-0b5c67f07fdf h1:oXVg4h2qJDd9htKxb5SCpFBHLipW6hXmL3qpUixS2jw= 34 | golang.org/x/exp v0.0.0-20220518171630-0b5c67f07fdf/go.mod h1:yh0Ynu2b5ZUe3MQfp2nM0ecK7wsgouWTDN0FNeJuIys= 35 | golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 36 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 37 | golang.org/x/sys v0.0.0-20210423185535-09eb48e85fd7/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 38 | golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 39 | golang.org/x/sys v0.0.0-20211019181941-9d821ace8654 h1:id054HUawV2/6IGm2IV8KZQjqtwAOo2CYlOToYqa0d0= 40 | golang.org/x/sys v0.0.0-20211019181941-9d821ace8654/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 41 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1 h1:v+OssWQX+hTHEmOBgwxdZxK4zHq3yOs8F9J7mk0PY8E= 42 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 43 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 44 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 45 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 46 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 47 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 48 | gopkg.in/yaml.v3 v3.0.0-20220512140231-539c8e751b99 h1:dbuHpmKjkDzSOMKAWl10QNlgaZUd3V1q99xc81tt2Kc= 49 | gopkg.in/yaml.v3 v3.0.0-20220512140231-539c8e751b99/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 50 | -------------------------------------------------------------------------------- /gopher/README.md: -------------------------------------------------------------------------------- 1 | ## Credits 2 | 3 | Devil Gopher was made with love by [Megan Ali](https://www.facebook.com/mgn.ali)! 4 | -------------------------------------------------------------------------------- /gopher/devil_gopher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pioz/god/d2d90cf1fe613b66b7a63faa28279f9b8deff5ed/gopher/devil_gopher.png -------------------------------------------------------------------------------- /gopher/devil_gopher.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 47 | 48 | 49 | 50 | 65 | 70 | 75 | 79 | 82 | 85 | 87 | 89 | 91 | 93 | 98 | 104 | 106 | 108 | 110 | 113 | 117 | 119 | 121 | 124 | 127 | 129 | 131 | 133 | 134 | 136 | 137 | 138 | 139 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 150 | 151 | 152 | 153 | 154 | 155 | 156 | 157 | 162 | 163 | 169 | 175 | 176 | 181 | 186 | 192 | 199 | 202 | 205 | 206 | 208 | 209 | 211 | 212 | 213 | 225 | 226 | 227 | 228 | 229 | 230 | 231 | 237 | 246 | 249 | 251 | 253 | 255 | 256 | 257 | 258 | 259 | 260 | 261 | 262 | 263 | 264 | 265 | 266 | 267 | 268 | 269 | 270 | 335 | 336 | 337 | 338 | 339 | 340 | 372 | 373 | 375 | 377 | 379 | 381 | 383 | 384 | 385 | 386 | 387 | 389 | 391 | 393 | 395 | 397 | 398 | 399 | 400 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | "os" 7 | "sync" 8 | 9 | "github.com/charmbracelet/lipgloss" 10 | "github.com/pioz/god/runner" 11 | "golang.org/x/exp/slices" 12 | ) 13 | 14 | var availableCommands = []string{"install", "uninstall", "start", "stop", "restart", "status", "show-service"} 15 | 16 | func init() { 17 | flag.Usage = func() { 18 | fmt.Fprintf(flag.CommandLine.Output(), "Usage: %s [OPTIONS...] {COMMAND} ...\n", os.Args[0]) 19 | flag.PrintDefaults() 20 | fmt.Fprintln(flag.CommandLine.Output()) 21 | fmt.Fprintln(flag.CommandLine.Output(), lipgloss.NewStyle().Bold(true).Render("Commands:")) 22 | fmt.Fprintln(flag.CommandLine.Output(), lipgloss.NewStyle().Width(120).Render("After each command you can specify one or more services. If you do not specify any, all services in the YAML configuration file will be selected.")) 23 | fmt.Fprintln(flag.CommandLine.Output()) 24 | commands := [][]string{ 25 | {"install SERVICE...", "Install one or more services on the remote host."}, 26 | {"uninstall SERVICE...", "Uninstall one or more services on the remote host."}, 27 | {"start SERVICE...", "Start one or more services."}, 28 | {"stop SERVICE...", "Stop one or more services."}, 29 | {"restart SERVICE...", "Restart one or more services."}, 30 | {"status SERVICE...", "Show runtime status of one or more services."}, 31 | {"show-service SERVICE...", "Print systemd unit service file of one or more services."}, 32 | } 33 | for _, command := range commands { 34 | fmt.Fprintln( 35 | flag.CommandLine.Output(), 36 | lipgloss.JoinHorizontal( 37 | lipgloss.Top, 38 | lipgloss.NewStyle().Width(30).Render(command[0]), 39 | lipgloss.NewStyle().Width(90).Render(command[1]), 40 | ), 41 | ) 42 | } 43 | fmt.Fprintln(flag.CommandLine.Output()) 44 | fmt.Fprintln(flag.CommandLine.Output(), lipgloss.NewStyle().Bold(true).Render("Configuration YAML file options:")) 45 | confOptions := [][]string{ 46 | {"user", "User to log in with on the remote machine. (default current user)"}, 47 | {"host", "Hostname to log in for executing commands on the remote host. (required)"}, 48 | {"port", "Port to connect to on the remote host. (default 22)"}, 49 | {"private_key_path", "Local path of the private key used to authenticate on the remote host. (default '~/.ssh/id_rsa')"}, 50 | {"go_exec_path", "Remote path of the Go binary executable. (default '$GOBIN/go')"}, 51 | {"go_bin_directory", "The directory where 'go install' will install the service executable. (default '$GOBIN')"}, 52 | {"go_install", "Go package to install on the remote host. Package path must refer to main packages and must have the version suffix, ex: @latest. (required)"}, 53 | {"go_private", "Set GOPRIVATE environment variable to be used when run 'go install' to install from private sources."}, 54 | {"netrc_machine", "Add in remote .netrc file the machine name to be used to access private repository."}, 55 | {"netrc_login", "Add in remote .netrc file the login name to be used to access private repository."}, 56 | {"netrc_password", "Add in remote .netrc file the password or access token to be used to access private repository."}, 57 | {"systemd_path", "Remote path of systemd binary executable. (default 'systemd')"}, 58 | {"systemd_services_directory", "Remote directory where to save user instance systemd unit service configuration file. (default '~/.config/systemd/user/')"}, 59 | {"systemd_linger_directory", "Remote directory where to find the lingering user list. If lingering is enabled for a specific user, a user manager is spawned for the user at boot and kept around after logouts. (default '/var/lib/systemd/linger/')"}, 60 | {"exec_start", "Command with its arguments that are executed when this service is started."}, 61 | {"working_directory", "Sets the remote working directory for executed processes. (default: '~/')"}, 62 | {"environment", "Sets environment variables for executed process. Takes a space-separated list of variable assignments."}, 63 | {"log_path", "Sets the remote file path where executed processes will redirect its standard output and standard error."}, 64 | {"run_after_service", "Ensures that the service is started after the listed unit finished starting up."}, 65 | {"start_limit_burst", "Configure service start rate limiting. Services which are started more than burst times within an interval time interval are not permitted to start any more. Use 'start_limit_interval_sec' to configure the checking interval."}, 66 | {"start_limit_interval_sec", "Configure the checking interval used by 'start_limit_burst'."}, 67 | {"restart_sec", "Configures the time to sleep before restarting a service. Takes a unit-less value in seconds."}, 68 | {"copy_files", "[Array] Copy files to the remote working directory."}, 69 | {"ignore", "If a command is called without any service name, all services in the YAML configuration file will be selected, except those with ignore set to true. (default false)"}, 70 | } 71 | for _, option := range confOptions { 72 | fmt.Fprintln( 73 | flag.CommandLine.Output(), 74 | lipgloss.JoinHorizontal( 75 | lipgloss.Top, 76 | lipgloss.NewStyle().Width(30).Render(option[0]), 77 | lipgloss.NewStyle().Width(90).Render(option[1]), 78 | ), 79 | ) 80 | } 81 | fmt.Fprintln(flag.CommandLine.Output(), lipgloss.NewStyle().Width(120).Render("\nAll previous configuration options can be overridden with environment variables in the form _. For example, the option netrc_password can be overridden with the environment variable MY_SERVICE_NAME_NETRC_PASSWORD.")) 82 | } 83 | } 84 | 85 | func main() { 86 | var createWorkingDirectory, help, quiet bool 87 | var confFilePath string 88 | flag.StringVar(&confFilePath, "f", ".god.yml", "Configuration YAML file path.") 89 | flag.BoolVar(&createWorkingDirectory, "c", false, "Creates the remote service working directory if not exists. With uninstall command, removes log files and the remote working directory if empty.") 90 | flag.BoolVar(&quiet, "q", false, "Disable printing.") 91 | flag.BoolVar(&help, "h", false, "Print this help.") 92 | flag.Parse() 93 | if help { 94 | flag.Usage() 95 | os.Exit(0) 96 | } 97 | 98 | args := flag.Args() 99 | if len(args) == 0 { 100 | flag.Usage() 101 | os.Exit(1) 102 | } 103 | 104 | command, services := args[0], args[1:] 105 | if !slices.Contains(availableCommands, command) { 106 | flag.Usage() 107 | os.Exit(1) 108 | } 109 | 110 | r, err := runner.MakeRunner(confFilePath) 111 | if err != nil { 112 | fmt.Println(err) 113 | os.Exit(1) 114 | } 115 | r.QuietMode = quiet 116 | if len(services) == 0 { 117 | services = r.GetServiceNames() 118 | } 119 | go r.StartPrintOutput(services) 120 | defer r.StopPrintOutput() 121 | 122 | var wg sync.WaitGroup 123 | wg.Add(len(services)) 124 | 125 | switch command { 126 | case "install": 127 | { 128 | for _, serviceName := range services { 129 | go func(serviceName string) { 130 | defer wg.Done() 131 | s, err := r.MakeService(serviceName) 132 | if err != nil { 133 | r.SendMessage(serviceName, err.Error(), runner.MessageError) 134 | } else { 135 | s.Install(createWorkingDirectory) 136 | } 137 | }(serviceName) 138 | } 139 | } 140 | case "uninstall": 141 | { 142 | for _, serviceName := range services { 143 | go func(serviceName string) { 144 | defer wg.Done() 145 | s, err := r.MakeService(serviceName) 146 | if err != nil { 147 | r.SendMessage(serviceName, err.Error(), runner.MessageError) 148 | } else { 149 | s.Uninstall(createWorkingDirectory) 150 | } 151 | }(serviceName) 152 | } 153 | } 154 | case "start": 155 | { 156 | for _, serviceName := range services { 157 | go func(serviceName string) { 158 | defer wg.Done() 159 | s, err := r.MakeService(serviceName) 160 | if err != nil { 161 | r.SendMessage(serviceName, err.Error(), runner.MessageError) 162 | } else { 163 | s.StartService() 164 | } 165 | }(serviceName) 166 | } 167 | } 168 | case "stop": 169 | { 170 | for _, serviceName := range services { 171 | go func(serviceName string) { 172 | defer wg.Done() 173 | s, err := r.MakeService(serviceName) 174 | if err != nil { 175 | r.SendMessage(serviceName, err.Error(), runner.MessageError) 176 | } else { 177 | s.StopService() 178 | } 179 | }(serviceName) 180 | } 181 | } 182 | case "restart": 183 | { 184 | for _, serviceName := range services { 185 | go func(serviceName string) { 186 | defer wg.Done() 187 | s, err := r.MakeService(serviceName) 188 | if err != nil { 189 | r.SendMessage(serviceName, err.Error(), runner.MessageError) 190 | } else { 191 | s.RestartService() 192 | } 193 | }(serviceName) 194 | } 195 | } 196 | case "status": 197 | { 198 | for _, serviceName := range services { 199 | go func(serviceName string) { 200 | defer wg.Done() 201 | s, err := r.MakeService(serviceName) 202 | if err != nil { 203 | r.SendMessage(serviceName, err.Error(), runner.MessageError) 204 | } else { 205 | s.StatusService() 206 | } 207 | }(serviceName) 208 | } 209 | } 210 | case "show-service": 211 | { 212 | for _, serviceName := range services { 213 | go func(serviceName string) { 214 | defer wg.Done() 215 | s, err := r.MakeService(serviceName) 216 | if err != nil { 217 | r.SendMessage(serviceName, err.Error(), runner.MessageError) 218 | } else { 219 | s.ShowServiceFile() 220 | } 221 | }(serviceName) 222 | } 223 | } 224 | } 225 | 226 | wg.Wait() 227 | } 228 | -------------------------------------------------------------------------------- /runner/commands.go: -------------------------------------------------------------------------------- 1 | package runner 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "path/filepath" 7 | "strings" 8 | 9 | "github.com/pkg/sftp" 10 | "golang.org/x/exp/slices" 11 | ) 12 | 13 | func (s *Service) CheckGo() error { 14 | errorMessage := fmt.Sprintf("couldn't find the `go` executable. Please install `go` or set the executable path in `%s` file using the `go_exec_path` variable", s.runner.confFilePath) 15 | cmd := s.ParseCommand("{{.GoExecPath}} version") 16 | return s.PrintExec(cmd, errorMessage) 17 | } 18 | 19 | func (s *Service) CheckSystemd() error { 20 | errorMessage := fmt.Sprintf("couldn't find the `systemd` executable. Please install `systemd` or set the executable path in `%s` file using the `systemd_path` variable", s.runner.confFilePath) 21 | cmd := s.ParseCommand("{{.SystemdPath}} --version") 22 | return s.PrintExec(cmd, errorMessage) 23 | } 24 | 25 | func (s *Service) CheckLingering() error { 26 | cmd := s.ParseCommand("ls {{.SystemdLingerDirectory}}") 27 | s.runner.SendMessage(s.Name, cmd, MessageNormal) 28 | output, err := s.Exec(cmd) 29 | if err != nil { 30 | s.runner.SendMessage(s.Name, err.Error(), MessageError) 31 | return err 32 | } 33 | if !slices.Contains(strings.Split(output, "\n"), s.Conf.User) { 34 | err = fmt.Errorf("user `%s` is not in the linger list. You can add it with the command `sudo loginctl enable-linger %s`", s.Conf.User, s.Conf.User) 35 | s.runner.SendMessage(s.Name, err.Error(), MessageError) 36 | return err 37 | } 38 | s.runner.SendMessage(s.Name, output, MessageSuccess) 39 | return nil 40 | } 41 | 42 | func (s *Service) CheckWorkingDir(createWorkingDirectory bool) error { 43 | cmd := s.ParseCommand("test -e {{.WorkingDirectory}}") 44 | s.runner.SendMessage(s.Name, cmd, MessageNormal) 45 | _, err := s.Exec(cmd) 46 | if err == nil { 47 | s.runner.SendMessage(s.Name, "", MessageSuccess) 48 | return nil 49 | } 50 | if !createWorkingDirectory { 51 | s.runner.SendMessage(s.Name, fmt.Sprintf("Service working directory '%s' does not exist on the remote host", s.Conf.WorkingDirectory), MessageError) 52 | return err 53 | } 54 | cmd = s.ParseCommand("mkdir -p {{.WorkingDirectory}}") 55 | s.runner.SendMessage(s.Name, cmd, MessageNormal) 56 | output, err := s.Exec(cmd) 57 | if err != nil { 58 | s.runner.SendMessage(s.Name, err.Error(), MessageError) 59 | return err 60 | } 61 | s.runner.SendMessage(s.Name, output, MessageSuccess) 62 | return nil 63 | } 64 | 65 | func (s *Service) AuthPrivateRepo() error { 66 | if s.Conf.GoPrivate != "" { 67 | s.runner.SendMessage(s.Name, "GO_PRIVATE found: edit .netrc file", MessageNormal) 68 | auth := fmt.Sprintf("machine %s login %s password %s", s.Conf.NetrcMachine, s.Conf.NetrcLogin, s.Conf.NetrcPassword) 69 | output, err := s.Exec("cat ~/.netrc") 70 | if !strings.Contains(output, auth) { 71 | var cmd string 72 | if err != nil { 73 | cmd = fmt.Sprintf("echo '%s' > ~/.netrc", auth) 74 | } else { 75 | cmd = fmt.Sprintf("echo '%s\n%s' > ~/.netrc", auth, output) 76 | } 77 | _, err := s.Exec(cmd) 78 | if err != nil { 79 | s.runner.SendMessage(s.Name, err.Error(), MessageError) 80 | return err 81 | } 82 | } 83 | s.runner.SendMessage(s.Name, "", MessageSuccess) 84 | } 85 | return nil 86 | } 87 | 88 | func (s *Service) InstallExecutable() error { 89 | var cmd string 90 | if s.Conf.GoPrivate != "" { 91 | cmd = s.ParseCommand("GOPRIVATE={{.GoPrivate}} {{.GoExecPath}} install {{.GoInstall}}") 92 | } else { 93 | cmd = s.ParseCommand("{{.GoExecPath}} install {{.GoInstall}}") 94 | } 95 | errorMessage := fmt.Sprintf("cannot install the package `%s`", s.Conf.GoInstall) 96 | s.runner.SendMessage(s.Name, cmd, MessageNormal) 97 | output, err := s.Exec(cmd) 98 | if err != nil { 99 | errorMessage = fmt.Sprintf("%s: %s", errorMessage, output) 100 | s.runner.SendMessage(s.Name, fmt.Sprintf("%s: %s", errorMessage, output), MessageError) 101 | return err 102 | } 103 | cmd = s.ParseCommand("file {{.ExecStart}}") 104 | errorMessage = fmt.Sprintf("couldn't find the `%s` executable", s.Conf.ExecStart) 105 | output, err = s.Exec(cmd) 106 | if err != nil { 107 | s.runner.SendMessage(s.Name, fmt.Sprintf("%s: %s", errorMessage, output), MessageError) 108 | return err 109 | } 110 | s.runner.SendMessage(s.Name, "Installed", MessageSuccess) 111 | return nil 112 | } 113 | 114 | func (s *Service) DeleteExecutable() error { 115 | errorMessage := fmt.Sprintf("cannot delete service binary file `%s`", s.Conf.ExecStart) 116 | cmd := s.ParseCommand("rm {{.ExecStart}}") 117 | return s.PrintExec(cmd, errorMessage) 118 | } 119 | 120 | func (s *Service) CreateServiceFile() error { 121 | message := fmt.Sprintf("Copy service file in `%s`", s.Conf.SystemdServicesDirectory) 122 | s.runner.SendMessage(s.Name, message, MessageNormal) 123 | err := s.CopyUnitServiceFile() 124 | if err != nil { 125 | s.runner.SendMessage(s.Name, err.Error(), MessageError) 126 | return err 127 | } 128 | s.runner.SendMessage(s.Name, "Copied", MessageSuccess) 129 | return nil 130 | } 131 | 132 | func (s *Service) CopyFiles() error { 133 | if len(s.Conf.CopyFiles) > 0 { 134 | s.runner.SendMessage(s.Name, "Copying files", MessageNormal) 135 | for _, path := range s.Conf.CopyFiles { 136 | err := s.CopyFile(path, s.Conf.WorkingDirectory) 137 | if err != nil { 138 | errorMessage := fmt.Sprintf("cannot copy file '%s': %s", path, err) 139 | s.runner.SendMessage(s.Name, errorMessage, MessageError) 140 | return err 141 | } 142 | } 143 | s.runner.SendMessage(s.Name, "All files copied", MessageSuccess) 144 | } 145 | return nil 146 | } 147 | 148 | func (s *Service) DeleteFiles(removeWorkingDirectory bool) error { 149 | if len(s.Conf.CopyFiles) > 0 { 150 | s.runner.SendMessage(s.Name, "Deleting files", MessageNormal) 151 | for _, path := range s.Conf.CopyFiles { 152 | err := s.DeleteFile(path, s.Conf.WorkingDirectory) 153 | if err != nil { 154 | errorMessage := fmt.Sprintf("cannot delete file '%s': %s", path, err) 155 | s.runner.SendMessage(s.Name, errorMessage, MessageWarning) 156 | } 157 | } 158 | s.runner.SendMessage(s.Name, "All files deleted", MessageSuccess) 159 | } 160 | if removeWorkingDirectory { 161 | if s.Conf.LogPath != "" { 162 | s.runner.SendMessage(s.Name, fmt.Sprintf("Deleting log file '%s'", s.Conf.LogPath), MessageNormal) 163 | err := s.client.ConnectSftpClient() 164 | if err != nil { 165 | s.runner.SendMessage(s.Name, fmt.Sprintf("Cannot delete log file '%s': %s", s.Conf.LogPath, err.Error()), MessageError) 166 | } 167 | err = s.client.SftClient.Remove(s.Conf.LogPath) 168 | if err != nil { 169 | s.runner.SendMessage(s.Name, fmt.Sprintf("Cannot delete log file '%s': %s", s.Conf.LogPath, err.Error()), MessageError) 170 | } else { 171 | s.runner.SendMessage(s.Name, "Deleted", MessageSuccess) 172 | } 173 | } 174 | if s.Conf.WorkingDirectory != s.remoteHomeDir { 175 | s.runner.SendMessage(s.Name, fmt.Sprintf("Deleting service working directory '%s'", s.Conf.WorkingDirectory), MessageNormal) 176 | err := s.DeleteDirIfEmpty(s.Conf.WorkingDirectory) 177 | if err, ok := err.(*sftp.StatusError); ok { 178 | switch err.Code { 179 | case 4: // sshFxFailure 180 | s.runner.SendMessage(s.Name, fmt.Sprintf("Cannot delete service working directory '%s': directory is not empty", s.Conf.WorkingDirectory), MessageError) 181 | default: 182 | s.runner.SendMessage(s.Name, fmt.Sprintf("Cannot delete service working directory '%s': %s", s.Conf.WorkingDirectory, err.Error()), MessageError) 183 | } 184 | } 185 | s.runner.SendMessage(s.Name, "Deleted", MessageSuccess) 186 | } 187 | } 188 | return nil 189 | } 190 | 191 | func (s *Service) ShowServiceFile() { 192 | var buf bytes.Buffer 193 | s.GenerateServiceFile(&buf) 194 | s.runner.SendMessage(s.Name, buf.String(), MessageNormal) 195 | } 196 | 197 | func (s *Service) DeleteServiceFile() error { 198 | filename := filepath.Join(s.Conf.SystemdServicesDirectory, fmt.Sprintf("%s.service", s.Name)) 199 | errorMessage := fmt.Sprintf("cannot delete service file `%s`", filename) 200 | return s.PrintExec(fmt.Sprintf("rm %s", filename), errorMessage) 201 | } 202 | 203 | func (s *Service) ReloadDaemon() error { 204 | return s.PrintExec("systemctl --user daemon-reload", "couldn't reload systemd daemon") 205 | } 206 | 207 | func (s *Service) ResetFailedServices() error { 208 | return s.PrintExec("systemctl --user reset-failed", "couldn't reset failed systemd services") 209 | } 210 | 211 | func (s *Service) EnableService() error { 212 | return s.PrintExec(fmt.Sprintf("systemctl --user enable %s", s.Name), "couldn't enable systemd service") 213 | } 214 | 215 | func (s *Service) DisableService() error { 216 | return s.PrintExec(fmt.Sprintf("systemctl --user disable %s", s.Name), "couldn't disable systemd service") 217 | } 218 | 219 | func (s *Service) StartService() error { 220 | return s.PrintExec(fmt.Sprintf("systemctl --user start %s", s.Name), "couldn't start systemd service") 221 | } 222 | 223 | func (s *Service) StopService() error { 224 | return s.PrintExec(fmt.Sprintf("systemctl --user stop %s", s.Name), "couldn't stop systemd service") 225 | } 226 | 227 | func (s *Service) RestartService() error { 228 | return s.PrintExec(fmt.Sprintf("systemctl --user restart %s", s.Name), "couldn't restart systemd service") 229 | } 230 | 231 | func (s *Service) StatusService() error { 232 | return s.PrintExec(fmt.Sprintf("systemctl --user status %s", s.Name), "") 233 | } 234 | 235 | func (s *Service) Install(createWorkingDirectory bool) error { 236 | if err := s.CheckGo(); err != nil { 237 | return err 238 | } 239 | if err := s.CheckSystemd(); err != nil { 240 | return err 241 | } 242 | if err := s.CheckLingering(); err != nil { 243 | return err 244 | } 245 | if err := s.CheckWorkingDir(createWorkingDirectory); err != nil { 246 | return err 247 | } 248 | if err := s.AuthPrivateRepo(); err != nil { 249 | return err 250 | } 251 | if err := s.InstallExecutable(); err != nil { 252 | return err 253 | } 254 | if err := s.CopyFiles(); err != nil { 255 | return err 256 | } 257 | if err := s.CreateServiceFile(); err != nil { 258 | return err 259 | } 260 | if err := s.ReloadDaemon(); err != nil { 261 | return err 262 | } 263 | if err := s.EnableService(); err != nil { 264 | return err 265 | } 266 | return nil 267 | } 268 | 269 | func (s *Service) Uninstall(removeWorkingDirectory bool) { 270 | s.StopService() 271 | s.DisableService() 272 | s.DeleteServiceFile() 273 | s.ReloadDaemon() 274 | s.ResetFailedServices() 275 | s.DeleteExecutable() 276 | s.DeleteFiles(removeWorkingDirectory) 277 | } 278 | -------------------------------------------------------------------------------- /runner/message.go: -------------------------------------------------------------------------------- 1 | package runner 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/charmbracelet/lipgloss" 7 | ) 8 | 9 | // Types of messages. Types are normal, success and error. 10 | type MessageStatus uint8 11 | 12 | const ( 13 | // Normal message (uncolored) 14 | MessageNormal MessageStatus = 1 << iota // 1 << 0 which is 00000001 15 | // Success message (green) 16 | MessageSuccess // 1 << 1 which is 00000010 17 | // Error message (red) 18 | MessageError // 1 << 2 which is 00000100 19 | // Warning message (yellow) 20 | MessageWarning // 1 << 3 which is 00001000 21 | ) 22 | 23 | type message struct { 24 | serviceName string 25 | text string 26 | status MessageStatus 27 | } 28 | 29 | const ( 30 | green1 = "#22c55e" 31 | green2 = "#059669" 32 | red1 = "#ef4444" 33 | red2 = "#dc2626" 34 | yellow1 = "#f9c10b" 35 | yellow2 = "#fcc203" 36 | ) 37 | 38 | var styles = map[MessageStatus]map[string]lipgloss.Style{ 39 | MessageNormal: { 40 | "normal": lipgloss.NewStyle(), 41 | "bold": lipgloss.NewStyle().Bold(true), 42 | "symbol": lipgloss.NewStyle().SetString("→"), 43 | }, 44 | MessageSuccess: { 45 | "normal": lipgloss.NewStyle().Foreground(lipgloss.Color(green2)), 46 | "bold": lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color(green1)), 47 | "symbol": lipgloss.NewStyle().SetString("✓").Foreground(lipgloss.Color(green1)), 48 | }, 49 | MessageError: { 50 | "normal": lipgloss.NewStyle().Foreground(lipgloss.Color(red2)), 51 | "bold": lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color(red1)), 52 | "symbol": lipgloss.NewStyle().SetString("×").Foreground(lipgloss.Color(red1)), 53 | }, 54 | MessageWarning: { 55 | "normal": lipgloss.NewStyle().Foreground(lipgloss.Color(yellow2)), 56 | "bold": lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color(yellow1)), 57 | "symbol": lipgloss.NewStyle().SetString("⚠").Foreground(lipgloss.Color(yellow1)), 58 | }, 59 | } 60 | 61 | func (m *message) print(width int) { 62 | 63 | if styles[m.status] == nil { 64 | return 65 | } 66 | if len(m.serviceName) > width { 67 | width = len(m.serviceName) 68 | } 69 | if width != 0 { 70 | width += 3 // add padding 71 | } 72 | if m.text == "" && m.status == MessageSuccess { 73 | m.text = "ok" 74 | } 75 | fmt.Println(lipgloss.JoinHorizontal( 76 | lipgloss.Top, 77 | styles[m.status]["symbol"].String(), 78 | styles[m.status]["bold"].PaddingLeft(1).Width(width).Render("["+m.serviceName+"]"), 79 | styles[m.status]["normal"].PaddingLeft(1).Width(120).Render(m.text), 80 | )) 81 | } 82 | -------------------------------------------------------------------------------- /runner/runner.go: -------------------------------------------------------------------------------- 1 | // Package runner exposes the APIs used by God for deploy and manage services in 2 | // the Go ecosystem. 3 | package runner 4 | 5 | import ( 6 | "fmt" 7 | "io/ioutil" 8 | "os" 9 | "os/user" 10 | "path/filepath" 11 | "reflect" 12 | "regexp" 13 | "strconv" 14 | "strings" 15 | "sync" 16 | 17 | "github.com/pioz/god/sshcmd" 18 | "gopkg.in/yaml.v3" 19 | ) 20 | 21 | // Conf holds a service configuration. 22 | type Conf struct { 23 | User string `yaml:"user"` 24 | Host string `yaml:"host"` 25 | Port string `yaml:"port"` 26 | PrivateKeyPath string `yaml:"private_key_path"` 27 | 28 | GoExecPath string `yaml:"go_exec_path"` 29 | GoBinDirectory string `yaml:"go_bin_directory"` 30 | GoInstall string `yaml:"go_install"` 31 | 32 | GoPrivate string `yaml:"go_private"` 33 | NetrcMachine string `yaml:"netrc_machine"` 34 | NetrcLogin string `yaml:"netrc_login"` 35 | NetrcPassword string `yaml:"netrc_password"` 36 | 37 | SystemdPath string `yaml:"systemd_path"` 38 | SystemdServicesDirectory string `yaml:"systemd_services_directory"` 39 | SystemdLingerDirectory string `yaml:"systemd_linger_directory"` 40 | 41 | ExecStart string `yaml:"exec_start"` 42 | WorkingDirectory string `yaml:"working_directory"` 43 | Environment string `yaml:"environment"` 44 | LogPath string `yaml:"log_path"` 45 | RunAfterService string `yaml:"run_after_service"` 46 | StartLimitBurst int `yaml:"start_limit_burst"` 47 | StartLimitIntervalSec int `yaml:"start_limit_interval_sec"` 48 | RestartSec int `yaml:"restart_sec"` 49 | 50 | CopyFiles []string `yaml:"copy_files"` 51 | 52 | Ignore bool `yaml:"ignore"` 53 | } 54 | 55 | type Runner struct { 56 | QuietMode bool 57 | confFilePath string 58 | conf map[string]*Conf 59 | services map[string]Service 60 | mu sync.Mutex 61 | output chan message 62 | quit chan struct{} 63 | } 64 | 65 | // MakeRunner loads the configuration from confFilePath and returns an 66 | // initialized Runner. 67 | func MakeRunner(confFilePath string) (*Runner, error) { 68 | runner := &Runner{ 69 | confFilePath: confFilePath, 70 | services: make(map[string]Service), 71 | output: make(chan message), 72 | quit: make(chan struct{}), 73 | } 74 | conf, err := readConf(confFilePath) 75 | if err != nil { 76 | return nil, err 77 | } 78 | runner.conf = conf 79 | return runner, nil 80 | } 81 | 82 | // GetServiceNames returns a slice with all not ignored services found in the 83 | // configuration file. 84 | func (r *Runner) GetServiceNames() []string { 85 | var names []string 86 | for key, value := range r.conf { 87 | if !value.Ignore { 88 | names = append(names, key) 89 | } 90 | } 91 | return names 92 | } 93 | 94 | // MakeService makes a new Service using the configuration under serviceName key 95 | // in the configuration file. 96 | func (r *Runner) MakeService(serviceName string) (Service, error) { 97 | // Fetch service from cache 98 | s, found := r.services[serviceName] 99 | if found { 100 | return s, nil 101 | } 102 | 103 | // Fetch service configuration 104 | conf, found := r.conf[serviceName] 105 | if !found { 106 | err := fmt.Errorf("configuration for service `%s` was not found. Please add service configuration in `%s` file", serviceName, r.confFilePath) 107 | return Service{}, err 108 | } 109 | 110 | // Validate configuration 111 | err := r.validateConf(conf) 112 | if err != nil { 113 | return Service{}, err 114 | } 115 | 116 | // Set SSH connection default configuration for missing values 117 | if conf.User == "" { 118 | currentUser, err := user.Current() 119 | if err == nil { 120 | conf.User = currentUser.Username 121 | } 122 | } 123 | if conf.Port == "" { 124 | conf.Port = "22" 125 | } 126 | if conf.PrivateKeyPath == "" { 127 | conf.PrivateKeyPath = filepath.Join(os.Getenv("HOME"), "/.ssh/id_rsa") 128 | } 129 | 130 | // Create SSH client 131 | client, err := sshcmd.MakeClient(conf.User, conf.Host, conf.Port, conf.PrivateKeyPath) 132 | if err != nil { 133 | return Service{}, err 134 | } 135 | 136 | // Connect the client 137 | err = client.Connect() 138 | if err != nil { 139 | return Service{}, err 140 | } 141 | 142 | // Create the service 143 | service := Service{Name: serviceName, Conf: conf, client: client, runner: r} 144 | 145 | // Find remote host working directory 146 | pwd, err := service.Exec("pwd") 147 | if err != nil { 148 | return Service{}, err 149 | } 150 | service.remoteHomeDir = pwd 151 | 152 | // Set default configuration for missing values 153 | 154 | // Go conf 155 | if conf.GoBinDirectory == "" { 156 | conf.GoBinDirectory, err = service.Exec("go env GOBIN") 157 | if err != nil { 158 | conf.GoBinDirectory = "" 159 | } 160 | } 161 | if conf.GoBinDirectory == "" { 162 | conf.GoBinDirectory, err = service.Exec("mise exec -- go env GOBIN") 163 | if err != nil { 164 | conf.GoBinDirectory = "" 165 | } 166 | } 167 | if conf.GoBinDirectory == "" { 168 | return Service{}, fmt.Errorf("$GOBIN environment variable is not set on the remote host: please set the $GOBIN env variable on the remote host or add `go_bin_directory: ` in `%s` file", r.confFilePath) 169 | } 170 | if conf.GoExecPath == "" { 171 | conf.GoExecPath, err = service.Exec("which go") 172 | if err != nil { 173 | conf.GoExecPath = "" 174 | } 175 | } 176 | if conf.GoExecPath == "" { 177 | conf.GoExecPath, err = service.Exec("mise exec -- which go") 178 | if err != nil { 179 | conf.GoExecPath = "" 180 | } 181 | } 182 | if conf.GoExecPath == "" { 183 | conf.GoExecPath = filepath.Join(conf.GoBinDirectory, "go") 184 | } 185 | 186 | // Systemd conf 187 | if conf.SystemdPath == "" { 188 | conf.SystemdPath = "systemd" 189 | } 190 | if conf.SystemdServicesDirectory == "" { 191 | conf.SystemdServicesDirectory = filepath.Join(pwd, ".config/systemd/user") 192 | } 193 | if conf.SystemdLingerDirectory == "" { 194 | conf.SystemdLingerDirectory = "/var/lib/systemd/linger" 195 | } 196 | 197 | // Service conf 198 | if conf.ExecStart == "" { 199 | exec := getExec(conf.GoInstall) 200 | if exec != "" { 201 | conf.ExecStart = filepath.Join(conf.GoBinDirectory, exec) 202 | } 203 | } 204 | if conf.WorkingDirectory == "" { 205 | conf.WorkingDirectory = pwd 206 | } 207 | // Save cache 208 | r.mu.Lock() 209 | r.services[serviceName] = service 210 | r.mu.Unlock() 211 | 212 | return service, nil 213 | } 214 | 215 | // StartPrintOutput starts a go routine that read messages from runner channel 216 | // and prints them. 217 | func (runner *Runner) StartPrintOutput(services []string) { 218 | width := 0 219 | for _, serviceName := range services { 220 | if len(serviceName) > width { 221 | width = len(serviceName) 222 | } 223 | } 224 | for { 225 | select { 226 | case message := <-runner.output: 227 | if !runner.QuietMode || message.status == MessageError { 228 | message.print(width) 229 | } 230 | case <-runner.quit: 231 | return 232 | } 233 | } 234 | } 235 | 236 | // StopPrintOutput stop the go routine started with StartPrintOutput. 237 | func (runner *Runner) StopPrintOutput() { 238 | runner.quit <- struct{}{} 239 | } 240 | 241 | // SendMessage writes a message in the runner channel that can be captured and 242 | // printed by the go routine started with StartPrintOutput. 243 | func (runner *Runner) SendMessage(serviceName, text string, status MessageStatus) { 244 | runner.output <- message{ 245 | serviceName: serviceName, 246 | text: text, 247 | status: status, 248 | } 249 | } 250 | 251 | // Private functions 252 | 253 | func readConf(filename string) (map[string]*Conf, error) { 254 | conf := make(map[string]*Conf) 255 | 256 | buf, err := ioutil.ReadFile(filename) 257 | if err != nil { 258 | return nil, err 259 | } 260 | 261 | err = yaml.Unmarshal(buf, conf) 262 | if err != nil { 263 | return nil, err 264 | } 265 | 266 | loadConfFromEnv(conf) 267 | 268 | return conf, nil 269 | } 270 | 271 | func loadConfFromEnv(conf map[string]*Conf) { 272 | for serviceName, value := range conf { 273 | reflectValue := reflect.ValueOf(value).Elem() 274 | for i := 0; i < reflectValue.NumField(); i++ { 275 | fieldReflectType := reflectValue.Type().Field(i) 276 | yamlTagValue := fieldReflectType.Tag.Get("yaml") 277 | if yamlTagValue != "" { 278 | envValue := os.Getenv(fmt.Sprintf("%s_%s", serviceNameToEnvName(serviceName), strings.ToUpper(yamlTagValue))) 279 | if envValue != "" { 280 | fieldValue := reflectValue.Field(i) 281 | switch fieldValue.Type().Name() { 282 | case "int": 283 | i, err := strconv.Atoi(envValue) 284 | if err == nil { 285 | fieldValue.Set(reflect.ValueOf(i)) 286 | } 287 | case "string": 288 | fieldValue.Set(reflect.ValueOf(envValue)) 289 | } 290 | } 291 | } 292 | } 293 | } 294 | } 295 | 296 | func (r *Runner) validateConf(conf *Conf) error { 297 | if conf.Host == "" { 298 | return fmt.Errorf("required configuration `host` value is missing: please add `host: ` in `%s` file", r.confFilePath) 299 | } 300 | if conf.GoInstall == "" { 301 | return fmt.Errorf("required configuration `go_install` value is missing: please add `go_install: ` in `%s` file", r.confFilePath) 302 | } 303 | return nil 304 | } 305 | 306 | var packageRegExp = regexp.MustCompile(`\/?([-_\w]+)@.*`) 307 | 308 | func getExec(packageName string) string { 309 | match := packageRegExp.FindStringSubmatch(packageName) 310 | if len(match) == 2 { 311 | return match[1] 312 | } 313 | return "" 314 | } 315 | 316 | func serviceNameToEnvName(serviceName string) string { 317 | if len(serviceName) == 0 { 318 | return "" 319 | } 320 | trim := func(r rune) rune { 321 | switch { 322 | case r >= 'A' && r <= 'Z': 323 | return r 324 | case r >= 'a' && r <= 'z': 325 | return r 326 | case r >= '0' && r <= '9': 327 | return r 328 | } 329 | return '_' 330 | } 331 | result := strings.ToUpper(strings.Map(trim, serviceName)) 332 | if result[0] >= '0' && result[0] <= '9' { 333 | result = "_" + result 334 | } 335 | return result 336 | } 337 | -------------------------------------------------------------------------------- /runner/service.go: -------------------------------------------------------------------------------- 1 | package runner 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "html/template" 7 | "io" 8 | "io/fs" 9 | "os" 10 | "path/filepath" 11 | "strings" 12 | 13 | "github.com/pioz/god/sshcmd" 14 | ) 15 | 16 | // Service represents a service that will be installed and launched on the 17 | // remote machine. 18 | type Service struct { 19 | // The service name (key in the configuration YAML file) 20 | Name string 21 | // Configuration under the key in the configuration YAML file 22 | Conf *Conf 23 | 24 | client *sshcmd.Client 25 | runner *Runner 26 | remoteHomeDir string 27 | } 28 | 29 | // Exec runs cmd on the remote host. 30 | func (service *Service) Exec(cmd string) (string, error) { 31 | output, err := service.client.Exec(cmd) 32 | return strings.TrimSuffix(output, "\n"), err 33 | } 34 | 35 | // PrintExec runs cmd on the remote host and sends the output on the runner 36 | // channel. 37 | func (service *Service) PrintExec(cmd, errorMessage string) error { 38 | service.runner.SendMessage(service.Name, cmd, MessageNormal) 39 | output, err := service.Exec(cmd) 40 | if err != nil { 41 | if errorMessage == "" { 42 | errorMessage = output 43 | } else { 44 | errorMessage = fmt.Sprintf("%s: %s", errorMessage, output) 45 | } 46 | service.runner.SendMessage(service.Name, errorMessage, MessageError) 47 | return err 48 | } else { 49 | service.runner.SendMessage(service.Name, output, MessageSuccess) 50 | return nil 51 | } 52 | } 53 | 54 | // ParseCommand parses the cmd string replacing the variables with those present 55 | // in the configuration. 56 | func (service *Service) ParseCommand(cmd string) string { 57 | tmpl, err := template.New("command").Parse(cmd) 58 | if err != nil { 59 | panic(err) 60 | } 61 | var parsedCommand bytes.Buffer 62 | tmpl.Execute(&parsedCommand, service.Conf) 63 | return parsedCommand.String() 64 | } 65 | 66 | // CopyFile copies the local file on the remote host to the remote 67 | // workingDirectory. If the local file is a directory, create the directory on 68 | // the remote host and recursively copy all files inside. 69 | func (service *Service) CopyFile(path, workingDirectory string) error { 70 | err := service.client.ConnectSftpClient() 71 | if err != nil { 72 | return err 73 | } 74 | 75 | return service.client.WalkDir(path, workingDirectory, func(localPath, remotePath string, info fs.DirEntry, e error) error { 76 | if info.IsDir() { 77 | return service.client.SftClient.MkdirAll(remotePath) 78 | } 79 | srcFile, err := os.Open(localPath) 80 | if err != nil { 81 | return err 82 | } 83 | 84 | dstFile, err := service.client.SftClient.Create(remotePath) 85 | if err != nil { 86 | return err 87 | } 88 | defer dstFile.Close() 89 | 90 | _, err = dstFile.ReadFrom(srcFile) 91 | return err 92 | }) 93 | } 94 | 95 | // DeleteFile deletes the file on the remote host relative to the remote 96 | // workingDirectory. 97 | func (service *Service) DeleteFile(path, workingDirectory string) error { 98 | var directories []string 99 | err := service.client.ConnectSftpClient() 100 | if err != nil { 101 | return err 102 | } 103 | err = service.client.WalkDir(path, workingDirectory, func(localPath, remotePath string, info fs.DirEntry, e error) error { 104 | if info.IsDir() { 105 | directories = append(directories, remotePath) 106 | } else { 107 | service.client.SftClient.Remove(remotePath) 108 | } 109 | return nil 110 | }) 111 | for i := len(directories) - 1; i >= 0; i-- { 112 | service.client.SftClient.RemoveDirectory(directories[i]) 113 | } 114 | return err 115 | } 116 | 117 | // CopyUnitServiceFile copies the systemd unit service file on the remote host. 118 | func (service *Service) CopyUnitServiceFile() error { 119 | err := service.client.ConnectSftpClient() 120 | if err != nil { 121 | return err 122 | } 123 | 124 | var buf bytes.Buffer 125 | service.GenerateServiceFile(&buf) 126 | 127 | // Create the destination file 128 | filename := filepath.Join(service.Conf.SystemdServicesDirectory, fmt.Sprintf("%s.service", service.Name)) 129 | dstFile, err := service.client.SftClient.Create(filename) 130 | if err != nil { 131 | return err 132 | } 133 | defer dstFile.Close() 134 | 135 | // write to file 136 | if _, err := dstFile.ReadFrom(&buf); err != nil { 137 | return err 138 | } 139 | return nil 140 | } 141 | 142 | // GenerateServiceFile generates the systemd unit service file using the service 143 | // configuration. 144 | func (service *Service) GenerateServiceFile(buf io.Writer) { 145 | tmpl, err := template.New("serviceFile").Parse(fmt.Sprintf(serviceTemplate, service.Name)) 146 | if err != nil { 147 | panic(err) 148 | } 149 | tmpl.Execute(buf, service.Conf) 150 | } 151 | 152 | // DeleteDirIfEmpty deletes remote directory only if empty. 153 | func (service *Service) DeleteDirIfEmpty(dirPath string) error { 154 | err := service.client.ConnectSftpClient() 155 | if err != nil { 156 | return err 157 | } 158 | return service.client.SftClient.Remove(dirPath) 159 | } 160 | 161 | const serviceTemplate = `[Unit] 162 | Description=%s 163 | {{- if .RunAfterService}} 164 | After={{.RunAfterService}} 165 | {{- end}} 166 | {{- if .StartLimitBurst}} 167 | StartLimitBurst={{.StartLimitBurst}} 168 | {{- end}} 169 | {{- if .StartLimitIntervalSec}} 170 | StartLimitIntervalSec={{.StartLimitIntervalSec}} 171 | {{- end}} 172 | 173 | [Service] 174 | Type=simple 175 | Restart=always 176 | {{- if .RestartSec}} 177 | RestartSec={{.RestartSec}} 178 | {{- end}} 179 | {{- if .Environment}} 180 | Environment={{.Environment}} 181 | {{- end}} 182 | {{- if .LogPath}} 183 | StandardOutput=append:{{.LogPath}} 184 | {{- end}} 185 | {{- if .LogPath}} 186 | StandardError=append:{{.LogPath}} 187 | {{- end}} 188 | WorkingDirectory={{.WorkingDirectory}} 189 | ExecStart={{.ExecStart}} 190 | 191 | [Install] 192 | WantedBy=default.target` 193 | -------------------------------------------------------------------------------- /sshcmd/client.go: -------------------------------------------------------------------------------- 1 | // Package sshcmd allows running commands on a remote host via ssh. 2 | package sshcmd 3 | 4 | import ( 5 | "bytes" 6 | "io/fs" 7 | "io/ioutil" 8 | "net" 9 | "path/filepath" 10 | "strings" 11 | 12 | "github.com/pkg/errors" 13 | "github.com/pkg/sftp" 14 | "golang.org/x/crypto/ssh" 15 | ) 16 | 17 | // Client is a wrapped around ssh.Client to run commands on a remote host via 18 | // ssh using a simple and easy to use APIs. 19 | type Client struct { 20 | SshClient *ssh.Client 21 | SftClient *sftp.Client 22 | Username string 23 | Host string 24 | Port string 25 | 26 | privateKey []byte 27 | } 28 | 29 | // MakeClient returns an initialized Client. 30 | func MakeClient(username, host, port, privateKeyPath string) (*Client, error) { 31 | if port == "" { 32 | port = "22" 33 | } 34 | client := &Client{ 35 | Username: username, 36 | Host: host, 37 | Port: port, 38 | } 39 | 40 | bytes, err := ioutil.ReadFile(privateKeyPath) 41 | if err != nil { 42 | return nil, err 43 | } 44 | client.privateKey = bytes 45 | return client, nil 46 | } 47 | 48 | // Connect connects the client to the remote host. After connection, the client 49 | // is ready to run a command on the remote host. 50 | func (c *Client) Connect() error { 51 | key, err := ssh.ParsePrivateKey(c.privateKey) 52 | if err != nil { 53 | return err 54 | } 55 | // Authentication 56 | config := &ssh.ClientConfig{ 57 | User: c.Username, 58 | // https://github.com/golang/go/issues/19767 59 | // as clientConfig is non-permissive by default 60 | // you can set ssh.InsercureIgnoreHostKey to allow any host 61 | HostKeyCallback: ssh.InsecureIgnoreHostKey(), 62 | Auth: []ssh.AuthMethod{ssh.PublicKeys(key)}, 63 | // //alternatively, you could use a password 64 | // Auth: []ssh.AuthMethod{ssh.Password("PASSWORD")}, 65 | } 66 | // Connect 67 | client, err := ssh.Dial("tcp", net.JoinHostPort(c.Host, c.Port), config) 68 | if err != nil { 69 | return err 70 | } 71 | c.SshClient = client 72 | return nil 73 | } 74 | 75 | // ConnectSftpClient initialize and connects the sftp.Client using the current 76 | // ssh.Client. If the sftpClient is already initialized, it has no effect. 77 | func (c *Client) ConnectSftpClient() error { 78 | if c.SftClient != nil { 79 | return nil 80 | } 81 | sftp, err := sftp.NewClient(c.SshClient) 82 | if err != nil { 83 | return err 84 | } 85 | c.SftClient = sftp 86 | return nil 87 | } 88 | 89 | // Exec runs a command on the remote host. Returns the output of the command and 90 | // the error if occurred. 91 | func (c *Client) Exec(cmd string) (string, error) { 92 | if c.SshClient == nil { 93 | return "", errors.New("client is not connected") 94 | } 95 | // Create a session. It is one session per command. 96 | session, err := c.SshClient.NewSession() 97 | if err != nil { 98 | return "", err 99 | } 100 | defer session.Close() 101 | 102 | var stdout, stderr bytes.Buffer 103 | session.Stdout = &stdout 104 | session.Stderr = &stderr 105 | err = session.Run(cmd) 106 | if err != nil { 107 | return stderr.String(), err 108 | } 109 | return stdout.String(), nil 110 | } 111 | 112 | // WalkDir is a wrapper around filepath.WalkDir. 113 | func (c *Client) WalkDir(srcPath, dstDir string, fn WalkDirFunc) error { 114 | dirs := make([]string, 0) 115 | return filepath.WalkDir(srcPath, func(path string, info fs.DirEntry, err error) error { 116 | curDir := filepath.Join(dirs...) 117 | d, _ := filepath.Split(path) 118 | if d != "" { 119 | d = d[:len(d)-1] 120 | } 121 | if !strings.HasSuffix(d, curDir) { 122 | if len(dirs) > 0 { 123 | dirs = dirs[:len(dirs)-1] 124 | } 125 | curDir = filepath.Join(dirs...) 126 | } 127 | dstPath := filepath.Join(dstDir, curDir, filepath.Base(path)) 128 | 129 | if info.IsDir() { 130 | dirs = append(dirs, filepath.Base(path)) 131 | } 132 | 133 | return fn(path, dstPath, info, err) 134 | }) 135 | } 136 | 137 | // WalkDirFunc is the type of the function called by WalkDir to visit each file 138 | // or directory. 139 | // 140 | // srcPath is the local source path. 141 | // 142 | // dstPath is the remote destination path rooted in dstDir parameter of WalkDir 143 | // method. 144 | // 145 | // info and err are the same as fs.WalkDirFunc. 146 | type WalkDirFunc func(srcPath, dstPath string, info fs.DirEntry, err error) error 147 | --------------------------------------------------------------------------------