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 <[email protected]>
  • Loading branch information
ncopa committed Jan 16, 2025
1 parent 7edf778 commit 92be067
Show file tree
Hide file tree
Showing 6 changed files with 161 additions and 68 deletions.
55 changes: 55 additions & 0 deletions internal/pkg/flock/flock.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
/*
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 flock

import (
"os"
)

// Flock represents a file-based lock
// The lock is tied to the lifetime of the associated process.
type Flock struct {
path string
file *os.File
}

// New creates a new Flock instance.
func New(path string) *Flock {
return &Flock{
path: path,
}
}

// Unlock releases the lock.
func (f *Flock) Unlock() error {
if err := f.file.Close(); err != nil {
return err
}

f.file = nil
return nil
}

// Close is an alias for Unlock, ensuring compatibility with the cleanup pattern in the provided code.
func (f *Flock) Close() error {
return f.Unlock()
}

// Path returns the file path associated with the lock.
func (f *Flock) Path() string {
return f.path
}
26 changes: 15 additions & 11 deletions pkg/config/runtime_unix.go → internal/pkg/flock/flock_unix.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
//go:build unix

/*
Copyright 2023 k0s authors
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.
Expand All @@ -16,23 +16,27 @@ See the License for the specific language governing permissions and
limitations under the License.
*/

package config
package flock

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

func checkPid(pid int) error {
proc, err := os.FindProcess(pid)
// TryLock attempts to acquire the lock. Returns true if successful, false otherwise.
func (f *Flock) TryLock() (bool, error) {
file, err := os.OpenFile(f.path, os.O_CREATE|os.O_RDWR, 0600)
if err != nil {
return fmt.Errorf("failed to find process: %w", err)
return false, err
}

if err := proc.Signal(syscall.Signal(0)); err != nil {
return fmt.Errorf("failed to signal process: %w", err)
if err := unix.Flock(int(file.Fd()), unix.LOCK_EX|unix.LOCK_NB); err != nil {
_ = file.Close()
if err == unix.EWOULDBLOCK {
return false, nil // Lock is already held by another process
}
return false, err
}

return nil
f.file = file
return true, nil
}
51 changes: 51 additions & 0 deletions internal/pkg/flock/flock_windows.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
/*
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 flock

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

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

handle := windows.Handle(file.Fd())
overlapped := new(windows.Overlapped)
err = windows.LockFileEx(
handle,
windows.LOCKFILE_EXCLUSIVE_LOCK|windows.LOCKFILE_FAIL_IMMEDIATELY,
0,
1,
0,
overlapped,
)
if err != nil {
file.Close()
if err == windows.ERROR_LOCK_VIOLATION {
return false, nil // Lock is already held by another process
}
return false, err
}

f.file = file
return true, nil
}
49 changes: 30 additions & 19 deletions pkg/config/runtime.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import (
"path/filepath"

"github.com/k0sproject/k0s/internal/pkg/dir"
"github.com/k0sproject/k0s/internal/pkg/flock"
"github.com/k0sproject/k0s/pkg/apis/k0s/v1beta1"
"github.com/k0sproject/k0s/pkg/constant"

Expand Down Expand Up @@ -56,7 +57,7 @@ type RuntimeConfig struct {
type RuntimeConfigSpec struct {
NodeConfig *v1beta1.ClusterConfig `json:"nodeConfig"`
K0sVars *CfgVars `json:"k0sVars"`
Pid int `json:"pid"`
lock *flock.Flock
}

func LoadRuntimeConfig(path string) (*RuntimeConfigSpec, error) {
Expand All @@ -83,22 +84,28 @@ func LoadRuntimeConfig(path string) (*RuntimeConfigSpec, error) {
return nil, fmt.Errorf("%w: spec is nil", ErrInvalidRuntimeConfig)
}

// 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
}

func NewRuntimeConfig(k0sVars *CfgVars) (*RuntimeConfigSpec, error) {
if _, err := LoadRuntimeConfig(k0sVars.RuntimeConfigPath); err == nil {
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

lock := flock.New(k0sVars.RuntimeConfigPath + ".lock")
locked, err := lock.TryLock()
if err != nil {
return nil, fmt.Errorf("failed to aquire lock on runtime config: %w", err)
}
if !locked {
return nil, ErrK0sAlreadyRunning
}

Expand All @@ -118,7 +125,7 @@ func NewRuntimeConfig(k0sVars *CfgVars) (*RuntimeConfigSpec, error) {
Spec: &RuntimeConfigSpec{
NodeConfig: nodeConfig,
K0sVars: k0sVars,
Pid: os.Getpid(),
lock: lock,
},
}

Expand All @@ -127,10 +134,6 @@ func NewRuntimeConfig(k0sVars *CfgVars) (*RuntimeConfigSpec, 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 @@ -144,7 +147,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.lock.Close(); err != nil {
return fmt.Errorf("failed to unlock runtime config: %w", err)
}

if err := os.Remove(r.lock.Path()); err != nil {
return fmt.Errorf("failed to delete %s: %w", r.lock.Path(), err)
}
return nil
}
15 changes: 10 additions & 5 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)
require.NoError(t, err)
assert.NotNil(t, spec)
require.NoError(t, spec.Cleanup())
}

func TestNewRuntimeConfig(t *testing.T) {
Expand Down Expand Up @@ -74,7 +79,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)
cfg, err := spec.K0sVars.NodeConfig()
assert.NoError(t, err)
Expand All @@ -85,4 +89,5 @@ func TestNewRuntimeConfig(t *testing.T) {
_, err = NewRuntimeConfig(k0sVars)
assert.Error(t, err)
assert.ErrorIs(t, err, ErrK0sAlreadyRunning)
require.NoError(t, spec.Cleanup())
}
33 changes: 0 additions & 33 deletions pkg/config/runtime_windows.go

This file was deleted.

0 comments on commit 92be067

Please sign in to comment.