Files
cthulhu/internal/app/tentacle_upgrade_lifecycle_test.go
2026-11-01 12:21:55 -05:00

331 lines
9.4 KiB
Go

package app
import (
"archive/tar"
"bytes"
"compress/gzip"
"context"
"crypto/sha256"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"reflect"
"runtime"
"strings"
"testing"
"time"
"git.stormux.org/storm/cthulhu/internal/tentacle"
)
func prepare_upgrade_fixture(t *testing.T, mode string) (*tentacleRuntime, *tentacleBindingRecorder, tentacle.Repository, tentacle.Entry) {
t.Helper()
repository, data := install_fixture(t)
set_install_transport(t, func(*http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: 200,
ContentLength: int64(len(data)),
Body: io.NopCloser(bytes.NewReader(data)),
Header: make(http.Header),
}, nil
})
registryPath := filepath.Join(t.TempDir(), "tentacles.toml")
owner, bindings := managed_tentacle_fixture(t, registryPath, false)
owner.fetchRepository = func(context.Context, string) (tentacle.Repository, error) {
return repository, nil
}
if err := owner.Start(context.Background()); err != nil {
t.Fatal(err)
}
if err := owner.Manage(context.Background(), tentacle.ManagementRequest{
Action: "repository-add", URL: repository.URL,
}); err != nil {
t.Fatal(err)
}
if err := owner.Manage(context.Background(), tentacle.ManagementRequest{
Action: "install-enable", ID: "echo", URL: repository.URL,
}); err != nil {
t.Fatal(err)
}
id := tentacle.PackageID(repository, "echo")
wait_managed_state(t, owner, id, "active")
before := owner.Snapshot().Entries[0].Entry
// Rebuild the downloaded archive with a matching version 0.2 manifest.
var updated bytes.Buffer
gzipWriter, err := gzip.NewWriterLevel(&updated, gzip.BestSpeed)
if err != nil {
t.Fatal(err)
}
tarWriter := tar.NewWriter(gzipWriter)
tarReader, err := gzip.NewReader(bytes.NewReader(data))
if err != nil {
t.Fatal(err)
}
reader := tar.NewReader(tarReader)
for {
header, err := reader.Next()
if err == io.EOF {
break
}
if err != nil {
t.Fatal(err)
}
content, err := io.ReadAll(reader)
if err != nil {
t.Fatal(err)
}
if header.Name == "tentacle.toml" {
content = bytes.Replace(content, []byte("version='0.1'"), []byte("version='0.2'"), 1)
if mode == "stall" {
content = bytes.Replace(content, []byte(before.Program[len(before.Program)-1]), []byte(filepath.Join(t.TempDir(), "stall")), 1)
}
}
if header.Name == "bin/echo" && mode == "fail" {
content, err = os.ReadFile("/bin/false")
if err != nil {
t.Fatal(err)
}
}
copyHeader := *header
copyHeader.Size = int64(len(content))
if err := tarWriter.WriteHeader(&copyHeader); err != nil {
t.Fatal(err)
}
if _, err := tarWriter.Write(content); err != nil {
t.Fatal(err)
}
}
if err := tarWriter.Close(); err != nil {
t.Fatal(err)
}
if err := gzipWriter.Close(); err != nil {
t.Fatal(err)
}
if err := tarReader.Close(); err != nil {
t.Fatal(err)
}
updatedData := updated.Bytes()
digest := sha256.Sum256(updatedData)
repository.Revision = strings.Repeat("b", 40)
repository.Tentacles[0].Version = "0.2"
repository.Tentacles[0].Releases = []tentacle.Release{{
Platform: "linux",
Architecture: runtime.GOARCH,
URL: "https://example.org/release-0.2.tar.gz",
SHA256: fmt.Sprintf("%x", digest),
}}
data = updatedData
if err := owner.Manage(context.Background(), tentacle.ManagementRequest{
Action: "repository-refresh", URL: repository.URL,
}); err != nil {
t.Fatal(err)
}
return owner, bindings, repository, before
}
func TestUpgradeReplacesRunningPackageAndCleansOldFiles(t *testing.T) {
owner, _, repository, before := prepare_upgrade_fixture(t, "called")
id := before.ID
if err := owner.Manage(context.Background(), tentacle.ManagementRequest{
Action: "upgrade", ID: "echo", URL: repository.URL,
}); err != nil {
t.Fatal(err)
}
wait_managed_state(t, owner, id, "active")
after := owner.Snapshot().Entries[0].Entry
if !after.Enabled {
t.Fatal("upgrade disabled previously enabled tentacle")
}
if after.PackageDirectory == before.PackageDirectory {
t.Fatal("upgrade reused old package directory")
}
waitTentacle(t, func() bool {
_, err := os.Stat(before.PackageDirectory)
return os.IsNotExist(err)
})
if _, err := os.Stat(after.PackageDirectory); err != nil {
t.Fatalf("replacement package missing: %v", err)
}
}
func TestUpgradeFailureAndCancellationRestorePreviousPackage(t *testing.T) {
for _, operation := range []string{"fail", "cancel", "disable", "remove", "shutdown"} {
t.Run(operation, func(t *testing.T) {
mode := "stall"
if operation == "fail" {
mode = "fail"
}
owner, bindings, repository, before := prepare_upgrade_fixture(t, mode)
ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second)
defer cancel()
result := make(chan error, 1)
go func() {
result <- owner.Manage(ctx, tentacle.ManagementRequest{Action: "upgrade", ID: "echo", URL: repository.URL})
}()
if operation != "fail" {
waitTentacle(t, func() bool {
snapshot := owner.Snapshot()
return len(snapshot.Entries) == 1 && snapshot.Entries[0].Entry.Version == "0.2" && snapshot.Entries[0].State == "starting"
})
if _, err := os.Stat(before.PackageDirectory); err != nil {
t.Fatalf("old package lost during startup: %v", err)
}
switch operation {
case "cancel":
cancel()
case "disable", "remove":
manage_tentacle(t, owner, operation, before.ID, nil)
case "shutdown":
if err := owner.Stop(ctx); err != nil {
t.Fatal(err)
}
}
}
select {
case err := <-result:
if err == nil {
t.Fatal("failed or cancelled update reported success")
}
case <-time.After(10 * time.Second):
t.Fatal("update did not finish")
}
if operation == "fail" || operation == "cancel" {
wait_managed_state(t, owner, before.ID, "active")
owner.Invoke(context.Background(), bindings.TentacleBindings()[0].Command)
waitTentacle(t, func() bool { _, err := os.Stat(before.Program[len(before.Program)-1]); return err == nil })
}
if operation == "disable" {
wait_managed_state(t, owner, before.ID, "disabled")
}
if operation == "shutdown" {
restarted, _ := managed_tentacle_fixture(t, owner.registry.Path, false)
if err := restarted.Start(context.Background()); err != nil {
t.Fatal(err)
}
wait_managed_state(t, restarted, before.ID, "active")
}
disk, err := (&tentacle.Registry{Path: owner.registry.Path}).Load()
if err != nil {
t.Fatal(err)
}
if operation == "remove" {
if len(disk) != 0 {
t.Fatal(disk)
}
} else {
expected := before
if operation == "disable" {
expected.Enabled = false
}
if len(disk) != 1 || !reflect.DeepEqual(disk[0], expected) {
t.Fatalf("previous installation not restored: %+v", disk)
}
if _, err := os.Stat(before.PackageDirectory); err != nil {
t.Fatal(err)
}
}
waitTentacle(t, func() bool {
packages, err := filepath.Glob(filepath.Join(tentacle.PackageRoot(owner.registry.Path), "package-*"))
expected := 1
if operation == "remove" {
expected = 0
}
return err == nil && len(packages) == expected
})
})
}
}
func TestUpgradePreparationCancellationLeavesRunningPackage(t *testing.T) {
owner, _, repository, before := prepare_upgrade_fixture(t, "called")
preparing := make(chan struct{})
owner.preparePackage = func(ctx context.Context, _ string, _ tentacle.Repository, _ string) (*tentacle.PreparedPackage, error) {
close(preparing)
<-ctx.Done()
return nil, ctx.Err()
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
result := make(chan error, 1)
go func() {
result <- owner.Manage(ctx, tentacle.ManagementRequest{Action: "upgrade", ID: "echo", URL: repository.URL})
}()
select {
case <-preparing:
case <-time.After(5 * time.Second):
t.Fatal("preparation did not start")
}
cancel()
select {
case err := <-result:
if err == nil {
t.Fatal("cancelled preparation succeeded")
}
case <-time.After(5 * time.Second):
t.Fatal("preparation did not cancel")
}
status := owner.Snapshot().Entries[0]
if status.State != "active" || !reflect.DeepEqual(status.Entry, before) {
t.Fatalf("cancelled preparation changed running package: %+v", status)
}
}
func TestUpgradeUsesEnablementAtCommit(t *testing.T) {
owner, _, repository, before := prepare_upgrade_fixture(t, "called")
preparing, release := make(chan struct{}), make(chan struct{})
owner.preparePackage = func(ctx context.Context, root string, repository tentacle.Repository, id string) (*tentacle.PreparedPackage, error) {
close(preparing)
select {
case <-release:
case <-ctx.Done():
return nil, ctx.Err()
}
return tentacle.PreparePackage(ctx, root, repository, id)
}
ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second)
defer cancel()
result := make(chan error, 1)
go func() {
result <- owner.Manage(ctx, tentacle.ManagementRequest{Action: "upgrade", ID: "echo", URL: repository.URL})
}()
select {
case <-preparing:
case <-ctx.Done():
t.Fatal("preparation did not start")
}
manage_tentacle(t, owner, "disable", before.ID, nil)
close(release)
select {
case err := <-result:
if err != nil {
t.Fatal(err)
}
case <-ctx.Done():
t.Fatal("upgrade did not finish")
}
wait_managed_state(t, owner, before.ID, "disabled")
status := owner.Snapshot().Entries[0]
if status.Entry.Version != "0.2" || status.Entry.Enabled {
t.Fatal(status)
}
waitTentacle(t, func() bool { _, err := os.Stat(before.PackageDirectory); return os.IsNotExist(err) })
}