diff --git a/lib/checkpoint_options.go b/lib/checkpoint_options.go new file mode 100644 index 00000000..87e03cb8 --- /dev/null +++ b/lib/checkpoint_options.go @@ -0,0 +1,136 @@ +// SPDX-License-Identifier: Apache-2.0 + +package metadata + +import ( + "fmt" +) + +// OptionType represents the type of a checkpoint option value +type OptionType int + +const ( + OptionTypeBool OptionType = iota + OptionTypeInt + OptionTypeString +) + +// CheckpointOption defines a known checkpoint option with its expected type +type CheckpointOption struct { + Type OptionType +} + +// SupportedCheckpointOption defines a supported checkpoint option with help text +// for use by tools that include this package. +type SupportedCheckpointOption struct { + Type OptionType + Help string +} + +// SupportedCheckpointOptions lists all checkpoint options that are currently +// supported. Tools can use this to display help text or list available options. +var SupportedCheckpointOptions = map[string]SupportedCheckpointOption{ + "leave-running": { + Type: OptionTypeBool, + Help: "leave container(s) in running state after checkpointing", + }, +} + +// CheckpointOptions represents the options from a CheckpointPodRequest +type CheckpointOptions struct { + // LeaveRunning leaves the processes running in the container after checkpointing + LeaveRunning bool `json:"leaveRunning,omitempty"` + // TCPEstablished enables support for established TCP connections in the checkpoint + TCPEstablished bool `json:"tcpEstablished,omitempty"` + // GhostLimit limits max size of deleted file contents inside image + GhostLimit int64 `json:"ghostLimit,omitempty"` + // NetworkLock specifies the network locking/unlocking method + NetworkLock string `json:"networkLock,omitempty"` +} + +// knownCheckpointOptions defines all known checkpoint options and their expected types. +// It is built from SupportedCheckpointOptions plus additional options that are not yet +// supported but are defined here to ensure the parsing logic for all option types +// (bool, int, string) is tested and works. +var knownCheckpointOptions map[string]CheckpointOption + +func init() { + knownCheckpointOptions = make(map[string]CheckpointOption) + + // Include all supported options + for name, opt := range SupportedCheckpointOptions { + knownCheckpointOptions[name] = CheckpointOption{Type: opt.Type} + } + + // Additional options for testing different option types (not yet supported) + knownCheckpointOptions["tcp-established"] = CheckpointOption{Type: OptionTypeBool} + knownCheckpointOptions["ghost-limit"] = CheckpointOption{Type: OptionTypeInt} + knownCheckpointOptions["network-lock"] = CheckpointOption{Type: OptionTypeString} +} + +// parseBool parses a string value as a boolean, accepting: +// yes, no, true, false, on, off, 0, 1 (case-insensitive) +func parseBool(value string) (bool, error) { + switch value { + case "yes", "Yes", "YES", "true", "True", "TRUE", "on", "On", "ON", "1": + return true, nil + case "no", "No", "NO", "false", "False", "FALSE", "off", "Off", "OFF", "0": + return false, nil + default: + return false, fmt.Errorf("invalid boolean value: %q (accepted: yes, no, true, false, on, off, 0, 1)", value) + } +} + +// ParseCheckpointOptions validates and parses checkpoint options from a map[string]string. +// It checks if the options are known and if their values match the expected types. +// Returns a CheckpointOptions struct and any validation errors encountered. +func ParseCheckpointOptions(options map[string]string) (*CheckpointOptions, error) { + result := &CheckpointOptions{} + var errs []string + + for key, value := range options { + opt, known := knownCheckpointOptions[key] + if !known { + errs = append(errs, fmt.Sprintf("unknown option: %q", key)) + continue + } + + switch opt.Type { + case OptionTypeBool: + boolVal, err := parseBool(value) + if err != nil { + errs = append(errs, fmt.Sprintf("option %q: %v", key, err)) + continue + } + switch key { + case "leave-running": + result.LeaveRunning = boolVal + case "tcp-established": + result.TCPEstablished = boolVal + } + + case OptionTypeInt: + var intVal int64 + if _, err := fmt.Sscanf(value, "%d", &intVal); err != nil { + errs = append(errs, fmt.Sprintf("option %q: invalid integer value: %q", key, value)) + continue + } + switch key { + case "ghost-limit": + result.GhostLimit = intVal + } + + case OptionTypeString: + switch key { + case "network-lock": + result.NetworkLock = value + } + } + } + + if len(errs) > 0 { + return result, fmt.Errorf("validation errors: %v", errs) + } + + return result, nil +} diff --git a/lib/checkpoint_options_test.go b/lib/checkpoint_options_test.go new file mode 100644 index 00000000..97448159 --- /dev/null +++ b/lib/checkpoint_options_test.go @@ -0,0 +1,339 @@ +// SPDX-License-Identifier: Apache-2.0 + +package metadata + +import ( + "strings" + "testing" +) + +func TestParseBool(t *testing.T) { + tests := []struct { + name string + value string + wantBool bool + wantError bool + }{ + // True values + {name: "yes lowercase", value: "yes", wantBool: true, wantError: false}, + {name: "Yes capitalized", value: "Yes", wantBool: true, wantError: false}, + {name: "YES uppercase", value: "YES", wantBool: true, wantError: false}, + {name: "true lowercase", value: "true", wantBool: true, wantError: false}, + {name: "True capitalized", value: "True", wantBool: true, wantError: false}, + {name: "TRUE uppercase", value: "TRUE", wantBool: true, wantError: false}, + {name: "on lowercase", value: "on", wantBool: true, wantError: false}, + {name: "On capitalized", value: "On", wantBool: true, wantError: false}, + {name: "ON uppercase", value: "ON", wantBool: true, wantError: false}, + {name: "1", value: "1", wantBool: true, wantError: false}, + // False values + {name: "no lowercase", value: "no", wantBool: false, wantError: false}, + {name: "No capitalized", value: "No", wantBool: false, wantError: false}, + {name: "NO uppercase", value: "NO", wantBool: false, wantError: false}, + {name: "false lowercase", value: "false", wantBool: false, wantError: false}, + {name: "False capitalized", value: "False", wantBool: false, wantError: false}, + {name: "FALSE uppercase", value: "FALSE", wantBool: false, wantError: false}, + {name: "off lowercase", value: "off", wantBool: false, wantError: false}, + {name: "Off capitalized", value: "Off", wantBool: false, wantError: false}, + {name: "OFF uppercase", value: "OFF", wantBool: false, wantError: false}, + {name: "0", value: "0", wantBool: false, wantError: false}, + // Invalid values + {name: "invalid string", value: "invalid", wantBool: false, wantError: true}, + {name: "empty string", value: "", wantBool: false, wantError: true}, + {name: "yEs mixed case", value: "yEs", wantBool: false, wantError: true}, + {name: "number 2", value: "2", wantBool: false, wantError: true}, + {name: "whitespace", value: " yes", wantBool: false, wantError: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := parseBool(tt.value) + if tt.wantError { + if err == nil { + t.Errorf("parseBool(%q) expected error, got nil", tt.value) + } + return + } + if err != nil { + t.Errorf("parseBool(%q) unexpected error: %v", tt.value, err) + return + } + if got != tt.wantBool { + t.Errorf("parseBool(%q) = %v, want %v", tt.value, got, tt.wantBool) + } + }) + } +} + +func TestParseCheckpointOptions(t *testing.T) { + tests := []struct { + name string + options map[string]string + wantLeave bool + wantTCP bool + wantGhost int64 + wantNetwork string + wantError bool + wantErrStrings []string + }{ + { + name: "empty options", + options: map[string]string{}, + }, + { + name: "nil options", + options: nil, + }, + { + name: "leave-running true", + options: map[string]string{ + "leave-running": "yes", + }, + wantLeave: true, + }, + { + name: "leave-running false", + options: map[string]string{ + "leave-running": "no", + }, + wantLeave: false, + }, + { + name: "tcp-established true", + options: map[string]string{ + "tcp-established": "true", + }, + wantTCP: true, + }, + { + name: "tcp-established false", + options: map[string]string{ + "tcp-established": "0", + }, + wantTCP: false, + }, + { + name: "ghost-limit valid", + options: map[string]string{ + "ghost-limit": "1048576", + }, + wantGhost: 1048576, + }, + { + name: "ghost-limit zero", + options: map[string]string{ + "ghost-limit": "0", + }, + wantGhost: 0, + }, + { + name: "ghost-limit negative", + options: map[string]string{ + "ghost-limit": "-100", + }, + wantGhost: -100, + }, + { + name: "network-lock valid", + options: map[string]string{ + "network-lock": "nftables", + }, + wantNetwork: "nftables", + }, + { + name: "network-lock empty string", + options: map[string]string{ + "network-lock": "", + }, + wantNetwork: "", + }, + { + name: "all options combined", + options: map[string]string{ + "leave-running": "on", + "tcp-established": "1", + "ghost-limit": "2097152", + "network-lock": "iptables", + }, + wantLeave: true, + wantTCP: true, + wantGhost: 2097152, + wantNetwork: "iptables", + }, + { + name: "unknown option", + options: map[string]string{ + "unknown-option": "value", + }, + wantError: true, + wantErrStrings: []string{"unknown option", "unknown-option"}, + }, + { + name: "invalid boolean value", + options: map[string]string{ + "leave-running": "maybe", + }, + wantError: true, + wantErrStrings: []string{"leave-running", "invalid boolean value"}, + }, + { + name: "invalid integer value", + options: map[string]string{ + "ghost-limit": "not-a-number", + }, + wantError: true, + wantErrStrings: []string{"ghost-limit", "invalid integer value"}, + }, + { + name: "integer with text", + options: map[string]string{ + "ghost-limit": "100abc", + }, + wantGhost: 100, + }, + { + name: "multiple errors", + options: map[string]string{ + "unknown-option": "value", + "leave-running": "invalid", + "tcp-established": "also-invalid", + }, + wantError: true, + wantErrStrings: []string{"unknown option", "leave-running", "tcp-established"}, + }, + { + name: "valid and invalid options mixed", + options: map[string]string{ + "leave-running": "yes", + "ghost-limit": "not-valid", + }, + wantLeave: true, + wantError: true, + wantErrStrings: []string{"ghost-limit", "invalid integer value"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := ParseCheckpointOptions(tt.options) + + if tt.wantError { + if err == nil { + t.Errorf("ParseCheckpointOptions() expected error, got nil") + return + } + for _, substr := range tt.wantErrStrings { + if !strings.Contains(err.Error(), substr) { + t.Errorf("error %q should contain %q", err.Error(), substr) + } + } + } else { + if err != nil { + t.Errorf("ParseCheckpointOptions() unexpected error: %v", err) + return + } + } + + if result == nil { + t.Errorf("ParseCheckpointOptions() returned nil result") + return + } + + if result.LeaveRunning != tt.wantLeave { + t.Errorf("LeaveRunning = %v, want %v", result.LeaveRunning, tt.wantLeave) + } + if result.TCPEstablished != tt.wantTCP { + t.Errorf("TCPEstablished = %v, want %v", result.TCPEstablished, tt.wantTCP) + } + if result.GhostLimit != tt.wantGhost { + t.Errorf("GhostLimit = %v, want %v", result.GhostLimit, tt.wantGhost) + } + if result.NetworkLock != tt.wantNetwork { + t.Errorf("NetworkLock = %q, want %q", result.NetworkLock, tt.wantNetwork) + } + }) + } +} + +func TestOptionTypeConstants(t *testing.T) { + // Verify the option types are distinct + if OptionTypeBool == OptionTypeInt { + t.Error("OptionTypeBool should not equal OptionTypeInt") + } + if OptionTypeBool == OptionTypeString { + t.Error("OptionTypeBool should not equal OptionTypeString") + } + if OptionTypeInt == OptionTypeString { + t.Error("OptionTypeInt should not equal OptionTypeString") + } +} + +func TestKnownCheckpointOptions(t *testing.T) { + // Verify known options are properly defined + expectedOptions := map[string]OptionType{ + "leave-running": OptionTypeBool, + "tcp-established": OptionTypeBool, + "ghost-limit": OptionTypeInt, + "network-lock": OptionTypeString, + } + + for name, expectedType := range expectedOptions { + opt, exists := knownCheckpointOptions[name] + if !exists { + t.Errorf("expected option %q to exist in knownCheckpointOptions", name) + continue + } + if opt.Type != expectedType { + t.Errorf("option %q has type %v, want %v", name, opt.Type, expectedType) + } + } + + if len(knownCheckpointOptions) != len(expectedOptions) { + t.Errorf("knownCheckpointOptions has %d entries, want %d", + len(knownCheckpointOptions), len(expectedOptions)) + } +} + +func TestSupportedCheckpointOptions(t *testing.T) { + // Verify supported options are properly defined with help text + expectedOptions := map[string]struct { + optType OptionType + help string + }{ + "leave-running": { + optType: OptionTypeBool, + help: "leave container(s) in running state after checkpointing", + }, + } + + for name, expected := range expectedOptions { + opt, exists := SupportedCheckpointOptions[name] + if !exists { + t.Errorf("expected option %q to exist in SupportedCheckpointOptions", name) + continue + } + if opt.Type != expected.optType { + t.Errorf("option %q has type %v, want %v", name, opt.Type, expected.optType) + } + if opt.Help != expected.help { + t.Errorf("option %q has help %q, want %q", name, opt.Help, expected.help) + } + } + + if len(SupportedCheckpointOptions) != len(expectedOptions) { + t.Errorf("SupportedCheckpointOptions has %d entries, want %d", + len(SupportedCheckpointOptions), len(expectedOptions)) + } + + // Verify all supported options are also in knownCheckpointOptions + for name, supported := range SupportedCheckpointOptions { + known, exists := knownCheckpointOptions[name] + if !exists { + t.Errorf("supported option %q not found in knownCheckpointOptions", name) + continue + } + if known.Type != supported.Type { + t.Errorf("option %q type mismatch: supported=%v, known=%v", + name, supported.Type, known.Type) + } + } +}