Files
cthulhu/internal/app/tentacle_install_test.go

214 lines
8.0 KiB
Go

// Copyright (c) 2026 Storm Dragon
// SPDX-License-Identifier: GPL-3.0-or-later
package app
import (
"archive/tar"
"bytes"
"compress/gzip"
"context"
"crypto/sha256"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
"git.stormux.org/storm/cthulhu/internal/tentacle"
)
type installTransport func(*http.Request) (*http.Response, error)
func (transport installTransport) RoundTrip(request *http.Request) (*http.Response, error) {
return transport(request)
}
func install_fixture(t *testing.T) (tentacle.Repository, []byte) {
t.Helper()
program, err := os.Executable()
if err != nil {
t.Fatal(err)
}
binary, err := os.ReadFile(program)
if err != nil {
t.Fatal(err)
}
marker := filepath.Join(t.TempDir(), "called")
manifest := fmt.Sprintf("format-version=1\nid='echo'\nname='Echo'\nversion='0.1'\nruntime='native'\nplatforms=['linux']\nentry-point=['bin/echo','-test.run=^TestTentacleReaderPeer$','--','reader-peer',%q]\napis=['commands']\ncapabilities=[]\n[protocol]\nmajor=1\nminor=0\n", marker)
var data bytes.Buffer
compressed := gzip.NewWriter(&data)
archive := tar.NewWriter(compressed)
for _, item := range []struct {
name string
data []byte
mode int64
}{{"tentacle.toml", []byte(manifest), 0644}, {"bin/echo", binary, 0755}} {
if err := archive.WriteHeader(&tar.Header{Name: item.name, Size: int64(len(item.data)), Mode: item.mode}); err != nil {
t.Fatal(err)
}
if _, err := archive.Write(item.data); err != nil {
t.Fatal(err)
}
}
if err := archive.Close(); err != nil {
t.Fatal(err)
}
if err := compressed.Close(); err != nil {
t.Fatal(err)
}
digest := sha256.Sum256(data.Bytes())
repository := tentacle.Repository{URL: "https://example.org/team/tentacles", ID: "example.tentacles", Name: "Example", Revision: strings.Repeat("a", 40), Tentacles: []tentacle.Manifest{{FormatVersion: 1, ID: "echo", Name: "Echo", Version: "0.1", Releases: []tentacle.Release{{Platform: "linux", Architecture: runtime.GOARCH, URL: "https://example.org/release.tar.gz", SHA256: fmt.Sprintf("%x", digest)}}}}}
return repository, data.Bytes()
}
func set_install_transport(t *testing.T, transport installTransport) {
t.Helper()
previous := http.DefaultTransport
http.DefaultTransport = transport
t.Cleanup(func() { http.DefaultTransport = previous })
}
func TestRepositoryPackageInstallEnableDisableRemoveAndOfflineRestart(t *testing.T) {
test_repository_package_install(t, false)
}
func TestNativeRepositoryPackageInstall(t *testing.T) {
if os.Getenv("CTHULHU_TENTACLE_MANAGED") != "1" {
t.Skip("requires a systemd user manager and cgroup v2")
}
test_repository_package_install(t, true)
}
func test_repository_package_install(t *testing.T, contained bool) {
repository, data := install_fixture(t)
downloads := 0
set_install_transport(t, func(*http.Request) (*http.Response, error) {
downloads++
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, contained)
if binary := os.Getenv("CTHULHU_TENTACLE_GUARDIAN_BINARY"); contained && binary != "" {
owner.guardianProgram = []string{binary, "--tentacle-guardian"}
}
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", ID: "echo", URL: repository.URL}); err != nil {
t.Fatal(err)
}
id := tentacle.PackageID(repository, "echo")
wait_managed_state(t, owner, id, "disabled")
entry := owner.Snapshot().Entries[0].Entry
if entry.Enabled || downloads != 1 {
t.Fatal(entry, downloads)
}
if err := owner.Manage(context.Background(), tentacle.ManagementRequest{Action: "install-enable", ID: "echo", URL: repository.URL}); err == nil {
t.Fatal("duplicate install accepted")
}
manage_tentacle(t, owner, "enable", id, nil)
wait_managed_state(t, owner, id, "active")
owner.Invoke(context.Background(), bindings.TentacleBindings()[0].Command)
waitTentacle(t, func() bool { _, err := os.Stat(entry.Program[len(entry.Program)-1]); return err == nil })
manage_tentacle(t, owner, "disable", id, nil)
wait_managed_state(t, owner, id, "disabled")
if _, err := os.Stat(entry.Program[0]); err != nil {
t.Fatal("disable deleted package", err)
}
manage_tentacle(t, owner, "enable", id, nil)
wait_managed_state(t, owner, id, "active")
shutdown, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := owner.Stop(shutdown); err != nil {
t.Fatal(err)
}
restarted, _ := managed_tentacle_fixture(t, registryPath, contained)
if binary := os.Getenv("CTHULHU_TENTACLE_GUARDIAN_BINARY"); contained && binary != "" {
restarted.guardianProgram = []string{binary, "--tentacle-guardian"}
}
restarted.fetchRepository = func(context.Context, string) (tentacle.Repository, error) {
t.Error("restart fetched catalog")
return repository, nil
}
if err := restarted.Start(context.Background()); err != nil {
t.Fatal(err)
}
wait_managed_state(t, restarted, id, "active")
if downloads != 1 {
t.Fatal("enable/restart downloaded update", downloads)
}
if err := restarted.Manage(context.Background(), tentacle.ManagementRequest{Action: "repository-remove", URL: repository.URL}); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(entry.Program[0]); err != nil {
t.Fatal("source removal deleted package", err)
}
manage_tentacle(t, restarted, "remove", id, nil)
waitTentacle(t, func() bool { _, err := os.Stat(entry.PackageDirectory); return os.IsNotExist(err) })
disk, err := (&tentacle.Registry{Path: registryPath}).Load()
if err != nil || len(disk) != 0 {
t.Fatal(disk, err)
}
}
func TestRepositoryPackageInstallFailureCancellationAndChangedCatalog(t *testing.T) {
repository, data := install_fixture(t)
for _, problem := range []string{"save failure", "cancel during download", "catalog changed", "emergency disable"} {
t.Run(problem, func(t *testing.T) {
registryPath := filepath.Join(t.TempDir(), "tentacles.toml")
registry := &tentacle.Registry{Path: registryPath}
if _, err := registry.Load(); err != nil {
t.Fatal(err)
}
if err := registry.SaveState(nil, []tentacle.Repository{repository}); err != nil {
t.Fatal(err)
}
owner, _ := managed_tentacle_fixture(t, registryPath, false)
if err := owner.Start(context.Background()); err != nil {
t.Fatal(err)
}
waitTentacle(t, func() bool { return len(owner.Snapshot().Repositories) == 1 })
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
set_install_transport(t, func(request *http.Request) (*http.Response, error) {
switch problem {
case "save failure":
if err := os.WriteFile(registryPath, []byte("# external edit\n"), 0600); err != nil {
t.Fatal(err)
}
case "cancel during download":
cancel()
case "catalog changed":
if err := owner.Manage(context.Background(), tentacle.ManagementRequest{Action: "repository-remove", URL: repository.URL}); err != nil {
t.Fatal(err)
}
case "emergency disable":
owner.Disable()
}
return &http.Response{StatusCode: 200, Body: io.NopCloser(bytes.NewReader(data)), Header: make(http.Header)}, nil
})
if err := owner.Manage(ctx, tentacle.ManagementRequest{Action: "install-enable", ID: "echo", URL: repository.URL}); err == nil {
t.Fatal("failed installation accepted")
}
if len(owner.Snapshot().Entries) != 0 {
t.Fatal("failed installation registered entry")
}
files, err := os.ReadDir(tentacle.PackageRoot(registryPath))
if !os.IsNotExist(err) && err != nil {
t.Fatal(err)
}
if len(files) != 0 {
t.Fatal("failed installation left files", files)
}
})
}
}