diff --git a/internal/kube/certificates/mgr_test.go b/internal/kube/certificates/mgr_test.go index 6eb8c8027..81d4d4656 100644 --- a/internal/kube/certificates/mgr_test.go +++ b/internal/kube/certificates/mgr_test.go @@ -411,7 +411,7 @@ func secretWithOwnerRef(secret *corev1.Secret, ref metav1.OwnerReference) *corev return secret } -// managedWithOwnerHosts sets up a Certificiate with skupper controlled and owner hosts annotations +// managedWithOwnerHosts sets up a Certificate with skupper controlled and owner hosts annotations func managedWithOwnerHosts(t *testing.T, cert *skupperv2alpha1.Certificate, ref metav1.OwnerReference, hosts ...string) *skupperv2alpha1.Certificate { t.Helper() cert.ObjectMeta.OwnerReferences = append(cert.ObjectMeta.OwnerReferences, ref) diff --git a/internal/nonkube/bootstrap/install.go b/internal/nonkube/bootstrap/install.go index 16aeb2e62..f0b0e1e52 100644 --- a/internal/nonkube/bootstrap/install.go +++ b/internal/nonkube/bootstrap/install.go @@ -40,6 +40,11 @@ func Install(platform string, reloadType string) error { return err } + if reloadType == "" { + reloadType = utils.DefaultStr(os.Getenv(types.ENV_SYSTEM_AUTO_RELOAD), + types.SystemReloadTypeManual) + } + config, err := configEnvVariables(platform) if err != nil { return err @@ -47,17 +52,29 @@ func Install(platform string, reloadType string) error { containerName := fmt.Sprintf("%s-skupper-controller", config.username) - isContainerAlreadyRunningInPodman := IsContainerRunning(containerName, types.PlatformPodman) + foundInPodman, podmanState := FindContainer(containerName, types.PlatformPodman) - if isContainerAlreadyRunningInPodman { - fmt.Printf("Warning: The system controller container %q is already running in Podman.\n", containerName) + if foundInPodman { + fmt.Printf("Warning: The system controller container %q is already present in Podman (state: %s).\n", containerName, podmanState) + if reloadType == types.SystemReloadTypeAuto { + enabler := newSiteServiceEnablerInstaller() + if err = enabler.Install(); err != nil { + return fmt.Errorf("failed to install skupper site service enabler: %v", err) + } + } return nil } - isContainerAlreadyRunningInDocker := IsContainerRunning(containerName, types.PlatformDocker) + foundInDocker, dockerState := FindContainer(containerName, types.PlatformDocker) - if isContainerAlreadyRunningInDocker { - fmt.Printf("Warning: The system controller container %q is already running in Docker.\n", containerName) + if foundInDocker { + fmt.Printf("Warning: The system controller container %q is already present in Docker (state: %s).\n", containerName, dockerState) + if reloadType == types.SystemReloadTypeAuto { + enabler := newSiteServiceEnablerInstaller() + if err = enabler.Install(); err != nil { + return fmt.Errorf("failed to install skupper site service enabler: %v", err) + } + } return nil } @@ -72,11 +89,6 @@ func Install(platform string, reloadType string) error { } fmt.Printf("Pulled system-controller image: %s\n", images.GetSystemControllerImageName()) - if reloadType == "" { - reloadType = utils.DefaultStr(os.Getenv(types.ENV_SYSTEM_AUTO_RELOAD), - types.SystemReloadTypeManual) - } - env := map[string]string{ "CONTAINER_ENDPOINT": config.containerEndpoint, "SKUPPER_OUTPUT_PATH": config.hostDataHome, @@ -144,6 +156,13 @@ func Install(platform string, reloadType string) error { return fmt.Errorf("failed to create system-controller systemd service: %v", err) } + if reloadType == types.SystemReloadTypeAuto { + enabler := newSiteServiceEnablerInstaller() + if err = enabler.Install(); err != nil { + return fmt.Errorf("failed to install skupper site service enabler: %v", err) + } + } + fmt.Printf("Platform %s is now configured for Skupper\n", platform) return nil @@ -262,7 +281,7 @@ func createSystemdService(container container.Container, platform string) error return nil } -func IsContainerRunning(containerName string, platform types.Platform) bool { +func FindContainer(containerName string, platform types.Platform) (bool, string) { endpoint := fmt.Sprintf("unix://%s/podman/podman.sock", api.GetRuntimeDir()) if platform == types.PlatformDocker { @@ -271,19 +290,19 @@ func IsContainerRunning(containerName string, platform types.Platform) bool { cli, err := internalclient.NewCompatClient(endpoint, "") if err != nil { - return false + return false, "" } containers, err := cli.ContainerList() if err != nil { - return false + return false, "" } - for _, container := range containers { - if container.Name == containerName { - return true + for _, c := range containers { + if c.Name == containerName { + return true, c.State } } - return false + return false, "" } diff --git a/internal/nonkube/bootstrap/site_service_enabler_installer.go b/internal/nonkube/bootstrap/site_service_enabler_installer.go new file mode 100644 index 000000000..d44a36ff3 --- /dev/null +++ b/internal/nonkube/bootstrap/site_service_enabler_installer.go @@ -0,0 +1,168 @@ +package bootstrap + +import ( + "bytes" + _ "embed" + "fmt" + "os" + "os/exec" + "path" + "text/template" + + "github.com/skupperproject/skupper/pkg/nonkube/api" +) + +//go:embed site_service_enabler_service.template +var siteServiceEnablerServiceTemplate string + +//go:embed site_service_enabler_script.template +var siteServiceEnablerScriptTemplate string + +const ( + siteServiceEnablerRootSystemdBasePath = "/etc/systemd/system" + siteServiceEnablerName = "skupper-site-service-enabler" + siteServiceEnablerServiceFile = siteServiceEnablerName + ".service" + siteServiceEnablerWrapperScript = siteServiceEnablerName +) + +type siteServiceEnablerData struct { + ScriptPath string + WantedBy string +} + +type siteServiceEnablerScriptData struct { + NamespacesDir string + SystemdUnitDir string + SystemctlArgs string +} + +type SiteServiceEnablerInstaller struct { + uid int + rootSystemdBasePath string + scriptDir string + command func(string, ...string) *exec.Cmd +} + +func newSiteServiceEnablerInstaller() *SiteServiceEnablerInstaller { + return &SiteServiceEnablerInstaller{ + uid: os.Getuid(), + rootSystemdBasePath: siteServiceEnablerRootSystemdBasePath, + scriptDir: path.Join(api.GetSystemControllerPath(), "bin"), + command: exec.Command, + } +} + +func (s *SiteServiceEnablerInstaller) Install() error { + if s.isRunning() { + return nil + } + if err := os.MkdirAll(s.scriptDir, 0755); err != nil { + return fmt.Errorf("unable to create script directory %q: %w", s.scriptDir, err) + } + if err := os.MkdirAll(s.userSystemdDir(), 0755); err != nil { + return fmt.Errorf("unable to create systemd unit directory %q: %w", s.userSystemdDir(), err) + } + + scriptPath := path.Join(s.scriptDir, siteServiceEnablerWrapperScript) + if err := s.renderFile(siteServiceEnablerScriptTemplate, s.scriptData(), scriptPath, 0755); err != nil { + return fmt.Errorf("unable to write wrapper script %q: %w", scriptPath, err) + } + + serviceFile := s.unitPath(siteServiceEnablerServiceFile) + if err := s.renderFile(siteServiceEnablerServiceTemplate, s.templateData(scriptPath), serviceFile, 0644); err != nil { + return fmt.Errorf("unable to write site enabler service unit: %w", err) + } + + if err := s.systemctl("daemon-reload"); err != nil { + return fmt.Errorf("daemon-reload failed: %w", err) + } + if err := s.systemctl("enable", siteServiceEnablerServiceFile); err != nil { + return fmt.Errorf("unable to enable %s: %w", siteServiceEnablerServiceFile, err) + } + if err := s.systemctl("start", siteServiceEnablerServiceFile); err != nil { + return fmt.Errorf("unable to start %s: %w", siteServiceEnablerServiceFile, err) + } + + return nil +} + +func (s *SiteServiceEnablerInstaller) Remove() error { + unitFile := s.unitPath(siteServiceEnablerServiceFile) + if _, err := os.Stat(unitFile); err == nil { + if err := s.systemctl("stop", siteServiceEnablerServiceFile); err != nil { + return fmt.Errorf("failed to stop %s: %w", siteServiceEnablerServiceFile, err) + } + if err := s.systemctl("disable", siteServiceEnablerServiceFile); err != nil { + return fmt.Errorf("failed to disable %s: %w", siteServiceEnablerServiceFile, err) + } + if err := os.Remove(unitFile); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("failed to remove unit file: %w", err) + } + } + if err := os.Remove(path.Join(s.scriptDir, siteServiceEnablerWrapperScript)); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("failed to remove wrapper script: %w", err) + } + if err := s.systemctl("daemon-reload"); err != nil { + return fmt.Errorf("daemon-reload failed after remove: %w", err) + } + return nil +} + +func (s *SiteServiceEnablerInstaller) scriptData() siteServiceEnablerScriptData { + systemctlArgs := "" + if s.uid != 0 { + systemctlArgs = "--user " + } + return siteServiceEnablerScriptData{ + NamespacesDir: api.GetDefaultOutputNamespacesPath(), + SystemdUnitDir: s.userSystemdDir(), + SystemctlArgs: systemctlArgs, + } +} + +func (s *SiteServiceEnablerInstaller) templateData(scriptPath string) siteServiceEnablerData { + wantedBy := "default.target" + if s.uid == 0 { + wantedBy = "multi-user.target" + } + return siteServiceEnablerData{ + ScriptPath: scriptPath, + WantedBy: wantedBy, + } +} + +func (s *SiteServiceEnablerInstaller) unitPath(unit string) string { + return path.Join(s.userSystemdDir(), unit) +} + +func (s *SiteServiceEnablerInstaller) userSystemdDir() string { + if s.uid == 0 { + return s.rootSystemdBasePath + } + return path.Join(api.GetConfigHome(), "systemd", "user") +} + +func (s *SiteServiceEnablerInstaller) isRunning() bool { + return s.systemctl("is-active", "--quiet", siteServiceEnablerServiceFile) == nil +} + +func (s *SiteServiceEnablerInstaller) systemctl(args ...string) error { + var fullArgs []string + if s.uid != 0 { + fullArgs = append(fullArgs, "--user") + } + fullArgs = append(fullArgs, args...) + return s.command("systemctl", fullArgs...).Run() +} + +func (s *SiteServiceEnablerInstaller) renderFile(tmplText string, data any, dst string, mode os.FileMode) error { + tmpl, err := template.New("").Parse(tmplText) + if err != nil { + return err + } + var buf bytes.Buffer + if err = tmpl.Execute(&buf, data); err != nil { + return err + } + return os.WriteFile(dst, buf.Bytes(), mode) +} diff --git a/internal/nonkube/bootstrap/site_service_enabler_installer_test.go b/internal/nonkube/bootstrap/site_service_enabler_installer_test.go new file mode 100644 index 000000000..67bc8e6f4 --- /dev/null +++ b/internal/nonkube/bootstrap/site_service_enabler_installer_test.go @@ -0,0 +1,444 @@ +package bootstrap + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "gotest.tools/v3/assert" +) + +func fakeCommand(calls *[][]string) func(string, ...string) *exec.Cmd { + return func(name string, args ...string) *exec.Cmd { + *calls = append(*calls, append([]string{name}, args...)) + return exec.CommandContext(context.Background(), "true") + } +} + +func fakeCommandNotRunning(calls *[][]string) func(string, ...string) *exec.Cmd { + return func(name string, args ...string) *exec.Cmd { + *calls = append(*calls, append([]string{name}, args...)) + for _, a := range args { + if a == "is-active" { + return exec.CommandContext(context.Background(), "false") + } + } + return exec.CommandContext(context.Background(), "true") + } +} + +func newTestInstaller(t *testing.T, uid int, calls *[][]string) *SiteServiceEnablerInstaller { + t.Helper() + tmp := t.TempDir() + + t.Setenv("XDG_CONFIG_HOME", filepath.Join(tmp, "config")) + return &SiteServiceEnablerInstaller{ + uid: uid, + rootSystemdBasePath: filepath.Join(tmp, "etc", "systemd", "system"), + scriptDir: filepath.Join(tmp, "bin"), + command: fakeCommand(calls), + } +} + +func TestTemplateData_NonRoot(t *testing.T) { + s := &SiteServiceEnablerInstaller{uid: 1000} + d := s.templateData("/some/path/script") + assert.Equal(t, d.ScriptPath, "/some/path/script") + assert.Equal(t, d.WantedBy, "default.target") +} + +func TestTemplateData_Root(t *testing.T) { + s := &SiteServiceEnablerInstaller{uid: 0} + d := s.templateData("/some/path/script") + assert.Equal(t, d.WantedBy, "multi-user.target") +} + +func TestUserSystemdDir_Root(t *testing.T) { + s := &SiteServiceEnablerInstaller{uid: 0, rootSystemdBasePath: "/etc/systemd/system"} + assert.Equal(t, s.userSystemdDir(), "/etc/systemd/system") +} + +func TestUserSystemdDir_NonRoot(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", "/fake/config") + s := &SiteServiceEnablerInstaller{uid: 1000} + got := s.userSystemdDir() + assert.Assert(t, strings.HasPrefix(got, "/fake/config"), "expected XDG_CONFIG_HOME prefix, got %s", got) +} + +func TestUnitPath(t *testing.T) { + s := &SiteServiceEnablerInstaller{uid: 0, rootSystemdBasePath: "/etc/systemd/system"} + got := s.unitPath("skupper-site-service-enabler.service") + assert.Equal(t, got, "/etc/systemd/system/skupper-site-service-enabler.service") +} + +func TestSystemctl_NonRoot_AddsUserFlag(t *testing.T) { + var calls [][]string + s := &SiteServiceEnablerInstaller{uid: 1000, command: fakeCommand(&calls)} + err := s.systemctl("start", "foo.service") + assert.NilError(t, err) + assert.Equal(t, len(calls), 1) + assert.DeepEqual(t, calls[0], []string{"systemctl", "--user", "start", "foo.service"}) +} + +func TestSystemctl_Root_NoUserFlag(t *testing.T) { + var calls [][]string + s := &SiteServiceEnablerInstaller{uid: 0, command: fakeCommand(&calls)} + err := s.systemctl("start", "foo.service") + assert.NilError(t, err) + assert.Equal(t, len(calls), 1) + assert.DeepEqual(t, calls[0], []string{"systemctl", "start", "foo.service"}) +} + +func TestRenderFile_ServiceTemplate(t *testing.T) { + tmp := t.TempDir() + dst := filepath.Join(tmp, "out.service") + s := &SiteServiceEnablerInstaller{} + err := s.renderFile(siteServiceEnablerServiceTemplate, siteServiceEnablerData{ + ScriptPath: "/usr/bin/skupper-site-service-enabler", + WantedBy: "multi-user.target", + }, dst, 0644) + assert.NilError(t, err) + + content, err := os.ReadFile(dst) + assert.NilError(t, err) + assert.Assert(t, strings.Contains(string(content), "ExecStart=/usr/bin/skupper-site-service-enabler")) + assert.Assert(t, strings.Contains(string(content), "WantedBy=multi-user.target")) +} + +func TestRenderFile_ScriptTemplate(t *testing.T) { + tmp := t.TempDir() + dst := filepath.Join(tmp, "out.sh") + s := &SiteServiceEnablerInstaller{} + err := s.renderFile(siteServiceEnablerScriptTemplate, siteServiceEnablerScriptData{ + NamespacesDir: "/home/user/.local/share/skupper/namespaces", + SystemdUnitDir: "/home/user/.config/systemd/user", + SystemctlArgs: "--user ", + }, dst, 0755) + assert.NilError(t, err) + + content, err := os.ReadFile(dst) + assert.NilError(t, err) + assert.Assert(t, strings.Contains(string(content), "NAMESPACES_DIR=\"/home/user/.local/share/skupper/namespaces\"")) + assert.Assert(t, strings.Contains(string(content), "UNIT_DIR=\"/home/user/.config/systemd/user\"")) + assert.Assert(t, strings.Contains(string(content), "SYSTEMCTL_ARGS=\"--user \"")) + assert.Assert(t, strings.Contains(string(content), "POLL_INTERVAL")) +} + +func renderScript(t *testing.T) string { + t.Helper() + tmp := t.TempDir() + dst := filepath.Join(tmp, "out.sh") + s := &SiteServiceEnablerInstaller{} + err := s.renderFile(siteServiceEnablerScriptTemplate, siteServiceEnablerScriptData{ + NamespacesDir: "/ns", + SystemdUnitDir: "/units", + SystemctlArgs: "", + }, dst, 0755) + assert.NilError(t, err) + content, err := os.ReadFile(dst) + assert.NilError(t, err) + return string(content) +} + +func TestScript_RestartsServiceOnUnitChange(t *testing.T) { + body := renderScript(t) + assert.Assert(t, strings.Contains(body, "changed=1"), "expected changed=1 inside change detection branch") + assert.Assert(t, strings.Contains(body, `if [ "$changed" -eq 1 ]`), "expected conditional restart block") + assert.Assert(t, strings.Contains(body, `systemctl_run restart "$svc"`), "expected restart via systemctl_run") +} + +func TestScript_OwnershipMarkerAppendedToUnitCopy(t *testing.T) { + body := renderScript(t) + assert.Assert(t, strings.Contains(body, "OWNERSHIP_MARKER="), "expected OWNERSHIP_MARKER variable") + assert.Assert(t, strings.Contains(body, "X-ManagedBy=skupper-site-service-enabler"), "expected X-ManagedBy marker text") + assert.Assert(t, strings.Contains(body, `awk '/^\[Unit\]/`), "expected awk injection into [Unit] section") + assert.Assert(t, strings.Contains(body, `printf '%s' "$owned" > "$dst"`), "expected owned content written to dst") +} + +func TestScript_ListActiveUsesMarkerNotPrefix(t *testing.T) { + body := renderScript(t) + assert.Assert(t, strings.Contains(body, `grep -rl "${OWNERSHIP_MARKER}"`), "expected grep on ownership marker in list_active") + assert.Assert(t, !strings.Contains(body, `find "${UNIT_DIR}" -maxdepth 1 -name "skupper-*.service"`), "must not use prefix-based find") +} + +func TestScript_KeepsUnitFileWhenDisableFails(t *testing.T) { + tmp := t.TempDir() + dst := filepath.Join(tmp, "out.sh") + s := &SiteServiceEnablerInstaller{} + err := s.renderFile(siteServiceEnablerScriptTemplate, siteServiceEnablerScriptData{ + NamespacesDir: "/ns", + SystemdUnitDir: "/units", + SystemctlArgs: "", + }, dst, 0755) + assert.NilError(t, err) + + content, err := os.ReadFile(dst) + assert.NilError(t, err) + body := string(content) + + assert.Assert(t, strings.Contains(body, `if systemctl_run disable --now "$svc"`), "expected disable guarding rm") + assert.Assert(t, strings.Contains(body, "rm -f"), "expected rm -f inside disable branch") +} + +func TestRenderFile_InvalidTemplate(t *testing.T) { + tmp := t.TempDir() + s := &SiteServiceEnablerInstaller{} + err := s.renderFile("{{.Invalid", siteServiceEnablerData{}, filepath.Join(tmp, "out.service"), 0644) + assert.Assert(t, err != nil) +} + +func TestInstall_CreatesWrapperScript(t *testing.T) { + var calls [][]string + s := newTestInstaller(t, 1000, &calls) + s.command = fakeCommandNotRunning(&calls) + + err := s.Install() + assert.NilError(t, err) + + scriptPath := filepath.Join(s.scriptDir, siteServiceEnablerWrapperScript) + content, err := os.ReadFile(scriptPath) + assert.NilError(t, err) + + assert.Assert(t, strings.HasPrefix(string(content), "#!/bin/sh"), "expected shell shebang") + assert.Assert(t, strings.Contains(string(content), "POLL_INTERVAL")) + + info, err := os.Stat(scriptPath) + assert.NilError(t, err) + assert.Equal(t, info.Mode(), os.FileMode(0755)) +} + +func TestInstall_CreatesServiceFile(t *testing.T) { + var calls [][]string + s := newTestInstaller(t, 0, &calls) + s.command = fakeCommandNotRunning(&calls) + _ = os.MkdirAll(s.unitPath(""), 0755) + + err := s.Install() + assert.NilError(t, err) + + svcPath := s.unitPath(siteServiceEnablerServiceFile) + content, err := os.ReadFile(svcPath) + assert.NilError(t, err) + assert.Assert(t, strings.Contains(string(content), "ExecStart=")) + assert.Assert(t, strings.Contains(string(content), "WantedBy=multi-user.target")) +} + +func TestInstall_SystemctlCallOrder(t *testing.T) { + var calls [][]string + s := newTestInstaller(t, 0, &calls) + s.command = fakeCommandNotRunning(&calls) + _ = os.MkdirAll(s.unitPath(""), 0755) + + err := s.Install() + assert.NilError(t, err) + + assert.Equal(t, len(calls), 4) + assert.DeepEqual(t, calls[0], []string{"systemctl", "is-active", "--quiet", siteServiceEnablerServiceFile}) + assert.DeepEqual(t, calls[1], []string{"systemctl", "daemon-reload"}) + assert.DeepEqual(t, calls[2], []string{"systemctl", "enable", siteServiceEnablerServiceFile}) + assert.DeepEqual(t, calls[3], []string{"systemctl", "start", siteServiceEnablerServiceFile}) +} + +func TestInstall_SkipsWhenAlreadyRunning(t *testing.T) { + var calls [][]string + s := newTestInstaller(t, 0, &calls) + + err := s.Install() + assert.NilError(t, err) + + assert.Equal(t, len(calls), 1) + assert.DeepEqual(t, calls[0], []string{"systemctl", "is-active", "--quiet", siteServiceEnablerServiceFile}) +} + +func TestInstall_NonRoot_SystemctlUsesUserFlag(t *testing.T) { + var calls [][]string + s := newTestInstaller(t, 1000, &calls) + s.command = fakeCommandNotRunning(&calls) + + err := s.Install() + assert.NilError(t, err) + + for _, c := range calls { + assert.Equal(t, c[1], "--user", "expected --user flag in call %v", c) + } +} + +func TestInstall_ScriptDirCreationFailure(t *testing.T) { + var calls [][]string + s := newTestInstaller(t, 1000, &calls) + s.command = fakeCommandNotRunning(&calls) + blocker := s.scriptDir + _ = os.MkdirAll(filepath.Dir(blocker), 0755) + _ = os.WriteFile(blocker, []byte("block"), 0644) + s.scriptDir = filepath.Join(blocker, "subdir") + + err := s.Install() + assert.Assert(t, err != nil) + assert.Assert(t, strings.Contains(err.Error(), "unable to create script directory")) +} + +func TestRemove_SystemctlCallOrder(t *testing.T) { + var calls [][]string + s := newTestInstaller(t, 0, &calls) + + // Pre-create the unit file so stop/disable are attempted. + svcPath := s.unitPath(siteServiceEnablerServiceFile) + _ = os.MkdirAll(filepath.Dir(svcPath), 0755) + _ = os.WriteFile(svcPath, []byte("[Unit]"), 0644) + + err := s.Remove() + assert.NilError(t, err) + + assert.Equal(t, len(calls), 3) + assert.DeepEqual(t, calls[0], []string{"systemctl", "stop", siteServiceEnablerServiceFile}) + assert.DeepEqual(t, calls[1], []string{"systemctl", "disable", siteServiceEnablerServiceFile}) + assert.DeepEqual(t, calls[2], []string{"systemctl", "daemon-reload"}) +} + +func TestRemove_DeletesFiles(t *testing.T) { + var calls [][]string + s := newTestInstaller(t, 0, &calls) + + svcPath := s.unitPath(siteServiceEnablerServiceFile) + _ = os.MkdirAll(filepath.Dir(svcPath), 0755) + _ = os.WriteFile(svcPath, []byte("[Unit]"), 0644) + + scriptPath := filepath.Join(s.scriptDir, siteServiceEnablerWrapperScript) + _ = os.MkdirAll(s.scriptDir, 0755) + _ = os.WriteFile(scriptPath, []byte("#!/bin/sh"), 0755) + + err := s.Remove() + assert.NilError(t, err) + + _, err = os.Stat(svcPath) + assert.Assert(t, os.IsNotExist(err), "service file should be removed") + + _, err = os.Stat(scriptPath) + assert.Assert(t, os.IsNotExist(err), "wrapper script should be removed") +} + +func TestRemove_NonRoot_SystemctlUsesUserFlag(t *testing.T) { + var calls [][]string + s := newTestInstaller(t, 1000, &calls) + + // Pre-create the unit file so stop/disable are attempted. + svcPath := s.unitPath(siteServiceEnablerServiceFile) + _ = os.MkdirAll(filepath.Dir(svcPath), 0755) + _ = os.WriteFile(svcPath, []byte("[Unit]"), 0644) + + err := s.Remove() + assert.NilError(t, err) + + for _, c := range calls { + assert.Equal(t, c[1], "--user", "expected --user flag in call %v", c) + } +} + +func TestRemove_ToleratesMissingFiles(t *testing.T) { + var calls [][]string + s := newTestInstaller(t, 0, &calls) + err := s.Remove() + assert.NilError(t, err) + // Unit absent: only daemon-reload is called. + assert.Equal(t, len(calls), 1) + assert.DeepEqual(t, calls[0], []string{"systemctl", "daemon-reload"}) +} + +func TestRemove_SkipsStopDisableWhenUnitAbsent(t *testing.T) { + var calls [][]string + s := newTestInstaller(t, 0, &calls) + + err := s.Remove() + assert.NilError(t, err) + + // stop and disable must not have been called; only daemon-reload. + for _, c := range calls { + assert.Assert(t, c[1] != "stop", "stop must not be called when unit is absent") + assert.Assert(t, c[1] != "disable", "disable must not be called when unit is absent") + } +} + +func TestRemove_FailsOnStopError(t *testing.T) { + var calls [][]string + s := newTestInstaller(t, 0, &calls) + s.command = func(name string, args ...string) *exec.Cmd { + calls = append(calls, append([]string{name}, args...)) + for _, a := range args { + if a == "stop" { + return exec.CommandContext(context.Background(), "false") + } + } + return exec.CommandContext(context.Background(), "true") + } + + // Pre-create the unit file so the stop branch is entered. + svcPath := s.unitPath(siteServiceEnablerServiceFile) + _ = os.MkdirAll(filepath.Dir(svcPath), 0755) + _ = os.WriteFile(svcPath, []byte("[Unit]"), 0644) + + err := s.Remove() + assert.Assert(t, err != nil) + assert.Assert(t, strings.Contains(err.Error(), "failed to stop")) + // disable and daemon-reload must not have been called + assert.Equal(t, len(calls), 1) +} + +func TestRemove_FailsOnDisableError(t *testing.T) { + var calls [][]string + s := newTestInstaller(t, 0, &calls) + s.command = func(name string, args ...string) *exec.Cmd { + calls = append(calls, append([]string{name}, args...)) + for _, a := range args { + if a == "disable" { + return exec.CommandContext(context.Background(), "false") + } + } + return exec.CommandContext(context.Background(), "true") + } + + // Pre-create the unit file so the disable branch is entered. + svcPath := s.unitPath(siteServiceEnablerServiceFile) + _ = os.MkdirAll(filepath.Dir(svcPath), 0755) + _ = os.WriteFile(svcPath, []byte("[Unit]"), 0644) + + err := s.Remove() + assert.Assert(t, err != nil) + assert.Assert(t, strings.Contains(err.Error(), "failed to disable")) + // daemon-reload must not have been called + assert.Equal(t, len(calls), 2) +} + +func TestRemove_FailsOnUnitFileRemoveError(t *testing.T) { + var calls [][]string + s := newTestInstaller(t, 0, &calls) + s.command = fakeCommand(&calls) + + unitPath := s.unitPath(siteServiceEnablerServiceFile) + _ = os.MkdirAll(unitPath, 0755) + _ = os.WriteFile(filepath.Join(unitPath, "child"), []byte("x"), 0644) + + err := s.Remove() + assert.Assert(t, err != nil) + assert.Assert(t, strings.Contains(err.Error(), "failed to remove unit file")) +} + +func TestRemove_FailsOnDaemonReloadError(t *testing.T) { + var calls [][]string + s := newTestInstaller(t, 0, &calls) + s.command = func(name string, args ...string) *exec.Cmd { + calls = append(calls, append([]string{name}, args...)) + for _, a := range args { + if a == "daemon-reload" { + return exec.CommandContext(context.Background(), "false") + } + } + return exec.CommandContext(context.Background(), "true") + } + + err := s.Remove() + assert.Assert(t, err != nil) + assert.Assert(t, strings.Contains(err.Error(), "daemon-reload failed after remove")) +} diff --git a/internal/nonkube/bootstrap/site_service_enabler_script.template b/internal/nonkube/bootstrap/site_service_enabler_script.template new file mode 100644 index 000000000..2adf3ace6 --- /dev/null +++ b/internal/nonkube/bootstrap/site_service_enabler_script.template @@ -0,0 +1,82 @@ +#!/bin/sh +# skupper-site-service-enabler +# Watches Skupper namespace script directories and registers/deregisters +# site systemd services as they appear or disappear. +# +# Generated by 'skupper system install'. Do not edit manually. + +NAMESPACES_DIR="{{.NamespacesDir}}" +UNIT_DIR="{{.SystemdUnitDir}}" +SYSTEMCTL_ARGS="{{.SystemctlArgs}}" + +SCRIPT_SUBPATH="internal/scripts" +POLL_INTERVAL=5 +OWNERSHIP_MARKER="X-ManagedBy=skupper-site-service-enabler" + +systemctl_run() { + systemctl ${SYSTEMCTL_ARGS}"$@" +} + +enable_service() { + src="$1" + svc=$(basename "$src") + dst="${UNIT_DIR}/${svc}" + mkdir -p "${UNIT_DIR}" + changed=0 + + owned=$(awk '/^\[Unit\]/{print; print "'"${OWNERSHIP_MARKER}"'"; next}1' "$src") + existing=$(cat "$dst" 2>/dev/null || true) + if [ "$owned" != "$existing" ]; then + printf '%s' "$owned" > "$dst" + systemctl_run daemon-reload || true + changed=1 + fi + systemctl_run enable --now "$svc" || true + if [ "$changed" -eq 1 ]; then + systemctl_run restart "$svc" || true + fi +} + +disable_service() { + svc="$1" + dst="${UNIT_DIR}/${svc}" + if [ -f "$dst" ]; then + if systemctl_run disable --now "$svc"; then + rm -f "$dst" + systemctl_run daemon-reload || true + fi + fi +} + +# List site service unit files we manage. +# Only files bearing the ownership marker are considered owned by this helper. +list_active() { + [ -d "${UNIT_DIR}" ] || return + grep -rl "${OWNERSHIP_MARKER}" "${UNIT_DIR}" 2>/dev/null \ + | grep -E '/skupper-[^/]+\.service$' +} + +while true; do + if [ -d "${NAMESPACES_DIR}" ]; then + find "${NAMESPACES_DIR}" \ + -path "*/${SCRIPT_SUBPATH}/skupper-*.service" \ + -type f | while IFS= read -r src; do + enable_service "$src" + done + fi + + list_active | while IFS= read -r dst; do + svc=$(basename "$dst") + found="" + if [ -d "${NAMESPACES_DIR}" ]; then + found=$(find "${NAMESPACES_DIR}" \ + -path "*/${SCRIPT_SUBPATH}/${svc}" \ + -type f | head -1) + fi + if [ -z "$found" ]; then + disable_service "$svc" + fi + done + + sleep "${POLL_INTERVAL}" +done diff --git a/internal/nonkube/bootstrap/site_service_enabler_service.template b/internal/nonkube/bootstrap/site_service_enabler_service.template new file mode 100644 index 000000000..b8c93c7ec --- /dev/null +++ b/internal/nonkube/bootstrap/site_service_enabler_service.template @@ -0,0 +1,12 @@ +[Unit] +Description=Skupper site service enabler +After=network.target + +[Service] +Type=simple +ExecStart={{.ScriptPath}} +Restart=always +RestartSec=5 + +[Install] +WantedBy={{.WantedBy}} diff --git a/internal/nonkube/bootstrap/uninstall.go b/internal/nonkube/bootstrap/uninstall.go index d47ef92cc..86f394685 100644 --- a/internal/nonkube/bootstrap/uninstall.go +++ b/internal/nonkube/bootstrap/uninstall.go @@ -15,6 +15,10 @@ import ( func Uninstall(platform string) error { + if err := newSiteServiceEnablerInstaller().Remove(); err != nil { + return fmt.Errorf("failed to remove skupper site service enabler: %w", err) + } + currentUser, err := user.Current() if err != nil { return fmt.Errorf("failed to get current user: %v", err) @@ -22,17 +26,17 @@ func Uninstall(platform string) error { containerName := fmt.Sprintf("%s-skupper-controller", currentUser.Username) - isContainerAlreadyRunningInPodman := IsContainerRunning(containerName, types.PlatformPodman) + foundInPodman, podmanState := FindContainer(containerName, types.PlatformPodman) - if isContainerAlreadyRunningInPodman && platform == "docker" { - fmt.Printf("Warning: The system controller container %q is already running in Podman but the selected platform is Docker.\n", containerName) + if foundInPodman && platform == "docker" { + fmt.Printf("Warning: The system controller container %q is already present in Podman (state: %s) but the selected platform is Docker.\n", containerName, podmanState) return nil } - isContainerAlreadyRunningInDocker := IsContainerRunning(containerName, types.PlatformDocker) + foundInDocker, dockerState := FindContainer(containerName, types.PlatformDocker) - if isContainerAlreadyRunningInDocker && platform == "podman" { - fmt.Printf("Warning: The system controller container %q is already running in Docker but the selected platform is Podman.\n", containerName) + if foundInDocker && platform == "podman" { + fmt.Printf("Warning: The system controller container %q is already present in Docker (state: %s) but the selected platform is Podman.\n", containerName, dockerState) return nil } diff --git a/internal/nonkube/client/compat/container.go b/internal/nonkube/client/compat/container.go index b233817cd..e6949531d 100644 --- a/internal/nonkube/client/compat/container.go +++ b/internal/nonkube/client/compat/container.go @@ -43,6 +43,7 @@ func (c *CompatClient) ContainerList() ([]*container.Container, error) { FileMounts: make([]container.FileMount, 0), Ports: make([]container.Port, 0), Command: []string{cMap["Command"].(string)}, + State: cMap["State"].(string), Running: cMap["State"].(string) == "running", CreatedAt: time.Unix(jsonNumberAsInt(cMap["Created"]), 0), } @@ -216,6 +217,7 @@ func FromInspectContainer(c *containers_compat.ContainerInspectOKBody) *containe // State info if c.State != nil { ct.Running = c.State.Running + ct.State = c.State.Status startedAt, _ := time.Parse(time.RFC3339, c.State.StartedAt) exitedAt, _ := time.Parse(time.RFC3339, c.State.FinishedAt) ct.StartedAt = startedAt diff --git a/pkg/container/client.go b/pkg/container/client.go index 5ff9fb3be..e105ff26d 100644 --- a/pkg/container/client.go +++ b/pkg/container/client.go @@ -77,6 +77,7 @@ type Container struct { MaxMemoryBytes int64 RestartCount int Running bool + State string CreatedAt time.Time StartedAt time.Time ExitedAt time.Time