iop/packages/go/hostsetup/setup_test.go

393 lines
10 KiB
Go

package hostsetup
import (
"bytes"
"context"
"os"
"path/filepath"
"strings"
"testing"
)
type fakeRunner struct {
calls [][]string
missing map[string]bool // name+arg0 lookups that should return error to simulate absence
failures map[string]error
}
func newFakeRunner() *fakeRunner {
return &fakeRunner{
missing: map[string]bool{},
failures: map[string]error{},
}
}
func (f *fakeRunner) Run(_ context.Context, name string, args ...string) ([]byte, error) {
rec := append([]string{name}, args...)
f.calls = append(f.calls, rec)
key := strings.Join(rec, " ")
if err, ok := f.failures[key]; ok {
return nil, err
}
// getent/id lookups: return error to simulate "not found" if marked missing
if name == "getent" || name == "id" {
if f.missing[key] {
return nil, &exitErr{}
}
}
return nil, nil
}
type exitErr struct{}
func (exitErr) Error() string { return "not found" }
func (f *fakeRunner) called(name string, args ...string) bool {
want := append([]string{name}, args...)
for _, c := range f.calls {
if len(c) != len(want) {
continue
}
match := true
for i := range c {
if c[i] != want[i] {
match = false
break
}
}
if match {
return true
}
}
return false
}
func testSpec(dir string) AppSpec {
return AppSpec{
Name: "iop-test",
Description: "iop test",
DefaultConfig: filepath.Join(dir, "etc", "iop", "test.yaml"),
DefaultDataDir: filepath.Join(dir, "var", "lib", "iop"),
DefaultUnit: filepath.Join(dir, "etc", "systemd", "system", "iop-test.service"),
ConfigTemplate: "key: value\n",
}
}
func baseOpts(runner CommandRunner) SetupOptions {
return SetupOptions{
BinaryPath: "/usr/local/bin/iop-test",
Enable: true,
Start: true,
Runner: runner,
Getuid: func() int { return 0 },
}
}
func TestRunDryRunDoesNotWriteFiles(t *testing.T) {
dir := t.TempDir()
spec := testSpec(dir)
runner := newFakeRunner()
opts := baseOpts(runner)
opts.DryRun = true
var out bytes.Buffer
if err := Run(context.Background(), spec, opts, &out); err != nil {
t.Fatalf("Run: %v", err)
}
if _, err := os.Stat(spec.DefaultConfig); !os.IsNotExist(err) {
t.Fatalf("config should not exist after dry-run: err=%v", err)
}
if _, err := os.Stat(spec.DefaultUnit); !os.IsNotExist(err) {
t.Fatalf("unit should not exist after dry-run: err=%v", err)
}
if len(runner.calls) != 0 {
t.Fatalf("dry-run should not invoke runner, got %v", runner.calls)
}
if !strings.Contains(out.String(), "unit preview") {
t.Fatalf("dry-run output missing unit preview: %s", out.String())
}
if !strings.Contains(out.String(), "config preview") {
t.Fatalf("dry-run output missing config preview: %s", out.String())
}
}
func TestRunWritesMissingConfigAndUnit(t *testing.T) {
dir := t.TempDir()
spec := testSpec(dir)
runner := newFakeRunner()
// user/group do not exist yet
runner.missing["getent group iop"] = true
runner.missing["id -u iop"] = true
opts := baseOpts(runner)
var out bytes.Buffer
if err := Run(context.Background(), spec, opts, &out); err != nil {
t.Fatalf("Run: %v", err)
}
cfg, err := os.ReadFile(spec.DefaultConfig)
if err != nil {
t.Fatalf("read config: %v", err)
}
if string(cfg) != spec.ConfigTemplate {
t.Fatalf("config content mismatch: %q", cfg)
}
info, err := os.Stat(spec.DefaultConfig)
if err != nil {
t.Fatalf("stat config: %v", err)
}
if info.Mode().Perm() != 0o600 {
t.Fatalf("config mode: want 0600 got %o", info.Mode().Perm())
}
unit, err := os.ReadFile(spec.DefaultUnit)
if err != nil {
t.Fatalf("read unit: %v", err)
}
if !strings.Contains(string(unit), "ExecStart=/usr/local/bin/iop-test serve --config "+spec.DefaultConfig) {
t.Fatalf("unit ExecStart wrong: %s", unit)
}
uinfo, err := os.Stat(spec.DefaultUnit)
if err != nil {
t.Fatalf("stat unit: %v", err)
}
if uinfo.Mode().Perm() != 0o644 {
t.Fatalf("unit mode: want 0644 got %o", uinfo.Mode().Perm())
}
if _, err := os.Stat(spec.DefaultDataDir); err != nil {
t.Fatalf("data dir missing: %v", err)
}
if !runner.called("groupadd", "--system", "iop") {
t.Fatalf("groupadd not called: %v", runner.calls)
}
if !runner.called("useradd", "--system", "--gid", "iop", "--home-dir", spec.DefaultDataDir, "--shell", "/usr/sbin/nologin", "iop") {
t.Fatalf("useradd not called: %v", runner.calls)
}
if !runner.called("chown", "-R", "iop:iop", spec.DefaultDataDir) {
t.Fatalf("chown data dir not called: %v", runner.calls)
}
if !runner.called("chown", "iop:iop", spec.DefaultConfig) {
t.Fatalf("chown config not called: %v", runner.calls)
}
if !runner.called("systemctl", "daemon-reload") {
t.Fatalf("daemon-reload not called")
}
if !runner.called("systemctl", "enable", "iop-test") {
t.Fatalf("enable not called")
}
if !runner.called("systemctl", "start", "iop-test") {
t.Fatalf("start not called")
}
}
func TestRunDoesNotOverwriteExistingConfig(t *testing.T) {
dir := t.TempDir()
spec := testSpec(dir)
runner := newFakeRunner()
opts := baseOpts(runner)
if err := os.MkdirAll(filepath.Dir(spec.DefaultConfig), 0o755); err != nil {
t.Fatal(err)
}
existing := "preserved: true\n"
if err := os.WriteFile(spec.DefaultConfig, []byte(existing), 0o600); err != nil {
t.Fatal(err)
}
var out bytes.Buffer
if err := Run(context.Background(), spec, opts, &out); err != nil {
t.Fatalf("Run: %v", err)
}
got, err := os.ReadFile(spec.DefaultConfig)
if err != nil {
t.Fatal(err)
}
if string(got) != existing {
t.Fatalf("config was overwritten: %q", got)
}
// With OverwriteConfig=true, it should overwrite.
opts.OverwriteConfig = true
if err := Run(context.Background(), spec, opts, &out); err != nil {
t.Fatalf("Run overwrite: %v", err)
}
got, err = os.ReadFile(spec.DefaultConfig)
if err != nil {
t.Fatal(err)
}
if string(got) != spec.ConfigTemplate {
t.Fatalf("overwrite did not apply: %q", got)
}
}
func TestRunCreatesTraversableParentForNestedDataDir(t *testing.T) {
dir := t.TempDir()
spec := testSpec(dir)
// Mimic real Edge/Node layout: leaf nested under a shared parent.
spec.DefaultDataDir = filepath.Join(dir, "var", "lib", "iop", "edge")
runner := newFakeRunner()
runner.missing["getent group iop"] = true
runner.missing["id -u iop"] = true
opts := baseOpts(runner)
var out bytes.Buffer
if err := Run(context.Background(), spec, opts, &out); err != nil {
t.Fatalf("Run: %v", err)
}
parent := filepath.Join(dir, "var", "lib", "iop")
info, err := os.Stat(parent)
if err != nil {
t.Fatalf("stat parent: %v", err)
}
// World-traversable (at least the +x bit for other) so the iop service user
// can traverse the shared parent into its leaf data dir.
if info.Mode().Perm()&0o001 == 0 {
t.Fatalf("parent %s not traversable by others: mode=%o", parent, info.Mode().Perm())
}
leaf, err := os.Stat(spec.DefaultDataDir)
if err != nil {
t.Fatalf("stat leaf: %v", err)
}
if leaf.Mode().Perm() != 0o750 {
t.Fatalf("leaf data dir mode: want 0750 got %o", leaf.Mode().Perm())
}
if !runner.called("chown", "-R", "iop:iop", spec.DefaultDataDir) {
t.Fatalf("chown -R should target leaf data dir, calls=%v", runner.calls)
}
}
func TestRunFixesExistingNonTraversableParent(t *testing.T) {
dir := t.TempDir()
spec := testSpec(dir)
spec.DefaultDataDir = filepath.Join(dir, "var", "lib", "iop", "edge")
parent := filepath.Join(dir, "var", "lib", "iop")
if err := os.MkdirAll(parent, 0o700); err != nil {
t.Fatal(err)
}
if err := os.Chmod(parent, 0o750); err != nil {
t.Fatal(err)
}
runner := newFakeRunner()
runner.missing["getent group iop"] = true
runner.missing["id -u iop"] = true
opts := baseOpts(runner)
if err := Run(context.Background(), spec, opts, &bytes.Buffer{}); err != nil {
t.Fatalf("Run: %v", err)
}
info, err := os.Stat(parent)
if err != nil {
t.Fatalf("stat parent: %v", err)
}
if info.Mode().Perm()&0o001 == 0 {
t.Fatalf("parent %s still not traversable: mode=%o", parent, info.Mode().Perm())
}
}
func TestRunRejectsUnsafeDataDir(t *testing.T) {
for _, bad := range []string{"/", "/var", "/var/lib", "/etc", "/usr", "/usr/lib", "/var/log"} {
t.Run(bad, func(t *testing.T) {
spec := AppSpec{
Name: "iop-test",
DefaultConfig: "/tmp/does-not-matter.yaml",
DefaultDataDir: bad,
DefaultUnit: "/tmp/does-not-matter.service",
ConfigTemplate: "",
}
runner := newFakeRunner()
opts := baseOpts(runner)
opts.DryRun = true // ensure we don't try to write to those paths anyway
err := Run(context.Background(), spec, opts, &bytes.Buffer{})
if err == nil {
t.Fatalf("expected error for unsafe data dir %q", bad)
}
if !strings.Contains(err.Error(), "not an app-specific path") {
t.Fatalf("unexpected error: %v", err)
}
if len(runner.calls) != 0 {
t.Fatalf("runner should not be invoked for unsafe data dir, got %v", runner.calls)
}
})
}
}
func TestEdgeSpecConfigTemplateUsesServiceWritableLogPath(t *testing.T) {
tmpl := EdgeSpec().ConfigTemplate
wants := []string{
"path: \"/var/lib/iop/edge/logs/edge.log\"",
"advertise_host:",
"bootstrap:",
"artifact_base_url:",
"control_plane:",
"wire_addr:",
"reconnect_interval_sec:",
}
for _, w := range wants {
if !strings.Contains(tmpl, w) {
t.Errorf("EdgeSpec().ConfigTemplate missing %q\n---\n%s", w, tmpl)
}
}
}
func TestEdgeBundleConfigTemplateKeepsEmptyLogPath(t *testing.T) {
tmpl := EdgeBundleConfigTemplate()
wants := []string{
"path: \"\"",
"advertise_host:",
"bootstrap:",
"artifact_base_url:",
"control_plane:",
"wire_addr:",
"reconnect_interval_sec:",
}
for _, w := range wants {
if !strings.Contains(tmpl, w) {
t.Errorf("EdgeBundleConfigTemplate missing %q\n---\n%s", w, tmpl)
}
}
if strings.Contains(tmpl, "/var/lib/iop/edge/logs/edge.log") {
t.Errorf("bundle template leaked service log path:\n%s", tmpl)
}
}
func TestRenderUnit(t *testing.T) {
spec := AppSpec{Name: "iop-edge", Description: "IOP Edge"}
opts := SetupOptions{
BinaryPath: "/usr/local/bin/iop-edge",
ConfigPath: "/etc/iop/edge.yaml",
DataDir: "/var/lib/iop/edge",
User: "iop",
Group: "iop",
}
unit := renderUnit(spec, opts)
for _, want := range []string{
"[Unit]",
"Description=IOP Edge",
"After=network-online.target",
"[Service]",
"Type=simple",
"User=iop",
"Group=iop",
"ExecStart=/usr/local/bin/iop-edge serve --config /etc/iop/edge.yaml",
"WorkingDirectory=/var/lib/iop/edge",
"Restart=on-failure",
"[Install]",
"WantedBy=multi-user.target",
} {
if !strings.Contains(unit, want) {
t.Fatalf("unit missing %q\n%s", want, unit)
}
}
}