stack.go

222 lines
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
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
}