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(©Header); 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) }) }