The same licence as ihasmail, which this tool deploys. The network clause is the part that matters for a deploy tool: a modified copy offered as a hosted service that deploys for other people has to make its source available to them, which GPL would not require. Running the tool for your own host is unaffected. v2026.9.13 was released under GPL-3.0-or-later and stays so; this applies from the next release.
121 lines
3.4 KiB
Go
121 lines
3.4 KiB
Go
// SPDX-FileCopyrightText: 2026 Coffey Labs
|
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
|
|
// Package render writes the deployment directory: compose.yaml, the Caddyfile,
|
|
// and the files holding secrets.
|
|
package render
|
|
|
|
import (
|
|
"bytes"
|
|
"embed"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"text/template"
|
|
|
|
"github.com/Coffey-Labs/ihasmail-oneshot/internal/config"
|
|
)
|
|
|
|
//go:embed templates/*.tmpl
|
|
var templates embed.FS
|
|
|
|
var tmpl = template.Must(template.New("").Funcs(template.FuncMap{
|
|
"join": strings.Join,
|
|
}).ParseFS(templates, "templates/*.tmpl"))
|
|
|
|
// Files the tool writes into a deployment directory, and nothing else. Destroy
|
|
// removes exactly these, so a file the operator added survives it.
|
|
const (
|
|
ComposeFile = "compose.yaml"
|
|
Caddyfile = "Caddyfile"
|
|
EnvFile = ".env"
|
|
CredentialsFile = "credentials.txt"
|
|
DNSFile = "dns-records.zone"
|
|
CARootFile = "acme-ca-root.pem"
|
|
CABundleFile = "ca-bundle.crt"
|
|
)
|
|
|
|
// Written lists every file name Destroy may remove.
|
|
var Written = []string{ComposeFile, Caddyfile, EnvFile, CredentialsFile, DNSFile, CARootFile, CABundleFile}
|
|
|
|
type data struct {
|
|
Version string
|
|
Plan config.Plan
|
|
CABundle bool
|
|
}
|
|
|
|
// Compose renders compose.yaml.
|
|
func Compose(p config.Plan, version string, caBundle bool) ([]byte, error) {
|
|
return execute("compose.yaml.tmpl", data{Version: version, Plan: p, CABundle: caBundle})
|
|
}
|
|
|
|
// Caddy renders the Caddyfile.
|
|
func Caddy(p config.Plan, version string) ([]byte, error) {
|
|
return execute("Caddyfile.tmpl", data{Version: version, Plan: p})
|
|
}
|
|
|
|
func execute(name string, d data) ([]byte, error) {
|
|
var b bytes.Buffer
|
|
if err := tmpl.ExecuteTemplate(&b, name, d); err != nil {
|
|
return nil, fmt.Errorf("render %s: %w", name, err)
|
|
}
|
|
return b.Bytes(), nil
|
|
}
|
|
|
|
// Env renders .env. Only the app secret lives here: compose.yaml reads it by
|
|
// interpolation, so the file compose.yaml sits in can be shown to someone
|
|
// without showing them the key every session is sealed with.
|
|
func Env(appSecret string) []byte {
|
|
return []byte("# Read by docker compose. Changing APP_SECRET signs everyone out.\nAPP_SECRET=" + appSecret + "\n")
|
|
}
|
|
|
|
// PrepareDir creates dir, or accepts it if it exists and is empty. Anything in
|
|
// it already is refused: the tool writes a fresh deployment, and silently
|
|
// replacing someone's compose.yaml is not a fresh deployment.
|
|
func PrepareDir(dir string) error {
|
|
entries, err := os.ReadDir(dir)
|
|
switch {
|
|
case errors.Is(err, os.ErrNotExist):
|
|
return os.MkdirAll(dir, 0o750)
|
|
case err != nil:
|
|
return err
|
|
case len(entries) > 0:
|
|
return fmt.Errorf("%s already has files in it; give --dir a new or empty directory", dir)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// WriteFile writes one file into dir, private if it holds a secret. It refuses
|
|
// to replace an existing file, for the same reason PrepareDir refuses a full
|
|
// directory.
|
|
func WriteFile(dir, name string, content []byte, secret bool) error {
|
|
mode := os.FileMode(0o644)
|
|
if secret {
|
|
mode = 0o600
|
|
}
|
|
f, err := os.OpenFile(filepath.Join(dir, name), os.O_WRONLY|os.O_CREATE|os.O_EXCL, mode)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if _, err := f.Write(content); err != nil {
|
|
f.Close()
|
|
return err
|
|
}
|
|
return f.Close()
|
|
}
|
|
|
|
// AppendFile adds to a file WriteFile created, keeping its mode.
|
|
func AppendFile(dir, name string, content []byte) error {
|
|
f, err := os.OpenFile(filepath.Join(dir, name), os.O_WRONLY|os.O_APPEND, 0)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if _, err := f.Write(content); err != nil {
|
|
f.Close()
|
|
return err
|
|
}
|
|
return f.Close()
|
|
}
|