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 17, 2025
1 parent 46b5ee1 commit 98d92f2
Show file tree
Hide file tree
Showing 6 changed files with 183 additions and 94 deletions.
60 changes: 60 additions & 0 deletions pkg/config/flock_unix.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
//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 *osFile 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
}

// Locked checks if the lock is currently held by another process.
func Locked(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 {
if err == unix.EWOULDBLOCK {

Check failure on line 53 in pkg/config/flock_unix.go

View workflow job for this annotation

GitHub Actions / Lint

S1008: should use 'return err == unix.EWOULDBLOCK' instead of 'if err == unix.EWOULDBLOCK { return true }; return false' (gosimple)
return true
}
return false
}

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
}

// Locked checks if the lock is currently held by another process.
func Locked(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
}
52 changes: 32 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 !Locked(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,23 @@ 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

lockFile, err := TryLock(k0sVars.RuntimeConfigPath + ".lock")
if err != nil {
return nil, err
}

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

Expand All @@ -137,10 +145,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 +158,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
}
11 changes: 8 additions & 3 deletions pkg/config/runtime_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,12 @@ import (
"sigs.k8s.io/yaml"
)

func TestLoadRuntimeConfig_K0sNotRunning(t *testing.T) {
func TestLoadRuntimeConfig(t *testing.T) {
// create a temporary file for runtime config
tmpfile, err := os.CreateTemp("", "runtime-config")
require.NoError(t, err)
defer os.Remove(tmpfile.Name())

// write some content to the runtime config file
rtConfigPath := filepath.Join(t.TempDir(), "runtime-config")
content := []byte(`---
Expand All @@ -37,14 +42,14 @@ spec:
nodeConfig:
metadata:
name: k0s
pid: -1
`)
require.NoError(t, os.WriteFile(rtConfigPath, content, 0644))

// try to load runtime config and check if it returns an error
spec, err := LoadRuntimeConfig(rtConfigPath)
assert.Nil(t, spec)
assert.ErrorIs(t, err, ErrK0sNotRunning)
t.Cleanup(func() { require.NoError(t, spec.Cleanup()) })
}

func TestNewRuntimeConfig(t *testing.T) {
Expand Down Expand Up @@ -75,7 +80,6 @@ func TestNewRuntimeConfig(t *testing.T) {
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 All @@ -86,4 +90,5 @@ func TestNewRuntimeConfig(t *testing.T) {
_, err = NewRuntimeConfig(k0sVars)
assert.Error(t, err)
assert.ErrorIs(t, err, ErrK0sAlreadyRunning)
t.Cleanup(func() { require.NoError(t, spec.Cleanup()) })
}
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 98d92f2

Please sign in to comment.