├── .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 |
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 |
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 |
--------------------------------------------------------------------------------