Skip to content

Commit

Permalink
Replace pid with flock for runtime config loading
Browse files Browse the repository at this point in the history
Use lock file and flock(2) to ensure there is only a single instance of
k0s running. This is more reliable than storing the pid in the runtime
config.

This solves false positives with k0s runtime config leftovers.

Fixes: #5399
Signed-off-by: Natanael Copa <ncopa@mirantis.com>
  • Loading branch information
ncopa committed Jan 20, 2025
1 parent abbbc19 commit d5e2254
Show file tree
Hide file tree
Showing 6 changed files with 178 additions and 94 deletions.
57 changes: 57 additions & 0 deletions pkg/config/flock_unix.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
//go:build unix

/*
Copyright 2025 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 config

import (
"golang.org/x/sys/unix"
"os"
)

// tryLock attempts to acquire the lock. Returns *os.File if successful, nil otherwise.
func tryLock(path string) (*os.File, error) {
file, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0600)
if err != nil {
return nil, err
}

if err := unix.Flock(int(file.Fd()), unix.LOCK_EX|unix.LOCK_NB); err != nil {
_ = file.Close()
if err == unix.EWOULDBLOCK {
return nil, ErrK0sAlreadyRunning // Lock is already held by another process
}
return nil, err
}
return file, nil
}

// isLocked checks if the lock is currently held by another process.
func isLocked(path string) bool {
file, err := os.OpenFile(path, os.O_RDWR, 0600)
if err != nil {
return false
}
defer file.Close()

// Attempt a non-blocking shared lock to test the lock state
if err := unix.Flock(int(file.Fd()), unix.LOCK_SH|unix.LOCK_NB); err != nil {
return err == unix.EWOULDBLOCK
}

return false
}
83 changes: 83 additions & 0 deletions pkg/config/flock_windows.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
//go:build windows

/*
Copyright 2025 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 config

import (
"golang.org/x/sys/windows"
"os"
)

// tryLock attempts to acquire the lock. Returns true if successful, false otherwise.
func tryLock(path string) (*os.File, error) {
file, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0600)
if err != nil {
return nil, err
}

handle := windows.Handle(file.Fd())
overlapped := new(windows.Overlapped) // The OVERLAPPED structure, required for asynchronous I/O operations

// Attempt to lock the file exclusively and fail immediately if it's already locked
// https://learn.microsoft.com/en-us/windows/win32/api/fileapi/nf-fileapi-lockfileex
err = windows.LockFileEx(
handle, // 1. HANDLE hFile: The handle to the file (must have GENERIC_READ or GENERIC_WRITE access)
windows.LOCKFILE_EXCLUSIVE_LOCK|windows.LOCKFILE_FAIL_IMMEDIATELY, // 2. DWORD dwFlags: Specifies the lock type and behavior
0, // 3. DWORD dwReserved: Reserved, must be zero
1, // 4. DWORD nNumberOfBytesToLockLow: Low-order part of the range of bytes to lock (1 byte in this case)
0, // 5. DWORD nNumberOfBytesToLockHigh: High-order part of the range of bytes to lock (0 for single-byte lock)
overlapped, // 6. LPOVERLAPPED lpOverlapped: Pointer to an OVERLAPPED structure, required for this function
)
if err != nil {
file.Close()
if err == windows.ERROR_LOCK_VIOLATION {
return nil, ErrK0sAlreadyRunning // Lock is already held by another process
}
return nil, err
}

return file, nil
}

// isLocked checks if the lock is currently held by another process.
func isLocked(path string) bool {
file, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0600)
if err != nil {
return false
}
defer file.Close()

handle := windows.Handle(file.Fd())
overlapped := new(windows.Overlapped)

// Try to acquire a shared lock without waiting
// https://learn.microsoft.com/en-us/windows/win32/api/fileapi/nf-fileapi-lockfileex
err = windows.LockFileEx(
handle, // 1. HANDLE hFile: The handle to the file (must have GENERIC_READ or GENERIC_WRITE access)
windows.LOCKFILE_FAIL_IMMEDIATELY, // Try without waiting
0, // 3. DWORD dwReserved: Reserved, must be zero
1, // 4. DWORD nNumberOfBytesToLockLow: Low-order part of the range of bytes to lock (1 byte in this case)
0, // 5. DWORD nNumberOfBytesToLockHigh: High-order part of the range of bytes to lock (0 for single-byte lock)
overlapped, // 6. LPOVERLAPPED lpOverlapped: Pointer to an OVERLAPPED structure, required for this function
)
if err != nil {
return true
}

return false
}
56 changes: 36 additions & 20 deletions pkg/config/runtime.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,10 +56,14 @@ type RuntimeConfig struct {
type RuntimeConfigSpec struct {
NodeConfig *v1beta1.ClusterConfig `json:"nodeConfig"`
K0sVars *CfgVars `json:"k0sVars"`
Pid int `json:"pid"`
lockFile *os.File
}

func LoadRuntimeConfig(path string) (*RuntimeConfigSpec, error) {
if !isLocked(path + ".lock") {
return nil, ErrK0sNotRunning
}

content, err := os.ReadFile(path)
if err != nil {
return nil, err
Expand All @@ -71,17 +75,6 @@ func LoadRuntimeConfig(path string) (*RuntimeConfigSpec, error) {
}
spec := config.Spec

// If a pid is defined but there's no process found, the instance of k0s is
// expected to have died, in which case the existing config is removed and
// an error is returned, which allows the controller startup to proceed to
// initialize a new runtime config.
if spec.Pid != 0 {
if err := checkPid(spec.Pid); err != nil {
defer func() { _ = spec.Cleanup() }()
return nil, errors.Join(ErrK0sNotRunning, err)
}
}

return spec, nil
}

Expand All @@ -108,8 +101,27 @@ func ParseRuntimeConfig(content []byte) (*RuntimeConfig, error) {
}

func NewRuntimeConfig(k0sVars *CfgVars) (*RuntimeConfig, error) {
if _, err := LoadRuntimeConfig(k0sVars.RuntimeConfigPath); err == nil {
return nil, ErrK0sAlreadyRunning
if err := dir.Init(filepath.Dir(k0sVars.RuntimeConfigPath), constant.RunDirMode); err != nil {
logrus.Warnf("failed to initialize runtime config dir: %v", err)
}

// A file lock is acquired using `flock(2)` to ensure that only one
// instance of the `k0s` process can modify the runtime configuration
// at a time. The lock is tied to the lifetime of the `k0s` process,
// meaning that if the process terminates unexpectedly, the lock is
// automatically released by the operating system. This ensures that
// subsequent processes can acquire the lock without manual cleanup.
// https://man7.org/linux/man-pages/man2/flock.2.html
//
// It works similar on Windows, but with LockFileEx

path, err := filepath.Abs(k0sVars.RuntimeConfigPath + ".lock")
if err != nil {
return nil, err
}
lockFile, err := tryLock(path)
if err != nil {
return nil, err
}

nodeConfig, err := k0sVars.NodeConfig()
Expand All @@ -128,7 +140,7 @@ func NewRuntimeConfig(k0sVars *CfgVars) (*RuntimeConfig, error) {
Spec: &RuntimeConfigSpec{
NodeConfig: nodeConfig,
K0sVars: k0sVars,
Pid: os.Getpid(),
lockFile: lockFile,
},
}

Expand All @@ -137,10 +149,6 @@ func NewRuntimeConfig(k0sVars *CfgVars) (*RuntimeConfig, error) {
return nil, err
}

if err := dir.Init(filepath.Dir(k0sVars.RuntimeConfigPath), constant.RunDirMode); err != nil {
logrus.Warnf("failed to initialize runtime config dir: %v", err)
}

if err := os.WriteFile(k0sVars.RuntimeConfigPath, content, 0600); err != nil {
return nil, fmt.Errorf("failed to write runtime config: %w", err)
}
Expand All @@ -154,7 +162,15 @@ func (r *RuntimeConfigSpec) Cleanup() error {
}

if err := os.Remove(r.K0sVars.RuntimeConfigPath); err != nil {
return fmt.Errorf("failed to clean up runtime config file: %w", err)
logrus.Warnf("failed to clean up runtime config file: %v", err)
}

if err := r.lockFile.Close(); err != nil {
return fmt.Errorf("failed to close the runtime config file: %w", err)
}

if err := os.Remove(r.lockFile.Name()); err != nil {
return fmt.Errorf("failed to delete %s: %w", r.lockFile.Name(), err)
}
return nil
}
5 changes: 2 additions & 3 deletions pkg/config/runtime_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ import (
"sigs.k8s.io/yaml"
)

func TestLoadRuntimeConfig_K0sNotRunning(t *testing.T) {
func TestLoadRuntimeConfig(t *testing.T) {
// write some content to the runtime config file
rtConfigPath := filepath.Join(t.TempDir(), "runtime-config")
content := []byte(`---
Expand All @@ -37,7 +37,6 @@ spec:
nodeConfig:
metadata:
name: k0s
pid: -1
`)
require.NoError(t, os.WriteFile(rtConfigPath, content, 0644))

Expand Down Expand Up @@ -71,11 +70,11 @@ func TestNewRuntimeConfig(t *testing.T) {

// create a new runtime config and check if it's valid
cfg, err := NewRuntimeConfig(k0sVars)
t.Cleanup(func() { assert.NoError(t, cfg.Spec.Cleanup()) })
spec := cfg.Spec
assert.NoError(t, err)
assert.NotNil(t, spec)
assert.Same(t, k0sVars, spec.K0sVars)
assert.Equal(t, os.Getpid(), spec.Pid)
assert.NotNil(t, spec.NodeConfig)
nodeConfig, err := spec.K0sVars.NodeConfig()
assert.NoError(t, err)
Expand Down
38 changes: 0 additions & 38 deletions pkg/config/runtime_unix.go

This file was deleted.

33 changes: 0 additions & 33 deletions pkg/config/runtime_windows.go

This file was deleted.

0 comments on commit d5e2254

Please # to comment.