feat: publish Tend v0.2 preview source
Publish the reviewed allowlisted snapshot whose exact binary completed maintenance deployment, rollback, and reactivation exercises for Gamertan and Sandwich Hime. Private-Source-Commit: 4d7094c8b7c61991bfb67b11fc1558724c874eb2 Private-Source-Tree: 54a2f74804f7acddf3755d7d4da5b97f5fc28381 AI-Assistance: OpenAI Codex assisted implementation, testing, security review, and release verification. Signed-off-by: Cole Speelman <crspeelman@gmail.com>
This commit is contained in:
+31
-10
@@ -177,6 +177,9 @@ func (m Manager) deployBlueGreen(ctx context.Context, cfg config.Config, record
|
||||
if err = m.probeAll(ctx, cfg, slot.Address); err != nil {
|
||||
return fmt.Errorf("post-activation smoke failed: %w", err)
|
||||
}
|
||||
if err = m.probePublic(ctx, cfg, true); err != nil {
|
||||
return fmt.Errorf("public-origin smoke failed: %w", err)
|
||||
}
|
||||
next := state.Record{SchemaVersion: 1, Strategy: cfg.Deployment.Strategy, ActiveSlot: inactive, ActiveRelease: release, PreviousSlot: record.ActiveSlot, PreviousRelease: record.ActiveRelease, UpdatedAt: m.Now().UTC().Format(time.RFC3339)}
|
||||
if err = state.Store(cfg.Deployment.StateFile, cfg.Deployment.Root, next); err != nil {
|
||||
return err
|
||||
@@ -187,10 +190,9 @@ func (m Manager) deployBlueGreen(ctx context.Context, cfg config.Config, record
|
||||
func (m Manager) deploySingleton(ctx context.Context, cfg config.Config, record state.Record, release string) (err error) {
|
||||
single := *cfg.Deployment.Singleton
|
||||
candidateUnit := cfg.Service.Name + "-tend-candidate.service"
|
||||
env := copyMap(single.Environment)
|
||||
env[single.ListenEnv] = single.CandidateAddress
|
||||
env := map[string]string{single.ListenEnv: single.CandidateAddress}
|
||||
binary := filepath.Join(release, cfg.Build.Binary)
|
||||
if err = m.Operator.StartCandidate(ctx, candidateUnit, binary, env); err != nil {
|
||||
if err = m.Operator.StartCandidate(ctx, candidateUnit, binary, cfg.Service.EnvironmentFile, env); err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() {
|
||||
@@ -238,6 +240,9 @@ func (m Manager) deploySingleton(ctx context.Context, cfg config.Config, record
|
||||
if err = m.probeAll(ctx, cfg, single.Address); err != nil {
|
||||
return fmt.Errorf("post-activation smoke failed: %w", err)
|
||||
}
|
||||
if err = m.probePublic(ctx, cfg, true); err != nil {
|
||||
return fmt.Errorf("public-origin smoke failed: %w", err)
|
||||
}
|
||||
next := state.Record{SchemaVersion: 1, Strategy: cfg.Deployment.Strategy, ActiveSlot: "singleton", ActiveRelease: release, PreviousSlot: "singleton", PreviousRelease: record.ActiveRelease, UpdatedAt: m.Now().UTC().Format(time.RFC3339)}
|
||||
if err = state.Store(cfg.Deployment.StateFile, cfg.Deployment.Root, next); err != nil {
|
||||
return err
|
||||
@@ -313,6 +318,9 @@ func (m Manager) rollbackBlueGreen(ctx context.Context, cfg config.Config, recor
|
||||
if err = m.probeHealthReadiness(ctx, cfg, slot.Address); err != nil {
|
||||
return err
|
||||
}
|
||||
if err = m.probePublic(ctx, cfg, false); err != nil {
|
||||
return err
|
||||
}
|
||||
next := state.Record{SchemaVersion: 1, Strategy: record.Strategy, ActiveSlot: record.PreviousSlot, ActiveRelease: record.PreviousRelease, PreviousSlot: record.ActiveSlot, PreviousRelease: record.ActiveRelease, UpdatedAt: m.Now().UTC().Format(time.RFC3339)}
|
||||
return state.Store(cfg.Deployment.StateFile, cfg.Deployment.Root, next)
|
||||
}
|
||||
@@ -337,6 +345,9 @@ func (m Manager) rollbackSingleton(ctx context.Context, cfg config.Config, recor
|
||||
if err = m.probeHealthReadiness(ctx, cfg, single.Address); err != nil {
|
||||
return err
|
||||
}
|
||||
if err = m.probePublic(ctx, cfg, false); err != nil {
|
||||
return err
|
||||
}
|
||||
_ = replaceSymlink(single.PreviousLink, record.ActiveRelease)
|
||||
next := state.Record{SchemaVersion: 1, Strategy: record.Strategy, ActiveSlot: "singleton", ActiveRelease: record.PreviousRelease, PreviousSlot: "singleton", PreviousRelease: record.ActiveRelease, UpdatedAt: m.Now().UTC().Format(time.RFC3339)}
|
||||
return state.Store(cfg.Deployment.StateFile, cfg.Deployment.Root, next)
|
||||
@@ -435,6 +446,23 @@ func (m Manager) probeHealthReadiness(ctx context.Context, cfg config.Config, ad
|
||||
return m.probe(ctx, cfg, address, checks)
|
||||
}
|
||||
|
||||
func (m Manager) probePublic(ctx context.Context, cfg config.Config, checkMarkers bool) error {
|
||||
timeout := time.Duration(cfg.Deployment.CandidateTimeoutSecs) * time.Second
|
||||
for _, check := range cfg.Deployment.PublicSmoke {
|
||||
attempt, cancel := context.WithTimeout(ctx, timeout)
|
||||
contains := check.Contains
|
||||
if !checkMarkers {
|
||||
contains = ""
|
||||
}
|
||||
err := m.Operator.ProbeURL(attempt, check.URL, contains)
|
||||
cancel()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m Manager) probe(ctx context.Context, cfg config.Config, address string, checks []config.Smoke) error {
|
||||
timeout := time.Duration(cfg.Deployment.CandidateTimeoutSecs) * time.Second
|
||||
for _, check := range checks {
|
||||
@@ -465,13 +493,6 @@ func slotConfig(bg config.BlueGreen, name string) config.Slot {
|
||||
}
|
||||
return bg.Green
|
||||
}
|
||||
func copyMap(source map[string]string) map[string]string {
|
||||
target := make(map[string]string, len(source)+1)
|
||||
for k, v := range source {
|
||||
target[k] = v
|
||||
}
|
||||
return target
|
||||
}
|
||||
func resolveReleaseLink(root, link string) (string, error) {
|
||||
info, err := os.Lstat(link)
|
||||
if err != nil {
|
||||
|
||||
@@ -22,6 +22,10 @@ type fakeOperator struct {
|
||||
active map[string]bool
|
||||
starts, stops, restarts []string
|
||||
probes []string
|
||||
publicProbes []string
|
||||
failPublic bool
|
||||
candidateEnvironment map[string]string
|
||||
candidateFile string
|
||||
}
|
||||
|
||||
func (f *fakeOperator) Restart(_ context.Context, unit string) error {
|
||||
@@ -40,14 +44,58 @@ func (f *fakeOperator) Stop(_ context.Context, unit string) error {
|
||||
func (f *fakeOperator) IsActive(_ context.Context, unit string) (bool, error) {
|
||||
return f.active[unit], nil
|
||||
}
|
||||
func (f *fakeOperator) StartCandidate(_ context.Context, unit, binary string, env map[string]string) error {
|
||||
if !filepath.IsAbs(binary) || len(env) == 0 {
|
||||
func (f *fakeOperator) StartCandidate(_ context.Context, unit, binary, environmentFile string, env map[string]string) error {
|
||||
if !filepath.IsAbs(binary) || !filepath.IsAbs(environmentFile) || len(env) == 0 {
|
||||
return errors.New("bad candidate")
|
||||
}
|
||||
f.starts = append(f.starts, unit)
|
||||
f.candidateFile = environmentFile
|
||||
f.candidateEnvironment = make(map[string]string, len(env))
|
||||
for key, value := range env {
|
||||
f.candidateEnvironment[key] = value
|
||||
}
|
||||
f.active[unit] = true
|
||||
return nil
|
||||
}
|
||||
func (f *fakeOperator) ProbeURL(_ context.Context, value, contains string) error {
|
||||
f.publicProbes = append(f.publicProbes, value)
|
||||
if f.failPublic {
|
||||
return errors.New("injected public smoke failure")
|
||||
}
|
||||
if f.rejectMarkers && contains != "" {
|
||||
return errors.New("unexpected future-release smoke marker")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestPublicSmokeFailureRestoresBlueGreenHandlerAndSlot(t *testing.T) {
|
||||
cfg, old, fresh := baseConfig(t, "blue_green")
|
||||
handler := filepath.Join(cfg.Deployment.Root, "handler.caddy")
|
||||
template := filepath.Join(cfg.Deployment.Root, "handler.template")
|
||||
original := []byte("reverse_proxy 127.0.0.1:8090\n")
|
||||
_ = os.WriteFile(handler, original, 0o644)
|
||||
_ = os.WriteFile(template, []byte("reverse_proxy {{UPSTREAM}}\n"), 0o644)
|
||||
blue := filepath.Join(cfg.Deployment.Root, "slots", "blue")
|
||||
green := filepath.Join(cfg.Deployment.Root, "slots", "green")
|
||||
_ = replaceSymlink(blue, old)
|
||||
_ = replaceSymlink(green, old)
|
||||
cfg.Deployment.BlueGreen = &config.BlueGreen{CaddyConfig: filepath.Join(cfg.Deployment.Root, "Caddyfile"), CaddyHandler: handler, CaddyHandlerTemplate: template, BootstrapActive: "blue", Blue: config.Slot{Unit: "example-blue.service", Address: "127.0.0.1:8090", Link: blue}, Green: config.Slot{Unit: "example-green.service", Address: "127.0.0.1:8091", Link: green}}
|
||||
operator := &fakeOperator{active: map[string]bool{}, failPublic: true}
|
||||
if _, err := manager(operator, fresh).Deploy(context.Background(), cfg, Request{Activate: true}); err == nil {
|
||||
t.Fatal("expected public smoke failure")
|
||||
}
|
||||
body, _ := os.ReadFile(handler)
|
||||
if string(body) != string(original) {
|
||||
t.Fatalf("handler not restored: %q", body)
|
||||
}
|
||||
target, err := resolveReleaseLink(cfg.Deployment.Root, green)
|
||||
if err != nil || target != old {
|
||||
t.Fatalf("green=%q err=%v", target, err)
|
||||
}
|
||||
if _, err = os.Stat(cfg.Deployment.StateFile); !os.IsNotExist(err) {
|
||||
t.Fatal("failed public smoke wrote state")
|
||||
}
|
||||
}
|
||||
func (f *fakeOperator) ValidateCaddy(context.Context, string) error { return nil }
|
||||
func (f *fakeOperator) ReloadCaddy(context.Context) error {
|
||||
if f.failReload {
|
||||
@@ -79,7 +127,7 @@ func baseConfig(t *testing.T, strategy string) (config.Config, string, string) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
cfg := config.Config{SchemaVersion: 1, Service: config.Service{Name: "example-site", AllowedHost: "example.test"}, Build: config.Build{Package: "./cmd/site", Binary: "app", Branch: "main"}, Deployment: config.Deployment{Strategy: strategy, Root: root, LockFile: filepath.Join(root, "deploy.lock"), StateFile: filepath.Join(root, "state.json"), HealthPath: "/healthz", ReadinessPath: "/readyz", CandidateTimeoutSecs: 2, Smoke: []config.Smoke{{Path: "/", Contains: "Example"}}}}
|
||||
cfg := config.Config{SchemaVersion: 2, Service: config.Service{Name: "example-site", AllowedHost: "example.test", EnvironmentFile: "/etc/tend/environment/example-site.env"}, Build: config.Build{Package: "./cmd/site", Binary: "app", Branch: "main"}, Deployment: config.Deployment{Strategy: strategy, Root: root, LockFile: filepath.Join(root, "deploy.lock"), StateFile: filepath.Join(root, "state.json"), HealthPath: "/healthz", ReadinessPath: "/readyz", CandidateTimeoutSecs: 2, Smoke: []config.Smoke{{Path: "/", Contains: "Example"}}, PublicSmoke: []config.PublicSmoke{{URL: "https://example.test/", Contains: "Example"}}}}
|
||||
return cfg, old, fresh
|
||||
}
|
||||
func manager(operator Operator, fresh string) Manager {
|
||||
@@ -210,4 +258,7 @@ func TestStatePersistsOnlyAfterSuccessfulActivation(t *testing.T) {
|
||||
if record.ActiveRelease != fresh || record.PreviousRelease != old {
|
||||
t.Fatalf("state=%+v", record)
|
||||
}
|
||||
if operator.candidateFile != cfg.Service.EnvironmentFile || len(operator.candidateEnvironment) != 1 || operator.candidateEnvironment["EXAMPLE_LISTEN"] != "127.0.0.1:18092" {
|
||||
t.Fatalf("candidate file=%q environment=%#v", operator.candidateFile, operator.candidateEnvironment)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
//go:build linux
|
||||
|
||||
package deploy
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestHostWideLockSerializesIndependentServices(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "tend-deploy.lock")
|
||||
first, err := acquireLock(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer first.Close()
|
||||
if second, err := acquireLock(path); err == nil {
|
||||
_ = second.Close()
|
||||
t.Fatal("second service acquired the shared activation lock")
|
||||
}
|
||||
if err = first.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
third, err := acquireLock(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = third.Close()
|
||||
}
|
||||
@@ -20,10 +20,11 @@ type Operator interface {
|
||||
Restart(context.Context, string) error
|
||||
Stop(context.Context, string) error
|
||||
IsActive(context.Context, string) (bool, error)
|
||||
StartCandidate(context.Context, string, string, map[string]string) error
|
||||
StartCandidate(context.Context, string, string, string, map[string]string) error
|
||||
ValidateCaddy(context.Context, string) error
|
||||
ReloadCaddy(context.Context) error
|
||||
Probe(context.Context, string, string, string, string) error
|
||||
ProbeURL(context.Context, string, string) error
|
||||
}
|
||||
|
||||
type SystemOperator struct {
|
||||
@@ -49,7 +50,7 @@ func (o SystemOperator) IsActive(ctx context.Context, unit string) (bool, error)
|
||||
}
|
||||
return strings.TrimSpace(string(out)) == "active", nil
|
||||
}
|
||||
func (o SystemOperator) StartCandidate(ctx context.Context, unit, binary string, env map[string]string) error {
|
||||
func (o SystemOperator) StartCandidate(ctx context.Context, unit, binary, environmentFile string, env map[string]string) error {
|
||||
args := []string{
|
||||
"--unit", unit, "--collect",
|
||||
"--property=DynamicUser=yes", "--property=NoNewPrivileges=yes",
|
||||
@@ -63,6 +64,7 @@ func (o SystemOperator) StartCandidate(ctx context.Context, unit, binary string,
|
||||
"--property=RestrictSUIDSGID=yes", "--property=LockPersonality=yes",
|
||||
"--property=MemoryDenyWriteExecute=yes", "--property=CapabilityBoundingSet=",
|
||||
"--property=AmbientCapabilities=",
|
||||
"--property=EnvironmentFile=" + environmentFile,
|
||||
}
|
||||
keys := make([]string, 0, len(env))
|
||||
for key := range env {
|
||||
@@ -86,11 +88,23 @@ func (o SystemOperator) ReloadCaddy(ctx context.Context) error {
|
||||
}
|
||||
func (o SystemOperator) Probe(ctx context.Context, address, host, path, contains string) error {
|
||||
u := url.URL{Scheme: "http", Host: address, Path: path}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
|
||||
return o.probeRequest(ctx, u.String(), host, contains)
|
||||
}
|
||||
func (o SystemOperator) ProbeURL(ctx context.Context, value, contains string) error {
|
||||
u, err := url.Parse(value)
|
||||
if err != nil || u.Scheme != "https" || u.Host == "" || u.User != nil || u.Fragment != "" {
|
||||
return errors.New("public probe URL is invalid")
|
||||
}
|
||||
return o.probeRequest(ctx, u.String(), "", contains)
|
||||
}
|
||||
func (o SystemOperator) probeRequest(ctx context.Context, value, host, contains string) error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, value, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Host = host
|
||||
if host != "" {
|
||||
req.Host = host
|
||||
}
|
||||
client := &http.Client{Timeout: o.Timeout, CheckRedirect: func(*http.Request, []*http.Request) error { return errors.New("redirect refused") }}
|
||||
response, err := client.Do(req)
|
||||
if err != nil {
|
||||
|
||||
@@ -5,6 +5,7 @@ package deploy
|
||||
import (
|
||||
"context"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -23,13 +24,13 @@ func TestStartCandidateUsesArgumentVectorAndHardenedUnit(t *testing.T) {
|
||||
runner := &recordingRunner{}
|
||||
operator := SystemOperator{Runner: runner}
|
||||
env := map[string]string{"Z_ENV": "safe value", "A_ENV": "first"}
|
||||
if err := operator.StartCandidate(context.Background(), "example-tend-candidate.service", "/opt/example/releases/sha256-a/app", env); err != nil {
|
||||
if err := operator.StartCandidate(context.Background(), "example-tend-candidate.service", "/opt/example/releases/sha256-a/app", "/etc/tend/environment/example.env", env); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if runner.name != "systemd-run" {
|
||||
t.Fatalf("command=%q", runner.name)
|
||||
}
|
||||
required := []string{"--property=DynamicUser=yes", "--property=NoNewPrivileges=yes", "--property=ProtectSystem=strict", "--property=MemoryDenyWriteExecute=yes", "--property=CapabilityBoundingSet=", "--setenv", "A_ENV=first", "--setenv", "Z_ENV=safe value", "--", "/opt/example/releases/sha256-a/app"}
|
||||
required := []string{"--property=DynamicUser=yes", "--property=NoNewPrivileges=yes", "--property=ProtectSystem=strict", "--property=MemoryDenyWriteExecute=yes", "--property=CapabilityBoundingSet=", "--property=EnvironmentFile=/etc/tend/environment/example.env", "--setenv", "A_ENV=first", "--setenv", "Z_ENV=safe value", "--", "/opt/example/releases/sha256-a/app"}
|
||||
cursor := 0
|
||||
for _, arg := range runner.args {
|
||||
if cursor < len(required) && arg == required[cursor] {
|
||||
@@ -42,4 +43,7 @@ func TestStartCandidateUsesArgumentVectorAndHardenedUnit(t *testing.T) {
|
||||
if reflect.DeepEqual(runner.args, []string{"sh", "-c"}) {
|
||||
t.Fatal("candidate command used a shell")
|
||||
}
|
||||
if strings.Contains(strings.Join(runner.args, "\n"), "SUPER_SECRET") {
|
||||
t.Fatal("candidate arguments exposed a secret value")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user