Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions core/cmd/cnpgi/common.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import (
"fmt"
"os"
"os/signal"
"path/filepath"
"syscall"
"time"

Expand Down Expand Up @@ -113,10 +114,13 @@ func runCNPGI(
}
}

// Add config file watcher so the sidecar restarts when the config changes
// Watch the whole config mount, not only the file behind --config: the
// restore and WAL services read the recovery source and replica source
// configurations from sibling files, and a rotated Secret must restart
// the sidecar for those too.
if configFile != "" {
if err := mgr.Add(
cnpgi.NewConfigFileWatcher(configFile, 10*time.Second),
cnpgi.NewConfigFileWatcher(filepath.Dir(configFile), 10*time.Second),
); err != nil {
return fmt.Errorf("while adding config watcher: %w", err)
}
Expand Down
12 changes: 12 additions & 0 deletions core/cmd/cnpgi/instance.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,8 +55,15 @@ var instanceCmd = &cobra.Command{
podName, _ := cmd.Flags().GetString("pod-name")
clusterName, _ := cmd.Flags().GetString("cluster-name")
clusterNamespace, _ := cmd.Flags().GetString("cluster-namespace")
pgData, _ := cmd.Flags().GetString("pgdata")

// One sidecar serves every phase of the instance: the restore hooks
// while the cluster bootstraps from a Klio backup, backup and WAL
// archiving afterwards. Each service picks its repository per request
// from the cluster definition, so nothing has to be swapped once the
// recovery completes, and a backup requested meanwhile is served.
capabilities := func(server *cnpgi.CNPGI) {
server.AddRestoreCapability(pgData)
server.AddBackupCapability(cnpgi.BackupCapabilityOptions{
Tier2: configuration.Tier2BackupEnabled,
})
Expand Down Expand Up @@ -115,6 +122,11 @@ func init() {
"",
"The name of the current instance",
)
instanceCmd.Flags().String(
"pgdata",
"/var/lib/postgresql/data/pgdata",
"The PGDATA directory a restore is unpacked into",
)

CnpgiCmd.AddCommand(instanceCmd)
}
99 changes: 0 additions & 99 deletions core/cmd/cnpgi/restore.go

This file was deleted.

64 changes: 53 additions & 11 deletions core/internal/cnpgi/configwatcher.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import (
"errors"
"fmt"
"os"
"path/filepath"
"time"

"github.com/cloudnative-pg/machinery/pkg/log"
Expand All @@ -35,25 +36,26 @@ import (
// ErrConfigFileChanged is returned when the config file changes on disk.
var ErrConfigFileChanged = errors.New("config file changed, restarting")

// NewConfigFileWatcher creates a manager.RunnableFunc that polls a config file
// and returns an error when its content changes. This causes the manager to
// shut down, and the kubelet will restart the container.
// NewConfigFileWatcher creates a manager.RunnableFunc that polls a config file,
// or every file in a config directory, and returns an error when the content
// changes. This causes the manager to shut down, and the kubelet will restart
// the container.
//
// Polling is used instead of fsnotify because Kubernetes updates secret
// volumes via symlink swaps, which fsnotify may not detect reliably.
func NewConfigFileWatcher(
configFile string,
configPath string,
interval time.Duration,
) manager.RunnableFunc {
return func(ctx context.Context) error {
logger := log.FromContext(ctx).WithName("config-watcher")

initialHash, err := hashFile(configFile)
initialHash, err := hashPath(configPath)
if err != nil {
return fmt.Errorf("while reading initial config file: %w", err)
return fmt.Errorf("while reading initial config: %w", err)
}

logger.Info("Config file watcher started", "file", configFile)
logger.Info("Config watcher started", "path", configPath)

ticker := time.NewTicker(interval)
defer ticker.Stop()
Expand All @@ -63,22 +65,62 @@ func NewConfigFileWatcher(
case <-ctx.Done():
return nil
case <-ticker.C:
currentHash, err := hashFile(configFile)
currentHash, err := hashPath(configPath)
if err != nil {
logger.Error(err, "Failed to read config file, will retry")
logger.Error(err, "Failed to read config, will retry")
continue
}

if currentHash != initialHash {
logger.Info("Config file changed, shutting down for restart",
"file", configFile)
logger.Info("Config changed, shutting down for restart",
"path", configPath)
return ErrConfigFileChanged
}
}
}
}
}

// hashPath hashes a file, or every regular file directly inside a directory
// together with its name. Directory entries are resolved through symlinks so
// the projected Secret volume layout (`<key>` linking into `..data/`) is
// followed, while its internal directories are skipped.
func hashPath(path string) (string, error) {
info, err := os.Stat(path)
if err != nil {
return "", fmt.Errorf("while reading path %q: %w", path, err)
}
if !info.IsDir() {
return hashFile(path)
}

entries, err := os.ReadDir(path)
if err != nil {
return "", fmt.Errorf("while reading directory %q: %w", path, err)
}

hash := sha256.New()
for _, entry := range entries {
entryPath := filepath.Join(path, entry.Name())
entryInfo, err := os.Stat(entryPath)
if err != nil {
return "", fmt.Errorf("while reading path %q: %w", entryPath, err)
}
if entryInfo.IsDir() {
continue
}

fileHash, err := hashFile(entryPath)
if err != nil {
return "", err
}
hash.Write([]byte(entry.Name()))
hash.Write([]byte(fileHash))
}

return hex.EncodeToString(hash.Sum(nil)), nil
}

// hashFile reads a file and returns its SHA256 hash as a hex string.
func hashFile(path string) (string, error) {
data, err := os.ReadFile(path) //nolint:gosec
Expand Down
110 changes: 109 additions & 1 deletion core/internal/cnpgi/configwatcher_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ func TestConfigFileWatcherInitialReadFailure(t *testing.T) {

err := watcher(ctx)
require.Error(t, err)
assert.Contains(t, err.Error(), "while reading initial config file")
assert.Contains(t, err.Error(), "while reading initial config")
}

func TestConfigFileWatcherTransientReadError(t *testing.T) {
Expand Down Expand Up @@ -181,3 +181,111 @@ func TestHashFile(t *testing.T) {
assert.NotEmpty(t, hash) // SHA256 of empty string is a valid hash
})
}

// writeProjectedSecret lays out a directory the way the kubelet does for a
// projected Secret volume: a timestamped data directory, a `..data` symlink
// pointing at it, and one symlink per key at the top level.
func writeProjectedSecret(t *testing.T, dir string, files map[string]string) {
t.Helper()

dataDir := filepath.Join(dir, "..2026_09_16_00_00_00.000000000")
require.NoError(t, os.MkdirAll(dataDir, 0o700))
for name, content := range files {
require.NoError(t, os.WriteFile(filepath.Join(dataDir, name), []byte(content), 0o600))
_ = os.Remove(filepath.Join(dir, name))
require.NoError(t, os.Symlink(filepath.Join("..data", name), filepath.Join(dir, name)))
}
_ = os.Remove(filepath.Join(dir, "..data"))
require.NoError(t, os.Symlink(filepath.Base(dataDir), filepath.Join(dir, "..data")))
}

func TestHashPathDirectory(t *testing.T) {
t.Run("follows the projected secret layout", func(t *testing.T) {
dir := t.TempDir()
writeProjectedSecret(t, dir, map[string]string{
"klio-archive": "archive",
"source": "recovery source",
})

hash, err := hashPath(dir)
require.NoError(t, err)
assert.NotEmpty(t, hash)
})

t.Run("changes when a sibling file changes", func(t *testing.T) {
dir := t.TempDir()
writeProjectedSecret(t, dir, map[string]string{
"klio-archive": "archive",
"source": "recovery source",
})
before, err := hashPath(dir)
require.NoError(t, err)

writeProjectedSecret(t, dir, map[string]string{
"klio-archive": "archive",
"source": "rotated recovery source",
})
after, err := hashPath(dir)
require.NoError(t, err)

assert.NotEqual(t, before, after)
})

t.Run("changes when a file is renamed", func(t *testing.T) {
dir := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(dir, "a"), []byte("same"), 0o600))
before, err := hashPath(dir)
require.NoError(t, err)

require.NoError(t, os.Rename(filepath.Join(dir, "a"), filepath.Join(dir, "b")))
after, err := hashPath(dir)
require.NoError(t, err)

assert.NotEqual(t, before, after)
})

t.Run("is stable across polls", func(t *testing.T) {
dir := t.TempDir()
writeProjectedSecret(t, dir, map[string]string{"klio-archive": "archive"})

first, err := hashPath(dir)
require.NoError(t, err)
second, err := hashPath(dir)
require.NoError(t, err)

assert.Equal(t, first, second)
})

t.Run("hashes a plain file like hashFile", func(t *testing.T) {
file := filepath.Join(t.TempDir(), testConfigFileName)
require.NoError(t, os.WriteFile(file, []byte("content"), 0o600))

fromPath, err := hashPath(file)
require.NoError(t, err)
fromFile, err := hashFile(file)
require.NoError(t, err)

assert.Equal(t, fromFile, fromPath)
})
}

func TestConfigFileWatcherDetectsSiblingChange(t *testing.T) {
dir := t.TempDir()
writeProjectedSecret(t, dir, map[string]string{
"klio-archive": "archive",
"source": "recovery source",
})

watcher := NewConfigFileWatcher(dir, 50*time.Millisecond)

go func() {
time.Sleep(100 * time.Millisecond)
// Rewrite the file through its symlink, as a Secret rotation would.
_ = os.WriteFile(filepath.Join(dir, "source"), []byte("rotated recovery source"), 0o600)
}()

ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()

assert.ErrorIs(t, watcher(ctx), ErrConfigFileChanged)
}
2 changes: 1 addition & 1 deletion documentation/web/docs/user/api/_klio_api.md
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,7 @@ _Appears in:_
| `clusterName` _string_ | ClusterName is the name of the PostgreSQL cluster we are connecting to | True | | MinLength: 1 <br />Required: \{\} <br /> |
| `pprof` _boolean_ | Pprof enables the pprof endpoint for performance profiling | | | Optional: \{\} <br /> |
| `mode` _[ServerMode](#servermode)_ | Mode selects the operation mode of the plugin. | True | standard | Enum: [standard read-only] <br /> |
| `containers` _[Container](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.37/#container-v1-core) array_ | Containers allows defining a list of containers that will be merged with the Klio sidecar containers.<br />This enables users to customize the sidecars with additional environment variables, volume mounts,<br />resource limits, and other container settings without polluting the PostgreSQL container environment.<br />Merge behavior:<br />- Containers are matched by name (klio-plugin, klio-restore)<br />- User customizations serve as the base<br />- Klio required values (name, args, CONTAINER_NAME env var) always override user values<br />- User-defined environment variables and volume mounts are preserved<br />- Template defaults are applied only for fields not set by the user or Klio | | | MaxItems: 2 <br />Optional: \{\} <br /> |
| `containers` _[Container](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.37/#container-v1-core) array_ | Containers allows defining a list of containers that will be merged with the Klio sidecar containers.<br />This enables users to customize the sidecars with additional environment variables, volume mounts,<br />resource limits, and other container settings without polluting the PostgreSQL container environment.<br />Merge behavior:<br />- Containers are matched by name (klio-plugin)<br />- User customizations serve as the base<br />- Klio required values (name, args, CONTAINER_NAME env var) always override user values<br />- User-defined environment variables and volume mounts are preserved<br />- Template defaults are applied only for fields not set by the user or Klio | | | MaxItems: 1 <br />Optional: \{\} <br /> |


#### PluginConfigurationStatus
Expand Down
Loading
Loading