stack.go
222 lines1
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
package platform
import (
"cmp"
"encoding/base64"
"fmt"
"strings"
)
// Stack is a declarative, multi-service deployment: a set of Services plus the
// named volumes and networks they share. It is docker-compose expressed as
// type-safe Go — import platform, build a Stack with compile-time-checked fields,
// and render it to a docker-compose.yml (ComposeYAML) or to cloud-init user_data
// that brings the whole stack up on a fresh host (CloudInit). Define one per app
// in a runtime package so a deploy is a value, not a hand-edited YAML file:
//
// func Stack(o Options) *platform.Stack {
// return &platform.Stack{
// Volumes: []string{"data"},
// Services: []platform.Service{{
// Name: "app",
// Image: "${APP_IMAGE}", // env-substituted from .env for push-update
// Ports: []platform.Port{{Host: 80, Container: 5000}},
// Volumes: []platform.Mount{{Source: "data", Target: "/data"}},
// }},
// }
// }
type Stack struct {
Services []Service
Volumes []string // named docker volumes referenced by service mounts
Networks []string // named docker networks; the compose default is used if empty
}
// ComposeYAML renders the stack to docker-compose.yml content. A Service Mount
// whose Source is a bare name (no slash) is treated as one of the Stack's named
// volumes; a Source with a slash is a bind mount.
func (s *Stack) ComposeYAML() string {
var b strings.Builder
b.WriteString("services:\n")
for _, svc := range s.Services {
writeService(&b, svc)
}
if len(s.Volumes) > 0 {
b.WriteString("volumes:\n")
for _, v := range s.Volumes {
b.WriteString(" " + v + ":\n")
}
}
if len(s.Networks) > 0 {
b.WriteString("networks:\n")
for _, n := range s.Networks {
b.WriteString(" " + n + ":\n")
}
}
return b.String()
}
func writeService(b *strings.Builder, svc Service) {
p := func(indent, text string) { b.WriteString(indent + text + "\n") }
p(" ", svc.Name+":")
p(" ", "image: "+svc.Image)
if svc.ContainerName != "" {
p(" ", "container_name: "+svc.ContainerName)
}
p(" ", "restart: "+cmp.Or(svc.Restart, "unless-stopped"))
if svc.Privileged {
p(" ", "privileged: true")
}
if len(svc.Command) > 0 {
p(" ", "command: ["+quoteList(svc.Command)+"]")
}
if len(svc.Ports) > 0 {
p(" ", "ports:")
for _, port := range svc.Ports {
host := fmt.Sprintf("%d", port.Host)
if port.Bind != "" {
host = port.Bind + ":" + host
}
p(" ", fmt.Sprintf("- \"%s:%d\"", host, port.Container))
}
}
if len(svc.Env) > 0 {
p(" ", "environment:")
for _, k := range sortedKeys(svc.Env) {
p(" ", k+": "+quoteYAML(svc.Env[k]))
}
}
if len(svc.Volumes) > 0 {
p(" ", "volumes:")
for _, m := range svc.Volumes {
p(" ", "- "+m.Source+":"+m.Target)
}
}
if len(svc.DependsOn) > 0 {
p(" ", "depends_on:")
for _, dep := range svc.DependsOn {
p(" ", "- "+dep)
}
}
if svc.Network != "" {
p(" ", "networks:")
p(" ", "- "+svc.Network)
}
if hc := svc.Healthcheck; hc != nil && hc.Cmd != "" {
p(" ", "healthcheck:")
p(" ", "test: ["+quoteList([]string{"CMD-SHELL", hc.Cmd})+"]")
if hc.Interval != "" {
p(" ", "interval: "+hc.Interval)
}
if hc.Timeout != "" {
p(" ", "timeout: "+hc.Timeout)
}
if hc.Retries > 0 {
p(" ", fmt.Sprintf("retries: %d", hc.Retries))
}
if hc.StartPeriod != "" {
p(" ", "start_period: "+hc.StartPeriod)
}
}
if r := svc.Resources; r != nil && (r.CPUs != "" || r.Memory != "") {
p(" ", "deploy:")
p(" ", "resources:")
p(" ", "limits:")
if r.CPUs != "" {
p(" ", "cpus: "+quoteYAML(r.CPUs))
}
if r.Memory != "" {
p(" ", "memory: "+r.Memory)
}
}
if l := svc.Logging; l != nil && l.Driver != "" {
p(" ", "logging:")
p(" ", "driver: "+quoteYAML(l.Driver))
if len(l.Options) > 0 {
p(" ", "options:")
for _, k := range sortedKeys(l.Options) {
p(" ", k+": "+quoteYAML(l.Options[k]))
}
}
}
}
// RegistryAuth logs the host into a private registry before pulling images.
type RegistryAuth struct {
Host string
Username string
Password string
}
// CloudInitOptions configures the script CloudInit generates.
type CloudInitOptions struct {
Dir string // where files are written (default /opt/app)
Env map[string]string // written to .env beside the compose file (e.g. APP_IMAGE=...)
Registry *RegistryAuth // optional private-registry login before pull
OpenPorts []int // host ports to open with ufw
}
// CloudInit renders cloud-init user_data that writes the compose file and .env,
// logs into the registry if configured, then pulls and starts the stack. It is
// the bring-up half of a deploy; updates afterward go to the running app (see the
// agent protocol) without re-provisioning. Compose is embedded base64 so no
// quoting of the YAML is needed on the host.
func (s *Stack) CloudInit(opts CloudInitOptions) string {
dir := cmp.Or(opts.Dir, "/opt/app")
compose := base64.StdEncoding.EncodeToString([]byte(s.ComposeYAML()))
var cmds []string
for _, port := range opts.OpenPorts {
cmds = append(cmds, fmt.Sprintf("ufw allow %d/tcp", port))
}
cmds = append(cmds, "mkdir -p "+dir)
if reg := opts.Registry; reg != nil && reg.Host != "" {
cmds = append(cmds, fmt.Sprintf("echo %q | docker login %s -u %s --password-stdin",
reg.Password, reg.Host, reg.Username))
}
cmds = append(cmds, fmt.Sprintf("echo '%s' | base64 -d > %s/docker-compose.yml", compose, dir))
if len(opts.Env) > 0 {
for _, k := range sortedKeys(opts.Env) {
cmds = append(cmds, fmt.Sprintf("echo '%s=%s' >> %s/.env", k, opts.Env[k], dir))
}
}
cmds = append(cmds,
fmt.Sprintf("docker compose -f %s/docker-compose.yml pull", dir),
fmt.Sprintf("docker compose -f %s/docker-compose.yml up -d", dir),
)
var b strings.Builder
b.WriteString("#cloud-config\nruncmd:\n")
for _, cmd := range cmds {
b.WriteString(" - " + cmd + "\n")
}
return b.String()
}
// quoteList renders a shell/JSON-ish string array for compose inline lists.
func quoteList(items []string) string {
quoted := make([]string, len(items))
for i, it := range items {
quoted[i] = `"` + strings.ReplaceAll(it, `"`, `\"`) + `"`
}
return strings.Join(quoted, ", ")
}
// quoteYAML single-quotes a scalar, doubling embedded single quotes.
func quoteYAML(v string) string {
return "'" + strings.ReplaceAll(v, "'", "''") + "'"
}
// sortedKeys returns a map's keys in deterministic order, so rendered output is
// stable (important for hashing and diffs).
func sortedKeys[V any](m map[string]V) []string {
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
for i := 1; i < len(keys); i++ {
for j := i; j > 0 && keys[j-1] > keys[j]; j-- {
keys[j-1], keys[j] = keys[j], keys[j-1]
}
}
return keys
}