docs: publish Tend Compose continuity evidence
Export the reviewed allowlisted snapshot from private source commit 07c1655921f21ee5e4fc4d85639d199e8867b17d. This records the Docker Compose activation, schema-compatible rollback, and stateful migration resource findings from Observatory Preview 19 dogfooding. AI-Assisted: OpenAI Codex Signed-off-by: Cole Speelman <crspeelman@gmail.com>
This commit is contained in:
@@ -0,0 +1,348 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
SchemaVersion = 2
|
||||
SharedLockFile = "/run/lock/tend-deploy.lock"
|
||||
)
|
||||
|
||||
var (
|
||||
namePattern = regexp.MustCompile(`^[a-z][a-z0-9-]{1,62}$`)
|
||||
binaryPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$`)
|
||||
packagePattern = regexp.MustCompile(`^\./[A-Za-z0-9_./-]+$`)
|
||||
symbolPattern = regexp.MustCompile(`^[A-Za-z0-9._/-]+$`)
|
||||
unitPattern = regexp.MustCompile(`^[A-Za-z0-9_.@-]+\.service$`)
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
SchemaVersion int `json:"schema_version"`
|
||||
Service Service `json:"service"`
|
||||
Build Build `json:"build"`
|
||||
Deployment Deployment `json:"deployment"`
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
Name string `json:"name"`
|
||||
AllowedHost string `json:"allowed_host"`
|
||||
EnvironmentFile string `json:"environment_file"`
|
||||
}
|
||||
|
||||
type Build struct {
|
||||
Package string `json:"package"`
|
||||
Binary string `json:"binary"`
|
||||
Branch string `json:"branch"`
|
||||
VersionSymbol string `json:"version_symbol,omitempty"`
|
||||
CommitSymbol string `json:"commit_symbol,omitempty"`
|
||||
DateSymbol string `json:"date_symbol,omitempty"`
|
||||
}
|
||||
|
||||
type Deployment struct {
|
||||
Strategy string `json:"strategy"`
|
||||
Root string `json:"root"`
|
||||
LockFile string `json:"lock_file"`
|
||||
StateFile string `json:"state_file"`
|
||||
EventLog string `json:"event_log"`
|
||||
HealthPath string `json:"health_path"`
|
||||
ReadinessPath string `json:"readiness_path"`
|
||||
CandidateTimeoutSecs int `json:"candidate_timeout_seconds"`
|
||||
ActivationWindowSecs int `json:"activation_window_seconds"`
|
||||
Smoke []Smoke `json:"smoke"`
|
||||
PublicSmoke []PublicSmoke `json:"public_smoke"`
|
||||
BlueGreen *BlueGreen `json:"blue_green,omitempty"`
|
||||
Singleton *Singleton `json:"singleton,omitempty"`
|
||||
}
|
||||
|
||||
type Smoke struct {
|
||||
Path string `json:"path"`
|
||||
Contains string `json:"contains"`
|
||||
}
|
||||
|
||||
type PublicSmoke struct {
|
||||
URL string `json:"url"`
|
||||
Contains string `json:"contains"`
|
||||
}
|
||||
|
||||
type BlueGreen struct {
|
||||
CaddyConfig string `json:"caddy_config"`
|
||||
CaddyHandler string `json:"caddy_handler"`
|
||||
CaddyHandlerTemplate string `json:"caddy_handler_template"`
|
||||
BootstrapActive string `json:"bootstrap_active"`
|
||||
Blue Slot `json:"blue"`
|
||||
Green Slot `json:"green"`
|
||||
}
|
||||
|
||||
type Slot struct {
|
||||
Unit string `json:"unit"`
|
||||
Address string `json:"address"`
|
||||
Link string `json:"link"`
|
||||
}
|
||||
|
||||
type Singleton struct {
|
||||
Unit string `json:"unit"`
|
||||
Address string `json:"address"`
|
||||
CandidateAddress string `json:"candidate_address"`
|
||||
ListenEnv string `json:"listen_env"`
|
||||
CurrentLink string `json:"current_link"`
|
||||
PreviousLink string `json:"previous_link"`
|
||||
CaddyConfig string `json:"caddy_config"`
|
||||
CaddyHandler string `json:"caddy_handler"`
|
||||
CaddyHandlerTemplate string `json:"caddy_handler_template"`
|
||||
}
|
||||
|
||||
func Load(path string) (Config, error) {
|
||||
if !filepath.IsAbs(path) {
|
||||
return Config{}, errors.New("configuration path must be absolute")
|
||||
}
|
||||
info, err := os.Lstat(path)
|
||||
if err != nil {
|
||||
return Config{}, fmt.Errorf("inspect configuration: %w", err)
|
||||
}
|
||||
if !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 || info.Size() > 1<<20 {
|
||||
return Config{}, errors.New("configuration must be a bounded regular file, not a symlink")
|
||||
}
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return Config{}, fmt.Errorf("read configuration: %w", err)
|
||||
}
|
||||
dec := json.NewDecoder(bytes.NewReader(b))
|
||||
dec.DisallowUnknownFields()
|
||||
var cfg Config
|
||||
if err := dec.Decode(&cfg); err != nil {
|
||||
return Config{}, fmt.Errorf("decode configuration: %w", err)
|
||||
}
|
||||
if err := requireEOF(dec); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
if err := cfg.Validate(); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func requireEOF(dec *json.Decoder) error {
|
||||
var extra any
|
||||
if err := dec.Decode(&extra); !errors.Is(err, io.EOF) {
|
||||
if err == nil {
|
||||
return errors.New("configuration contains multiple JSON values")
|
||||
}
|
||||
return fmt.Errorf("decode trailing configuration: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c Config) Validate() error {
|
||||
if c.SchemaVersion != SchemaVersion {
|
||||
return fmt.Errorf("schema_version must be %d", SchemaVersion)
|
||||
}
|
||||
if !namePattern.MatchString(c.Service.Name) {
|
||||
return errors.New("service.name is invalid")
|
||||
}
|
||||
if c.Service.AllowedHost == "" || strings.ContainsAny(c.Service.AllowedHost, "/\\\x00\r\n\t ") {
|
||||
return errors.New("service.allowed_host is invalid")
|
||||
}
|
||||
if err := safeAbsolute("service.environment_file", c.Service.EnvironmentFile); err != nil {
|
||||
return err
|
||||
}
|
||||
if !within("/etc/tend/environment", c.Service.EnvironmentFile) {
|
||||
return errors.New("service.environment_file must be below /etc/tend/environment")
|
||||
}
|
||||
if !packagePattern.MatchString(c.Build.Package) || strings.Contains(c.Build.Package, "..") {
|
||||
return errors.New("build.package must be a local package without traversal")
|
||||
}
|
||||
if !binaryPattern.MatchString(c.Build.Binary) {
|
||||
return errors.New("build.binary is invalid")
|
||||
}
|
||||
if c.Build.Branch == "" || strings.ContainsAny(c.Build.Branch, "\x00\r\n\t ~^:?*[\\") {
|
||||
return errors.New("build.branch is invalid")
|
||||
}
|
||||
for label, symbol := range map[string]string{
|
||||
"build.version_symbol": c.Build.VersionSymbol,
|
||||
"build.commit_symbol": c.Build.CommitSymbol,
|
||||
"build.date_symbol": c.Build.DateSymbol,
|
||||
} {
|
||||
if symbol != "" && !symbolPattern.MatchString(symbol) {
|
||||
return fmt.Errorf("%s is invalid", label)
|
||||
}
|
||||
}
|
||||
d := c.Deployment
|
||||
if err := safeAbsolute("deployment.root", d.Root); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := safeAbsolute("deployment.lock_file", d.LockFile); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := safeAbsolute("deployment.state_file", d.StateFile); err != nil {
|
||||
return err
|
||||
}
|
||||
if filepath.Clean(d.StateFile) == filepath.Clean(d.Root) || !within(d.Root, d.StateFile) {
|
||||
return errors.New("deployment.state_file must be below deployment.root")
|
||||
}
|
||||
if err := safeAbsolute("deployment.event_log", d.EventLog); err != nil {
|
||||
return err
|
||||
}
|
||||
if filepath.Clean(d.EventLog) == filepath.Clean(d.Root) || !within(d.Root, d.EventLog) || filepath.Clean(d.EventLog) == filepath.Clean(d.StateFile) {
|
||||
return errors.New("deployment.event_log must be a distinct file below deployment.root")
|
||||
}
|
||||
if !safeHTTPPath(d.HealthPath) || !safeHTTPPath(d.ReadinessPath) {
|
||||
return errors.New("health and readiness paths must be absolute HTTP paths")
|
||||
}
|
||||
if d.CandidateTimeoutSecs < 2 || d.CandidateTimeoutSecs > 300 {
|
||||
return errors.New("candidate_timeout_seconds must be between 2 and 300")
|
||||
}
|
||||
if d.ActivationWindowSecs < 1 || d.ActivationWindowSecs > 120 {
|
||||
return errors.New("activation_window_seconds must be between 1 and 120")
|
||||
}
|
||||
if len(d.Smoke) == 0 || len(d.Smoke) > 32 {
|
||||
return errors.New("deployment.smoke must contain 1 to 32 checks")
|
||||
}
|
||||
for i, smoke := range d.Smoke {
|
||||
if !safeHTTPPath(smoke.Path) || smoke.Contains == "" || len(smoke.Contains) > 4096 || strings.ContainsRune(smoke.Contains, '\x00') {
|
||||
return fmt.Errorf("deployment.smoke[%d] is invalid", i)
|
||||
}
|
||||
}
|
||||
if len(d.PublicSmoke) == 0 || len(d.PublicSmoke) > 16 {
|
||||
return errors.New("deployment.public_smoke must contain 1 to 16 checks")
|
||||
}
|
||||
for i, smoke := range d.PublicSmoke {
|
||||
parsed, err := url.Parse(smoke.URL)
|
||||
if err != nil || parsed.Scheme != "https" || parsed.Host == "" || parsed.User != nil || parsed.Fragment != "" || parsed.RawQuery != "" || parsed.Opaque != "" || smoke.Contains == "" || len(smoke.Contains) > 4096 || strings.ContainsRune(smoke.Contains, '\x00') {
|
||||
return fmt.Errorf("deployment.public_smoke[%d] is invalid", i)
|
||||
}
|
||||
}
|
||||
switch d.Strategy {
|
||||
case "blue_green":
|
||||
if d.BlueGreen == nil || d.Singleton != nil {
|
||||
return errors.New("blue_green strategy requires only blue_green settings")
|
||||
}
|
||||
if err := validateBlueGreen(d.Root, *d.BlueGreen); err != nil {
|
||||
return err
|
||||
}
|
||||
case "singleton_candidate":
|
||||
if d.Singleton == nil || d.BlueGreen != nil {
|
||||
return errors.New("singleton_candidate strategy requires only singleton settings")
|
||||
}
|
||||
if err := validateSingleton(d.Root, *d.Singleton); err != nil {
|
||||
return err
|
||||
}
|
||||
default:
|
||||
return errors.New("deployment.strategy must be blue_green or singleton_candidate")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateBlueGreen(root string, b BlueGreen) error {
|
||||
if b.BootstrapActive != "blue" && b.BootstrapActive != "green" {
|
||||
return errors.New("blue_green.bootstrap_active must be blue or green")
|
||||
}
|
||||
for label, path := range map[string]string{"caddy_config": b.CaddyConfig, "caddy_handler": b.CaddyHandler, "caddy_handler_template": b.CaddyHandlerTemplate} {
|
||||
if err := safeAbsolute("deployment.blue_green."+label, path); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if filepath.Clean(b.CaddyHandler) == filepath.Clean(b.CaddyHandlerTemplate) {
|
||||
return errors.New("Caddy handler and template must be different files")
|
||||
}
|
||||
if err := validateSlot(root, "blue", b.Blue); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateSlot(root, "green", b.Green); err != nil {
|
||||
return err
|
||||
}
|
||||
if b.Blue.Address == b.Green.Address || b.Blue.Unit == b.Green.Unit || b.Blue.Link == b.Green.Link {
|
||||
return errors.New("blue and green slots must be distinct")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateSlot(root, name string, slot Slot) error {
|
||||
if !unitPattern.MatchString(slot.Unit) {
|
||||
return fmt.Errorf("%s unit is invalid", name)
|
||||
}
|
||||
if err := loopbackAddress(slot.Address); err != nil {
|
||||
return fmt.Errorf("%s address: %w", name, err)
|
||||
}
|
||||
if err := safeAbsolute(name+" link", slot.Link); err != nil {
|
||||
return err
|
||||
}
|
||||
if !within(root, slot.Link) {
|
||||
return fmt.Errorf("%s link must be below deployment.root", name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateSingleton(root string, s Singleton) error {
|
||||
if !unitPattern.MatchString(s.Unit) {
|
||||
return errors.New("singleton unit is invalid")
|
||||
}
|
||||
if err := loopbackAddress(s.Address); err != nil {
|
||||
return fmt.Errorf("singleton address: %w", err)
|
||||
}
|
||||
if err := loopbackAddress(s.CandidateAddress); err != nil {
|
||||
return fmt.Errorf("candidate address: %w", err)
|
||||
}
|
||||
if s.Address == s.CandidateAddress {
|
||||
return errors.New("singleton addresses must be distinct")
|
||||
}
|
||||
if !regexp.MustCompile(`^[A-Z][A-Z0-9_]{0,63}$`).MatchString(s.ListenEnv) {
|
||||
return errors.New("listen_env is invalid")
|
||||
}
|
||||
for _, entry := range []struct{ name, path string }{{"current_link", s.CurrentLink}, {"previous_link", s.PreviousLink}} {
|
||||
if err := safeAbsolute(entry.name, entry.path); err != nil {
|
||||
return err
|
||||
}
|
||||
if !within(root, entry.path) {
|
||||
return fmt.Errorf("%s must be below deployment.root", entry.name)
|
||||
}
|
||||
}
|
||||
if s.CurrentLink == s.PreviousLink {
|
||||
return errors.New("current and previous links must differ")
|
||||
}
|
||||
for label, path := range map[string]string{"caddy_config": s.CaddyConfig, "caddy_handler": s.CaddyHandler, "caddy_handler_template": s.CaddyHandlerTemplate} {
|
||||
if err := safeAbsolute("deployment.singleton."+label, path); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if filepath.Clean(s.CaddyHandler) == filepath.Clean(s.CaddyHandlerTemplate) {
|
||||
return errors.New("singleton Caddy handler and template must be different files")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func safeAbsolute(label, path string) error {
|
||||
if !filepath.IsAbs(path) || filepath.Clean(path) != path || path == string(filepath.Separator) || strings.ContainsRune(path, '\x00') {
|
||||
return fmt.Errorf("%s must be a clean, non-root absolute path", label)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func within(root, child string) bool {
|
||||
rel, err := filepath.Rel(filepath.Clean(root), filepath.Clean(child))
|
||||
return err == nil && rel != "." && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))
|
||||
}
|
||||
|
||||
func safeHTTPPath(path string) bool {
|
||||
return strings.HasPrefix(path, "/") && !strings.HasPrefix(path, "//") && !strings.ContainsAny(path, "\x00\r\n?#")
|
||||
}
|
||||
|
||||
func loopbackAddress(value string) error {
|
||||
addr, err := netip.ParseAddrPort(value)
|
||||
if err != nil || !addr.Addr().IsLoopback() || addr.Port() == 0 {
|
||||
return errors.New("must be a loopback IP and nonzero port")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user