diff --git a/internal/pkg/flock/flock.go b/internal/pkg/flock/flock.go new file mode 100644 index 000000000000..12f5f6b8bbd1 --- /dev/null +++ b/internal/pkg/flock/flock.go @@ -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 +} diff --git a/pkg/config/runtime_unix.go b/internal/pkg/flock/flock_unix.go similarity index 52% rename from pkg/config/runtime_unix.go rename to internal/pkg/flock/flock_unix.go index 134e6a863ee6..e242a042c565 100644 --- a/pkg/config/runtime_unix.go +++ b/internal/pkg/flock/flock_unix.go @@ -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. @@ -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 } diff --git a/internal/pkg/flock/flock_windows.go b/internal/pkg/flock/flock_windows.go new file mode 100644 index 000000000000..62f10e6df4cc --- /dev/null +++ b/internal/pkg/flock/flock_windows.go @@ -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 +} diff --git a/pkg/config/runtime.go b/pkg/config/runtime.go index a94c67f8869f..b201637f69d4 100644 --- a/pkg/config/runtime.go +++ b/pkg/config/runtime.go @@ -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" @@ -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) { @@ -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 } @@ -118,7 +125,7 @@ func NewRuntimeConfig(k0sVars *CfgVars) (*RuntimeConfigSpec, error) { Spec: &RuntimeConfigSpec{ NodeConfig: nodeConfig, K0sVars: k0sVars, - Pid: os.Getpid(), + lock: lock, }, } @@ -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) } @@ -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 } diff --git a/pkg/config/runtime_test.go b/pkg/config/runtime_test.go index 390a534d6eb1..7b80586e10d5 100644 --- a/pkg/config/runtime_test.go +++ b/pkg/config/runtime_test.go @@ -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(`--- @@ -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) { @@ -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) @@ -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()) } diff --git a/pkg/config/runtime_windows.go b/pkg/config/runtime_windows.go deleted file mode 100644 index dd8903c93c0d..000000000000 --- a/pkg/config/runtime_windows.go +++ /dev/null @@ -1,33 +0,0 @@ -/* -Copyright 2023 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 ( - "fmt" - - "golang.org/x/sys/windows" -) - -func checkPid(pid int) error { - procHandle, err := windows.OpenProcess(windows.PROCESS_QUERY_LIMITED_INFORMATION, false, uint32(pid)) - if err != nil { - return fmt.Errorf("failed to find process: %w", err) - } - defer func() { _ = windows.CloseHandle(procHandle) }() - - return nil -}