diff --git a/pkg/supervisor/prochandle.go b/pkg/supervisor/prochandle.go new file mode 100644 index 000000000000..04b3f93e70d3 --- /dev/null +++ b/pkg/supervisor/prochandle.go @@ -0,0 +1,33 @@ +/* +Copyright 2024 k0s authors + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package supervisor + +// A handle to a running process. May be used to inspect the process properties +// and terminate it. +type procHandle interface { + // Reads and returns the process's command line. + cmdline() ([]string, error) + + // Reads and returns the process's environment. + environ() ([]string, error) + + // Terminates the process gracefully. + terminateGracefully() error + + // Terminates the process forcibly. + terminateForcibly() error +} diff --git a/pkg/supervisor/prochandle_unix.go b/pkg/supervisor/prochandle_unix.go new file mode 100644 index 000000000000..d06516f67bd9 --- /dev/null +++ b/pkg/supervisor/prochandle_unix.go @@ -0,0 +1,67 @@ +//go:build unix + +/* +Copyright 2022 k0s authors + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package supervisor + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "strconv" + "strings" + "syscall" +) + +type unixPID int + +func newProcHandle(pid int) (procHandle, error) { + return unixPID(pid), nil +} + +func (pid unixPID) cmdline() ([]string, error) { + cmdline, err := os.ReadFile(filepath.Join("/proc", strconv.Itoa(int(pid)), "cmdline")) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil, fmt.Errorf("%w: %w", syscall.ESRCH, err) + } + return nil, fmt.Errorf("failed to read process cmdline: %w", err) + } + + return strings.Split(string(cmdline), "\x00"), nil +} + +func (pid unixPID) environ() ([]string, error) { + env, err := os.ReadFile(filepath.Join("/proc", strconv.Itoa(int(pid)), "environ")) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil, fmt.Errorf("%w: %w", syscall.ESRCH, err) + } + return nil, fmt.Errorf("failed to read process environ: %w", err) + } + + return strings.Split(string(env), "\x00"), nil +} + +func (pid unixPID) terminateGracefully() error { + return syscall.Kill(int(pid), syscall.SIGTERM) +} + +func (pid unixPID) terminateForcibly() error { + return syscall.Kill(int(pid), syscall.SIGKILL) +} diff --git a/pkg/supervisor/supervisor_windows.go b/pkg/supervisor/prochandle_windows.go similarity index 64% rename from pkg/supervisor/supervisor_windows.go rename to pkg/supervisor/prochandle_windows.go index 9666d701c5c0..a1f5c826a26f 100644 --- a/pkg/supervisor/supervisor_windows.go +++ b/pkg/supervisor/prochandle_windows.go @@ -16,10 +16,11 @@ limitations under the License. package supervisor -// maybeKillPidFile checks kills the process in the pidFile if it's has -// the same binary as the supervisor's. This function does not delete -// the old pidFile as this is done by the caller. -func (s *Supervisor) maybeKillPidFile() error { - s.log.Warnf("maybeKillPidFile is not implemented on Windows") - return nil +import ( + "syscall" +) + +// newProcHandle is not implemented on Windows. +func newProcHandle(int) (procHandle, error) { + return nil, syscall.EWINDOWS } diff --git a/pkg/supervisor/supervisor.go b/pkg/supervisor/supervisor.go index 6c6ab1cf7df3..bcf4bc85f954 100644 --- a/pkg/supervisor/supervisor.go +++ b/pkg/supervisor/supervisor.go @@ -18,11 +18,13 @@ package supervisor import ( "context" + "errors" "fmt" "os" "os/exec" "path" "runtime" + "slices" "sort" "strconv" "strings" @@ -140,7 +142,11 @@ func (s *Supervisor) Supervise() error { } if err := s.maybeKillPidFile(); err != nil { - return err + if !errors.Is(err, errors.ErrUnsupported) { + return err + } + + s.log.WithError(err).Warn("Old process cannot be terminated") } var ctx context.Context @@ -242,6 +248,110 @@ func (s *Supervisor) Stop() { } } +// maybeKillPidFile checks kills the process in the pidFile if it's has +// the same binary as the supervisor's and also checks that the env +// `_KOS_MANAGED=yes`. This function does not delete the old pidFile as +// this is done by the caller. +func (s *Supervisor) maybeKillPidFile() error { + pid, err := os.ReadFile(s.PidFile) + if os.IsNotExist(err) { + return nil + } else if err != nil { + return fmt.Errorf("failed to read PID file %s: %w", s.PidFile, err) + } + + p, err := strconv.Atoi(strings.TrimSuffix(string(pid), "\n")) + if err != nil { + return fmt.Errorf("failed to parse PID file %s: %w", s.PidFile, err) + } + + ph, err := newProcHandle(p) + if err != nil { + return fmt.Errorf("cannot interact with PID %d from PID file %s: %w", p, s.PidFile, err) + } + + if err := s.killProcess(ph); err != nil { + return fmt.Errorf("failed to kill PID %d from PID file %s: %w", p, s.PidFile, err) + } + + return nil +} + +const exitCheckInterval = 200 * time.Millisecond + +// Tries to terminate a process gracefully. If it's still running after +// s.TimeoutStop, the process is forcibly terminated. +func (s *Supervisor) killProcess(ph procHandle) error { + // Kill the process pid + deadlineTicker := time.NewTicker(s.TimeoutStop) + defer deadlineTicker.Stop() + checkTicker := time.NewTicker(exitCheckInterval) + defer checkTicker.Stop() + +Loop: + for { + select { + case <-checkTicker.C: + shouldKill, err := s.shouldKillProcess(ph) + if err != nil { + return err + } + if !shouldKill { + return nil + } + + err = ph.terminateGracefully() + if errors.Is(err, syscall.ESRCH) { + return nil + } else if err != nil { + return fmt.Errorf("failed to terminate gracefully: %w", err) + } + case <-deadlineTicker.C: + break Loop + } + } + + shouldKill, err := s.shouldKillProcess(ph) + if err != nil { + return err + } + if !shouldKill { + return nil + } + + err = ph.terminateForcibly() + if errors.Is(err, syscall.ESRCH) { + return nil + } else if err != nil { + return fmt.Errorf("failed to terminate forcibly: %w", err) + } + return nil +} + +func (s *Supervisor) shouldKillProcess(ph procHandle) (bool, error) { + // only kill process if it has the expected cmd + if cmd, err := ph.cmdline(); err != nil { + if errors.Is(err, syscall.ESRCH) { + return false, nil + } + return false, err + } else if len(cmd) > 0 && cmd[0] != s.BinPath { + return false, nil + } + + //only kill process if it has the _KOS_MANAGED env set + if env, err := ph.environ(); err != nil { + if errors.Is(err, syscall.ESRCH) { + return false, nil + } + return false, err + } else if !slices.Contains(env, k0sManaged) { + return false, nil + } + + return true, nil +} + // Prepare the env for exec: // - handle component specific env // - inject k0s embedded bins into path diff --git a/pkg/supervisor/supervisor_unix.go b/pkg/supervisor/supervisor_unix.go deleted file mode 100644 index 9a38178c3a4e..000000000000 --- a/pkg/supervisor/supervisor_unix.go +++ /dev/null @@ -1,137 +0,0 @@ -//go:build unix - -/* -Copyright 2022 k0s authors - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -package supervisor - -import ( - "errors" - "fmt" - "os" - "path/filepath" - "strconv" - "strings" - "syscall" - "time" -) - -const ( - exitCheckInterval = 200 * time.Millisecond -) - -// killPid signals SIGTERM to a PID and if it's still running after -// s.TimeoutStop sends SIGKILL. -func (s *Supervisor) killPid(pid int) error { - // Kill the process pid - deadlineTicker := time.NewTicker(s.TimeoutStop) - defer deadlineTicker.Stop() - checkTicker := time.NewTicker(exitCheckInterval) - defer checkTicker.Stop() - -Loop: - for { - select { - case <-checkTicker.C: - shouldKill, err := s.shouldKillProcess(pid) - if err != nil { - return err - } - if !shouldKill { - return nil - } - - err = syscall.Kill(pid, syscall.SIGTERM) - if errors.Is(err, syscall.ESRCH) { - return nil - } else if err != nil { - return fmt.Errorf("failed to send SIGTERM: %w", err) - } - case <-deadlineTicker.C: - break Loop - } - } - - shouldKill, err := s.shouldKillProcess(pid) - if err != nil { - return err - } - if !shouldKill { - return nil - } - - err = syscall.Kill(pid, syscall.SIGKILL) - if errors.Is(err, syscall.ESRCH) { - return nil - } else if err != nil { - return fmt.Errorf("failed to send SIGKILL: %w", err) - } - return nil -} - -// maybeKillPidFile checks kills the process in the pidFile if it's has -// the same binary as the supervisor's and also checks that the env -// `_KOS_MANAGED=yes`. This function does not delete the old pidFile as -// this is done by the caller. -func (s *Supervisor) maybeKillPidFile() error { - pid, err := os.ReadFile(s.PidFile) - if os.IsNotExist(err) { - return nil - } else if err != nil { - return fmt.Errorf("failed to read pid file %s: %w", s.PidFile, err) - } - - p, err := strconv.Atoi(strings.TrimSuffix(string(pid), "\n")) - if err != nil { - return fmt.Errorf("failed to parse pid file %s: %w", s.PidFile, err) - } - - if err := s.killPid(p); err != nil { - return fmt.Errorf("failed to kill process with PID %d: %w", p, err) - } - - return nil -} - -func (s *Supervisor) shouldKillProcess(pid int) (bool, error) { - cmdline, err := os.ReadFile(filepath.Join("/proc", strconv.Itoa(pid), "cmdline")) - if os.IsNotExist(err) { - return false, nil - } else if err != nil { - return false, fmt.Errorf("failed to read process cmdline: %w", err) - } - - // only kill process if it has the expected cmd - cmd := strings.Split(string(cmdline), "\x00") - if cmd[0] != s.BinPath { - return false, nil - } - - //only kill process if it has the _KOS_MANAGED env set - env, err := os.ReadFile(filepath.Join("/proc", strconv.Itoa(pid), "environ")) - if os.IsNotExist(err) { - return false, nil - } else if err != nil { - return false, fmt.Errorf("failed to read process environ: %w", err) - } - - for _, e := range strings.Split(string(env), "\x00") { - if e == k0sManaged { - return true, nil - } - } - return false, nil -}