-
Notifications
You must be signed in to change notification settings - Fork 1
/
main.go
445 lines (404 loc) · 13 KB
/
main.go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
package main
import (
"bufio"
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io/ioutil"
"log"
"net"
"os"
"os/exec"
"path/filepath"
"strings"
"text/template"
"time"
clconfig "github.com/flatcar/container-linux-config-transpiler/config"
"github.com/hetznercloud/hcloud-go/hcloud"
"github.com/melbahja/goph"
"gopkg.in/yaml.v3"
)
var installScriptSource = "https://raw.githubusercontent.com/flatcar-linux/init/flatcar-master/bin/flatcar-install"
func transpileConfig(input []byte) (string, error) {
cfg, pt, report := clconfig.Parse(input)
if report.IsFatal() {
return "", errors.New("config parsing failed")
}
transpiledConfig, report := clconfig.Convert(cfg, "", pt)
if report.IsFatal() {
return "", errors.New("config conversion failed")
}
cfgJSON, err := json.Marshal(&transpiledConfig)
if err != nil {
return "", err
}
outFile, err := os.CreateTemp(os.TempDir(), "ignition")
if err != nil {
return "", err
}
if _, err := outFile.Write(cfgJSON); err != nil {
return "", err
}
return outFile.Name(), nil
}
// waitForAction queries the current state of an action every second and waits for it to complete
func waitForAction(actionClient hcloud.ActionClient, action *hcloud.Action) error {
log.Printf("waiting for action %s to complete\n", action.Command)
progressChannel, errorChannel := actionClient.WatchProgress(context.Background(), action)
success := false
for progress := range progressChannel {
if progress == 100 {
success = true
}
}
var err error
if !success {
// channel was closed before progress was 100 so there was probably an error
err = <-errorChannel
}
return err
}
type templateData struct {
Server hcloud.Server
SSHKey hcloud.SSHKey
Static map[string]string
ReadFile func(string) (string, error)
Indent func(int, string) string
}
type customTemplateDataHetzner struct {
Server hcloud.Server
SSHKey hcloud.SSHKey
}
type customTemplateData struct {
Hetzner customTemplateDataHetzner
}
func main() {
// TODO: cli parser...
if len(os.Args) == 1 {
fmt.Printf("%s <server name>\n", os.Args[0])
os.Exit(1)
}
cfg, err := ParseConfig("config.toml")
if err != nil {
log.Fatalf("error parsing config: %v\n", err)
}
serverName := os.Args[1]
client := hcloud.NewClient(hcloud.WithToken(cfg.HCloud.Token))
// find ssh key
sshKeyName := cfg.HCloud.SSHKey
sshKey, _, err := client.SSHKey.GetByName(context.Background(), sshKeyName)
if err != nil {
log.Fatalf("error requesting ssh key: %v\n", err)
}
if sshKey == nil {
log.Fatalf("ssh key %s doesn't exist\n", sshKeyName)
}
// find private network
privateNetworkName := cfg.HCloud.PrivateNetwork
privateNetwork, _, err := client.Network.GetByName(context.Background(), privateNetworkName)
if err != nil {
log.Fatalf("error requesting network: %v\n", err)
}
if privateNetwork == nil {
log.Fatalf("network %s doesn't exist\n", privateNetworkName)
}
serverExists := true
server, _, err := client.Server.GetByName(context.Background(), serverName)
if err != nil {
log.Fatalf("error finding server: %v\n", err)
}
if server == nil {
serverExists = false
}
if serverExists {
log.Printf("server '%s' (id %d) already exists, checking for necessary changes\n", serverName, server.ID)
// check if redeploy is necessary -- fetching user data afterwards not possible, maybe cache locally/connect to server?
// TODO: check if specification matches
// TODO: support more than one network?
// TODO: disable if network doesn't exist / not given
privateNetworkAttached := false
for _, attachedPrivateNet := range server.PrivateNet {
if attachedPrivateNet.Network.ID == privateNetwork.ID {
privateNetworkAttached = true
break
}
}
if !privateNetworkAttached {
// attach to private network
action, _, err := client.Server.AttachToNetwork(context.Background(), server, hcloud.ServerAttachToNetworkOpts{
Network: privateNetwork,
})
if err != nil {
log.Fatalf("error request attach to network: %v\n", err)
}
if action.Error() != nil {
log.Fatalf("error attaching server to network: %v\n", action.Error())
}
log.Printf("attached server to network %s\n", privateNetworkName)
}
} else {
log.Printf("creating server '%s'", serverName)
// create server
startAfterCreate := false
serverType, _, err := client.ServerType.GetByName(context.Background(), cfg.HCloud.ServerType)
if err != nil {
log.Fatalf("error finding server type: %v\n", err)
}
image, _, err := client.Image.Get(context.Background(), cfg.HCloud.Image)
if err != nil {
log.Fatalf("error finding image: %v\n", err)
}
location, _, err := client.Location.GetByName(context.Background(), cfg.HCloud.Location)
if err != nil {
log.Fatalf("error finding location: %v\n", err)
}
createOpts := hcloud.ServerCreateOpts{
Name: serverName,
StartAfterCreate: &startAfterCreate,
ServerType: serverType,
Image: image,
Location: location,
SSHKeys: []*hcloud.SSHKey{sshKey},
Networks: []*hcloud.Network{privateNetwork},
}
serverCreateResult, _, err := client.Server.Create(context.Background(), createOpts)
if err != nil {
log.Fatalf("error creating server: %v\n", err)
}
if serverCreateResult.Action.Error() != nil {
log.Fatalf("error creating server: %v\n", serverCreateResult.Action.Error())
}
err = waitForAction(client.Action, serverCreateResult.Action)
if err != nil {
log.Fatalf("error waiting for action: %v\n", err)
}
for _, pastCreateAction := range serverCreateResult.NextActions {
err = waitForAction(client.Action, pastCreateAction)
if err != nil {
log.Fatalf("error waiting for action: %v\n", err)
}
}
// update server object for templating
server, _, err = client.Server.GetByID(context.Background(), serverCreateResult.Server.ID)
if err != nil {
log.Fatalf("error requesting updated server object: %v\n", err)
}
}
var templateContent []byte
if cfg.Flatcar.TemplateCommand == "" {
ignitionTemplate := cfg.Flatcar.ConfigTemplate
log.Printf("rendering ignition config using native template at %s\n", ignitionTemplate)
buffer := &bytes.Buffer{}
tmpl, err := template.New(filepath.Base(ignitionTemplate)).ParseFiles(ignitionTemplate)
if err != nil {
log.Fatalf("error loading template: %v\n", err)
}
err = tmpl.Execute(buffer, templateData{
Server: *server,
SSHKey: *sshKey,
Static: cfg.Flatcar.TemplateStatic,
ReadFile: func(filename string) (string, error) {
content, err := ioutil.ReadFile(filename)
return string(content), err
},
Indent: func(indent int, input string) string {
lines := strings.Split(input, "\n")
output := make([]string, len(lines))
indentString := strings.Repeat(" ", indent)
for i := 0; i < len(output); i++ {
output[i] = indentString + lines[i]
}
return strings.Join(output, "\n")
},
})
if err != nil {
log.Fatalf("error rendering template: %v\n", err)
}
templateContent, _ = ioutil.ReadAll(buffer)
} else {
log.Printf("rendering ignition config using command '%s'\n", cfg.Flatcar.TemplateCommand)
// marshal template data for passing it to the custom command
templateData := customTemplateData{
Hetzner: customTemplateDataHetzner{
Server: *server,
SSHKey: *sshKey,
},
}
templateDataYAML, err := yaml.Marshal(templateData)
if err != nil {
log.Fatalf("error marshaling hcloud data to yaml: %v\n", err)
}
// execute custom template command
tmplCmd := exec.Command(cfg.Flatcar.TemplateCommand, server.Name)
tmplCmd.Stdin = bytes.NewReader(templateDataYAML)
templateContent, err = tmplCmd.Output()
if err != nil {
log.Println(string(err.(*exec.ExitError).Stderr))
log.Fatalf("error running template command: %v\n", err)
}
}
renderedPath, err := transpileConfig(templateContent)
if err != nil {
log.Fatalf("error transpiling config: %v\n", err)
}
defer func(path string) {
if err := os.Remove(path); err != nil {
log.Fatalf("error removing tempfile: %v\n", err)
}
}(renderedPath)
// enable rescue boot
if !server.RescueEnabled {
log.Println("enabling rescue boot")
result, _, err := client.Server.EnableRescue(context.Background(), server, hcloud.ServerEnableRescueOpts{
Type: hcloud.ServerRescueTypeLinux64,
SSHKeys: []*hcloud.SSHKey{sshKey},
})
if err != nil {
log.Fatalf("error sending enablerescue request: %v\n", err)
}
if result.Action.Error() != nil {
log.Fatalf("error enabling rescue: %v\n", result.Action.Error())
}
err = waitForAction(client.Action, result.Action)
if err != nil {
log.Fatalf("error waiting for action: %v\n", err)
}
}
var action *hcloud.Action
if server.Status == hcloud.ServerStatusRunning {
// server is already running, reboot into rescue
log.Println("server already running, rebooting into rescue for reinstall")
action, _, err = client.Server.Reboot(context.Background(), server)
} else {
log.Printf("powering server on")
action, _, err = client.Server.Poweron(context.Background(), server)
}
if err != nil {
log.Fatalf("error sending reboot or poweron request: %v\n", err)
}
if action.Error() != nil {
log.Fatalf("error rebooting or powering on server: %v\n", action.Error())
}
err = waitForAction(client.Action, action)
if err != nil {
log.Fatalf("error waiting for action: %v\n", err)
}
// give the server some time to (re)boot
log.Println("sleeping 30s to wait for server to (re)boot into rescue")
time.Sleep(30 * time.Second)
var sshAuth goph.Auth
if cfg.HCloud.SSHKeyPrivatePath != "" {
sshAuth, err = goph.Key(cfg.HCloud.SSHKeyPrivatePath, "")
} else {
sshAuth, err = goph.UseAgent()
}
if err != nil {
log.Fatalf("error building ssh authentication: %v\n", err)
}
initialRetries := 30
retries := 1
connectionSuccess := false
retryDelay := 10 * time.Second
var sshClient *goph.Client
for retries <= initialRetries {
// TODO: add option to enable host key checking, will be random, though because rescue always has a different hostkey
var addr string
if ip := server.PublicNet.IPv4.IP; ip != nil {
addr = ip.String()
} else {
addr = fmt.Sprintf("%s2", server.PublicNet.IPv6.IP.String())
}
// rescue os always uses ::2
sshClient, err = goph.NewUnknown("root", addr, sshAuth)
if err == nil {
connectionSuccess = true
break
} else {
if netError, ok := err.(net.Error); ok {
log.Printf("retrying network error (%d/%d): %v\n", retries, initialRetries, netError)
retries++
time.Sleep(retryDelay)
} else {
log.Fatalf("unretriable error while etablishing ssh connection: %v\n", err)
break
}
}
}
if !connectionSuccess {
log.Fatalf("ssh connection wasn't successful")
}
// Defer closing the network connection.
defer sshClient.Close()
installScriptTarget := "/root/flatcar-install"
ignitionTarget := "/root/ignition.json"
if cfg.Flatcar.InstallScript != "" {
err = sshClient.Upload(cfg.Flatcar.InstallScript, installScriptTarget)
if err != nil {
log.Fatalf("error uploading flatcar-install script: %v\n", err)
}
} else {
// download install script on remote maschine
cmd, err := sshClient.Command(fmt.Sprintf("curl -sS -o %s %s", installScriptTarget, installScriptSource))
if err != nil {
log.Fatalf("error creating cmd for install script download: %v\n", err)
}
err = cmd.Run()
if err != nil {
log.Fatalf("error downloading install script: %v\n", err)
}
}
err = sshClient.Upload(renderedPath, ignitionTarget)
if err != nil {
log.Fatalf("error uploading ignition file: %v\n", err)
}
// build flatcar-install command
var installDeviceArg string
if cfg.Flatcar.InstallDevice == "" {
installDeviceArg = "-s"
} else {
installDeviceArg = fmt.Sprintf("-d %s", cfg.Flatcar.InstallDevice)
}
installCommand := fmt.Sprintf("%s -i %s -V %s %s %s", installScriptTarget, ignitionTarget, cfg.Flatcar.Version, installDeviceArg, cfg.Flatcar.InstallArgs)
// execute commands to finally install flatcar
commands := []string{
"apt update",
"apt install -y gawk",
fmt.Sprintf("chmod +x %s", installScriptTarget),
installCommand,
}
for _, command := range commands {
log.Printf("running command '%s'\n", command)
cmd, err := sshClient.Command(command)
if err != nil {
log.Fatalf("error creating goph.Cmd for '%s': %v\n", command, err)
}
stdoutPipe, err := cmd.StdoutPipe()
if err != nil {
log.Fatalf("error creating stdoutpipe for '%s': %v\n", command, err)
}
go func() {
// TODO: don't print this if not desired
scanner := bufio.NewScanner(stdoutPipe)
for scanner.Scan() {
log.Printf("%s - %s", command, scanner.Text())
}
}()
err = cmd.Run()
if err != nil {
log.Fatalf("error running command '%s': %v\n", command, err)
}
}
// run reboot command
cmd, err := sshClient.Command("reboot now")
if err != nil {
log.Fatalf("error creating goph.Cmd for reboot command: %v\n", err)
}
err = cmd.Run()
if err != nil {
log.Printf("reboot command failed, VM probably rebooted anyways: %v\n", err)
}
log.Println("------")
log.Printf("successfully (re)installed %s, ID: %d IPv4: %s IPv6: %s\n", server.Name, server.ID, server.PublicNet.IPv4.IP.String(), server.PublicNet.IPv6.IP.String())
}