워크스페이스 기본 포트와 검증 문서를 실제 런타임 기본값에 맞추고, 브리지 경계에서 adapter config 타입 정보를 보존해야 이후 원격 smoke와 경계 안정화 작업이 같은 계약을 사용할 수 있다.
397 lines
10 KiB
Go
397 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:",
|
|
"metrics:",
|
|
"port: 19092",
|
|
}
|
|
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:",
|
|
"metrics:",
|
|
"port: 19092",
|
|
}
|
|
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)
|
|
}
|
|
}
|
|
}
|