Files
kportal/internal/config/validator.go
T
f3d3f259a1 Allow configuring forwards-table columns, order, and width (#82)
* Allow configuring forwards-table columns, order, and width

Adds an optional tui.columns section to .kportal.yaml so the visible
columns, their order, and their widths can be customized instead of
always rendering the hardcoded 8-column layout. Omitting the section
keeps the existing default columns/order/widths.

Closes #81

* fix(ui): keep the plain table layout and check column widths

The plain verbose table must look the same when no tui section is
set. It had narrower columns and shorter port headers than before. The
default layout is now identical to the old one, and a golden test
guards it.

A status column that is not last now pads by visible width. Table
rules follow the column widths. The UI applies new columns only after a
reload succeeds, including the SIGHUP path of the verbose table.

The validator rejects a column width above 200. The README describes
the tui.columns setting.

Refs #81

* fix(httplog): replace the deprecated ReverseProxy Director with Rewrite

Staticcheck reports SA1019 because Go 1.26 deprecates Director. The
proxy now sets the target in a Rewrite function. Rewrite drops the
X-Forwarded-For header that Director sent, so the function calls
SetXForwarded to keep that header.

A new test checks the target port, the path and the header.

* fix(release): drop the deprecated cask url verified key

Homebrew warns about the verified parameter of the cask url stanza.
The setting is not needed. GoReleaser check now passes without a
deprecation warning.

The postflight warning remains. GoReleaser generates it from the
post install hook, and the upstream fix is not released yet.

Refs #79

---------

Co-authored-by: Lukasz Raczylo <[email protected]>
2026-10-02 02:24:11 +01:00

761 lines
24 KiB
Go

package config
import (
"fmt"
"regexp"
"strings"
"time"
)
const (
MinPort = 1
MaxPort = 65535
// DNS1123LabelMaxLength is the maximum length of a DNS label (RFC 1123)
DNS1123LabelMaxLength = 63
// DNS1123SubdomainMaxLength is the maximum length of a DNS subdomain name
DNS1123SubdomainMaxLength = 253
)
var (
// dns1123LabelRegexp matches valid DNS labels (RFC 1123)
// Must consist of lowercase alphanumeric characters or '-', start with alphanumeric, end with alphanumeric
dns1123LabelRegexp = regexp.MustCompile(`^[a-z0-9]([-a-z0-9]*[a-z0-9])?$`)
// dns1123SubdomainRegexp matches valid DNS subdomain names
// A series of DNS labels separated by dots (no consecutive dots allowed)
dns1123SubdomainRegexp = regexp.MustCompile(`^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$`)
// contextNameRegexp matches valid kubeconfig context names.
// kubeconfig itself imposes no character restriction; we accept the union
// of common naming conventions seen in the wild:
// - hyphens / underscores: minikube, docker-desktop, gke_proj_zone_cluster
// - "@": user@cluster (kubectl rename, EKS aws-iam-authenticator)
// - ".": cluster.example.com, GKE dotted names
// - ":" and "/": EKS ARNs (arn:aws:eks:us-east-1:123:cluster/foo)
// Must start and end with an alphanumeric character.
contextNameRegexp = regexp.MustCompile(`^[a-zA-Z0-9]([a-zA-Z0-9._:/@_-]*[a-zA-Z0-9])?$`)
// validResourceTypes contains the allowed Kubernetes resource types
validResourceTypes = []string{"pod", "service"}
// validHealthCheckMethods contains the allowed health check methods
validHealthCheckMethods = []string{"tcp-dial", "data-transfer"}
)
// IsValidPort returns true if the port number is within the valid range (1-65535).
func IsValidPort(port int) bool {
return port >= MinPort && port <= MaxPort
}
// ValidationError represents a configuration validation error with context.
type ValidationError struct {
Context map[string]string
Field string
Message string
}
// Validator validates configuration files.
type Validator struct{}
// NewValidator creates a new Validator instance.
func NewValidator() *Validator {
return &Validator{}
}
// ValidateConfig validates the entire configuration and returns all errors found.
func (v *Validator) ValidateConfig(cfg *Config) []ValidationError {
return v.ValidateConfigWithOptions(cfg, false)
}
// ValidateConfigWithOptions validates configuration with configurable strictness.
// When allowEmpty is true, empty configurations (no contexts/forwards) are allowed.
// This is useful for newly created config files where the user will add forwards via the TUI.
func (v *Validator) ValidateConfigWithOptions(cfg *Config, allowEmpty bool) []ValidationError {
var errs []ValidationError
if cfg == nil {
return []ValidationError{{
Field: "config",
Message: "Configuration is nil",
}}
}
// If empty configs are allowed and this config is empty, skip structure validation
if allowEmpty && cfg.IsEmpty() {
// Still validate health check and reliability if present (they don't require forwards)
errs = append(errs, v.validateSpecDurations(cfg)...)
errs = append(errs, v.validateTUI(cfg)...)
return errs
}
// Validate structure
errs = append(errs, v.validateStructure(cfg)...)
// Validate each forward
for _, ctx := range cfg.Contexts {
for _, ns := range ctx.Namespaces {
for _, fwd := range ns.Forwards {
errs = append(errs, v.validateForward(&fwd)...)
}
}
}
// Check for duplicate local ports
errs = append(errs, v.validateDuplicatePorts(cfg)...)
// Validate mDNS configuration
if cfg.IsMDNSEnabled() {
errs = append(errs, v.validateMDNS(cfg)...)
}
// Validate duration fields in specs
errs = append(errs, v.validateSpecDurations(cfg)...)
// Validate TUI table column configuration
errs = append(errs, v.validateTUI(cfg)...)
return errs
}
// validateStructure validates the basic structure of the configuration.
func (v *Validator) validateStructure(cfg *Config) []ValidationError {
var errs []ValidationError
if len(cfg.Contexts) == 0 {
errs = append(errs, ValidationError{
Field: "contexts",
Message: "Configuration must have at least one context",
})
return errs
}
for i, ctx := range cfg.Contexts {
if ctx.Name == "" {
errs = append(errs, ValidationError{
Field: fmt.Sprintf("contexts[%d].name", i),
Message: "Context name cannot be empty",
})
} else {
// Validate context name format (alphanumeric, hyphens, underscores)
if err := validateContextName(ctx.Name, fmt.Sprintf("contexts[%d].name", i)); err != nil {
errs = append(errs, *err)
}
}
if len(ctx.Namespaces) == 0 {
errs = append(errs, ValidationError{
Field: fmt.Sprintf("contexts[%d].namespaces", i),
Message: fmt.Sprintf("Context '%s' must have at least one namespace", ctx.Name),
})
// Don't continue - still validate other aspects of the context if any
}
for j, ns := range ctx.Namespaces {
if ns.Name == "" {
errs = append(errs, ValidationError{
Field: fmt.Sprintf("contexts[%d].namespaces[%d].name", i, j),
Message: fmt.Sprintf("Namespace name cannot be empty in context '%s'", ctx.Name),
})
} else {
// Validate namespace name follows DNS subdomain conventions (Kubernetes requirement)
if err := validateNamespaceName(ns.Name, fmt.Sprintf("contexts[%d].namespaces[%d].name", i, j)); err != nil {
errs = append(errs, *err)
}
}
if len(ns.Forwards) == 0 {
errs = append(errs, ValidationError{
Field: fmt.Sprintf("contexts[%d].namespaces[%d].forwards", i, j),
Message: fmt.Sprintf("Namespace '%s/%s' must have at least one forward", ctx.Name, ns.Name),
})
}
}
}
return errs
}
// validateForward validates a single forward configuration.
func (v *Validator) validateForward(fwd *Forward) []ValidationError {
var errs []ValidationError
// Validate resource
if fwd.Resource == "" {
errs = append(errs, ValidationError{
Field: "resource",
Message: fmt.Sprintf("Resource cannot be empty for forward %s", fwd.ID()),
})
} else {
errs = append(errs, v.validateResource(fwd)...)
}
// Validate protocol - only "tcp" is currently supported
if fwd.Protocol != "" && fwd.Protocol != "tcp" {
errs = append(errs, ValidationError{
Field: "protocol",
Message: fmt.Sprintf("Invalid protocol '%s' for forward %s (only 'tcp' is supported)", fwd.Protocol, fwd.ID()),
})
}
// Validate ports
if !IsValidPort(fwd.Port) {
errs = append(errs, ValidationError{
Field: "port",
Message: fmt.Sprintf("Invalid port %d for forward %s (must be between %d and %d)", fwd.Port, fwd.ID(), MinPort, MaxPort),
})
}
if !IsValidPort(fwd.LocalPort) {
errs = append(errs, ValidationError{
Field: "localPort",
Message: fmt.Sprintf("Invalid localPort %d for forward %s (must be between %d and %d)", fwd.LocalPort, fwd.ID(), MinPort, MaxPort),
})
}
// Note: Alias validation is handled in validateMDNS since aliases are primarily
// used for mDNS hostname registration. We only validate alias format when mDNS
// is enabled to avoid unnecessary restrictions on non-mDNS usage.
// Validate HTTP log configuration if enabled
if fwd.HTTPLog != nil && fwd.HTTPLog.Enabled {
errs = append(errs, v.validateHTTPLog(fwd)...)
}
return errs
}
// validateResource validates the resource field format and selector usage.
func (v *Validator) validateResource(fwd *Forward) []ValidationError {
var errs []ValidationError
// Validate resource format (must be "type/name" or just "type" for pod with selector)
parts := strings.SplitN(fwd.Resource, "/", 2)
resourceType := parts[0]
// Validate resource type
if !isValidResourceType(resourceType) {
errs = append(errs, ValidationError{
Field: "resource",
Message: fmt.Sprintf("Invalid resource type '%s' for forward %s (must be one of: %s)", resourceType, fwd.ID(), strings.Join(validResourceTypes, ", ")),
})
return errs
}
// Validate resource name if provided
if len(parts) == 2 {
resourceName := parts[1]
if resourceName == "" {
// Use resource-type-specific error message for better clarity
entityType := "Resource"
switch resourceType {
case "pod":
entityType = "Pod"
case "service":
entityType = "Service"
}
errs = append(errs, ValidationError{
Field: "resource",
Message: fmt.Sprintf("%s name cannot be empty for forward %s", entityType, fwd.ID()),
})
} else {
// Validate resource name follows DNS subdomain conventions
if err := validateDNS1123Subdomain(resourceName, "resource", "Resource name"); err != nil {
err.Message = fmt.Sprintf("%s for forward %s", err.Message, fwd.ID())
errs = append(errs, *err)
}
}
}
// For pod resources
if resourceType == "pod" {
if len(parts) == 2 {
// pod/name format - should not have selector
if fwd.Selector != "" {
errs = append(errs, ValidationError{
Field: "selector",
Message: fmt.Sprintf("Forward %s uses explicit pod name (%s) and should not have a selector", fwd.ID(), fwd.Resource),
})
}
} else if fwd.Selector == "" {
// pod (no name) - must have selector
errs = append(errs, ValidationError{
Field: "selector",
Message: fmt.Sprintf("Forward %s uses generic 'pod' resource and must have a selector", fwd.ID()),
})
}
}
// For service resources
if resourceType == "service" {
if len(parts) < 2 || parts[1] == "" {
errs = append(errs, ValidationError{
Field: "resource",
Message: fmt.Sprintf("Service name cannot be empty for forward %s (format: service/name)", fwd.ID()),
})
}
if fwd.Selector != "" {
errs = append(errs, ValidationError{
Field: "selector",
Message: fmt.Sprintf("Forward %s uses service resource and should not have a selector", fwd.ID()),
})
}
}
return errs
}
// validateDuplicatePorts checks for duplicate local ports across all forwards.
func (v *Validator) validateDuplicatePorts(cfg *Config) []ValidationError {
var errs []ValidationError
portMap := make(map[int][]string) // port -> list of forward IDs
for _, ctx := range cfg.Contexts {
for _, ns := range ctx.Namespaces {
for _, fwd := range ns.Forwards {
portMap[fwd.LocalPort] = append(portMap[fwd.LocalPort], fwd.ID())
}
}
}
// Find duplicates
for port, forwards := range portMap {
if len(forwards) > 1 {
errs = append(errs, ValidationError{
Field: "localPort",
Message: fmt.Sprintf("Duplicate local port %d used by multiple forwards", port),
Context: map[string]string{
"port": fmt.Sprintf("%d", port),
"forwards": strings.Join(forwards, ", "),
},
})
}
}
return errs
}
// validateSpecDurations validates duration strings in HealthCheck and Reliability specs.
func (v *Validator) validateSpecDurations(cfg *Config) []ValidationError {
var errs []ValidationError
// Validate HealthCheck durations
if cfg.HealthCheck != nil {
if cfg.HealthCheck.Interval != "" {
if _, err := time.ParseDuration(cfg.HealthCheck.Interval); err != nil {
errs = append(errs, ValidationError{
Field: "healthCheck.interval",
Message: fmt.Sprintf("Invalid health check interval '%s': %v", cfg.HealthCheck.Interval, err),
})
}
}
if cfg.HealthCheck.Timeout != "" {
if _, err := time.ParseDuration(cfg.HealthCheck.Timeout); err != nil {
errs = append(errs, ValidationError{
Field: "healthCheck.timeout",
Message: fmt.Sprintf("Invalid health check timeout '%s': %v", cfg.HealthCheck.Timeout, err),
})
}
}
if cfg.HealthCheck.MaxConnectionAge != "" {
if _, err := time.ParseDuration(cfg.HealthCheck.MaxConnectionAge); err != nil {
errs = append(errs, ValidationError{
Field: "healthCheck.maxConnectionAge",
Message: fmt.Sprintf("Invalid max connection age '%s': %v", cfg.HealthCheck.MaxConnectionAge, err),
})
}
}
if cfg.HealthCheck.MaxIdleTime != "" {
if _, err := time.ParseDuration(cfg.HealthCheck.MaxIdleTime); err != nil {
errs = append(errs, ValidationError{
Field: "healthCheck.maxIdleTime",
Message: fmt.Sprintf("Invalid max idle time '%s': %v", cfg.HealthCheck.MaxIdleTime, err),
})
}
}
// Validate health check method
if cfg.HealthCheck.Method != "" && !isValidHealthCheckMethod(cfg.HealthCheck.Method) {
errs = append(errs, ValidationError{
Field: "healthCheck.method",
Message: fmt.Sprintf("Invalid health check method '%s' (must be one of: %s)", cfg.HealthCheck.Method, strings.Join(validHealthCheckMethods, ", ")),
})
}
}
// Validate Reliability durations
if cfg.Reliability != nil {
if cfg.Reliability.TCPKeepalive != "" {
if _, err := time.ParseDuration(cfg.Reliability.TCPKeepalive); err != nil {
errs = append(errs, ValidationError{
Field: "reliability.tcpKeepalive",
Message: fmt.Sprintf("Invalid TCP keepalive duration '%s': %v", cfg.Reliability.TCPKeepalive, err),
})
}
}
if cfg.Reliability.DialTimeout != "" {
if _, err := time.ParseDuration(cfg.Reliability.DialTimeout); err != nil {
errs = append(errs, ValidationError{
Field: "reliability.dialTimeout",
Message: fmt.Sprintf("Invalid dial timeout '%s': %v", cfg.Reliability.DialTimeout, err),
})
}
}
if cfg.Reliability.WatchdogPeriod != "" {
if _, err := time.ParseDuration(cfg.Reliability.WatchdogPeriod); err != nil {
errs = append(errs, ValidationError{
Field: "reliability.watchdogPeriod",
Message: fmt.Sprintf("Invalid watchdog period '%s': %v", cfg.Reliability.WatchdogPeriod, err),
})
}
}
}
return errs
}
// validateHTTPLog validates HTTP log configuration.
func (v *Validator) validateHTTPLog(fwd *Forward) []ValidationError {
var errs []ValidationError
if fwd.HTTPLog == nil {
return errs
}
// Validate maxBodySize is non-negative
if fwd.HTTPLog.MaxBodySize < 0 {
errs = append(errs, ValidationError{
Field: "httpLog.maxBodySize",
Message: fmt.Sprintf("Invalid maxBodySize %d for forward %s (must be non-negative)", fwd.HTTPLog.MaxBodySize, fwd.ID()),
})
}
return errs
}
// FormatValidationErrors formats validation errors into a human-readable string.
func FormatValidationErrors(errs []ValidationError) string {
if len(errs) == 0 {
return ""
}
var sb strings.Builder
sb.WriteString("\nConfiguration Validation Errors:\n")
sb.WriteString(strings.Repeat("=", 50) + "\n\n")
for i, err := range errs {
fmt.Fprintf(&sb, "%d. %s\n", i+1, err.Message)
if len(err.Context) > 0 {
for k, v := range err.Context {
fmt.Fprintf(&sb, " %s: %s\n", k, v)
}
}
sb.WriteString("\n")
}
return sb.String()
}
// validateMDNS validates mDNS configuration when enabled.
// It checks that aliases used for mDNS hostnames are valid and unique.
// This includes both explicit aliases and auto-generated ones from resource names.
func (v *Validator) validateMDNS(cfg *Config) []ValidationError {
var errs []ValidationError
aliasMap := make(map[string][]string) // alias -> list of forward IDs using it
for _, ctx := range cfg.Contexts {
for _, ns := range ctx.Namespaces {
for _, fwd := range ns.Forwards {
// Get the mDNS alias (explicit or generated from resource name)
mdnsAlias := fwd.GetMDNSAlias()
if mdnsAlias == "" {
// No alias available (e.g., "pod" with selector only)
continue
}
// Validate alias is a valid hostname (RFC 1123)
if !isValidHostname(mdnsAlias) {
errs = append(errs, ValidationError{
Field: "alias",
Message: fmt.Sprintf("Forward %s has invalid mDNS hostname '%s' (must be a valid RFC 1123 hostname)", fwd.ID(), mdnsAlias),
})
}
aliasMap[mdnsAlias] = append(aliasMap[mdnsAlias], fwd.ID())
}
}
}
// Check for duplicate aliases (would cause mDNS conflicts)
for alias, forwards := range aliasMap {
if len(forwards) > 1 {
errs = append(errs, ValidationError{
Field: "alias",
Message: fmt.Sprintf("Duplicate mDNS hostname '%s' used by multiple forwards (would cause conflict)", alias),
Context: map[string]string{
"alias": alias,
"forwards": strings.Join(forwards, ", "),
},
})
}
}
return errs
}
// maxTableColumnWidth caps tui.columns width so a typo cannot blow up the table.
const maxTableColumnWidth = 200
// validTableColumnNames are the recognized forwards-table column identifiers.
var validTableColumnNames = map[string]bool{
"context": true,
"namespace": true,
"alias": true,
"type": true,
"resource": true,
"remote": true,
"local": true,
"status": true,
}
// validateTUI validates the tui.columns configuration.
func (v *Validator) validateTUI(cfg *Config) []ValidationError {
var errs []ValidationError
if cfg.TUI == nil {
return errs
}
seen := make(map[string]bool)
for i, col := range cfg.TUI.Columns {
name := strings.ToLower(strings.TrimSpace(col.Name))
if name == "" {
errs = append(errs, ValidationError{
Field: fmt.Sprintf("tui.columns[%d].name", i),
Message: "Column name cannot be empty",
})
continue
}
if !validTableColumnNames[name] {
errs = append(errs, ValidationError{
Field: fmt.Sprintf("tui.columns[%d].name", i),
Message: fmt.Sprintf("Invalid column name '%s' (must be one of: context, namespace, alias, type, resource, remote, local, status)", col.Name),
})
continue
}
if seen[name] {
errs = append(errs, ValidationError{
Field: fmt.Sprintf("tui.columns[%d].name", i),
Message: fmt.Sprintf("Duplicate column '%s' in tui.columns", name),
})
continue
}
seen[name] = true
if col.Width < 0 {
errs = append(errs, ValidationError{
Field: fmt.Sprintf("tui.columns[%d].width", i),
Message: fmt.Sprintf("Column '%s' width cannot be negative", name),
})
} else if col.Width > maxTableColumnWidth {
errs = append(errs, ValidationError{
Field: fmt.Sprintf("tui.columns[%d].width", i),
Message: fmt.Sprintf("Column '%s' width cannot exceed %d", name, maxTableColumnWidth),
})
}
}
return errs
}
// isValidHostname checks if a string is a valid RFC 1123 hostname.
// Hostnames must start with alphanumeric, contain only alphanumeric and hyphens,
// and be 1-63 characters long.
func isValidHostname(name string) bool {
if len(name) == 0 || len(name) > DNS1123LabelMaxLength {
return false
}
// Must start with alphanumeric
if !isAlphanumeric(name[0]) {
return false
}
// Must end with alphanumeric
if !isAlphanumeric(name[len(name)-1]) {
return false
}
// Check all characters
for i := 0; i < len(name); i++ {
c := name[i]
if !isAlphanumeric(c) && c != '-' {
return false
}
}
return true
}
// isAlphanumeric returns true if the character is a letter or digit.
func isAlphanumeric(c byte) bool {
return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9')
}
// isValidResourceType returns true if the resource type is valid.
func isValidResourceType(resourceType string) bool {
for _, rt := range validResourceTypes {
if rt == resourceType {
return true
}
}
return false
}
// isValidHealthCheckMethod returns true if the health check method is valid.
func isValidHealthCheckMethod(method string) bool {
for _, m := range validHealthCheckMethods {
if m == method {
return true
}
}
return false
}
// validateContextName validates that a context name follows the allowed format.
// Context names must consist of alphanumeric characters, hyphens, or underscores,
// and must start and end with an alphanumeric character.
// This more permissive validation supports various kubeconfig naming conventions
// (e.g., "gke_project_zone_cluster", "minikube", "docker-desktop").
func validateContextName(name, field string) *ValidationError {
if len(name) > DNS1123SubdomainMaxLength {
return &ValidationError{
Field: field,
Message: fmt.Sprintf("Context name '%s' exceeds maximum length of %d characters", name, DNS1123SubdomainMaxLength),
}
}
if !contextNameRegexp.MatchString(name) {
return &ValidationError{
Field: field,
Message: fmt.Sprintf("Context name '%s' is not valid (allowed: letters, digits, hyphens, underscores, dots, '@', ':', '/'; must start and end with a letter or digit)", name),
}
}
return nil
}
// validateNamespaceName validates that a namespace name is a valid DNS subdomain (RFC 1123).
// Kubernetes namespaces must follow DNS subdomain format which allows dots for subdomain separation.
// This is more permissive than DNS labels and supports names like "kube-system", "my-app.ns".
func validateNamespaceName(name, field string) *ValidationError {
if len(name) > DNS1123SubdomainMaxLength {
return &ValidationError{
Field: field,
Message: fmt.Sprintf("Namespace name '%s' exceeds maximum length of %d characters", name, DNS1123SubdomainMaxLength),
}
}
if !dns1123SubdomainRegexp.MatchString(name) {
return &ValidationError{
Field: field,
Message: fmt.Sprintf("Namespace name '%s' is not a valid DNS subdomain (must consist of lowercase alphanumeric characters, '-', or '.', start with alphanumeric, end with alphanumeric)", name),
}
}
return nil
}
// validateDNS1123Label validates that a name is a valid DNS label (RFC 1123).
// Used for context names and namespace names.
func validateDNS1123Label(name, field, entityType string) *ValidationError {
if len(name) > DNS1123LabelMaxLength {
return &ValidationError{
Field: field,
Message: fmt.Sprintf("%s name '%s' exceeds maximum length of %d characters", entityType, name, DNS1123LabelMaxLength),
}
}
if !dns1123LabelRegexp.MatchString(name) {
return &ValidationError{
Field: field,
Message: fmt.Sprintf("%s name '%s' is not a valid DNS label (must consist of lowercase alphanumeric characters or '-', start with alphanumeric, end with alphanumeric)", entityType, name),
}
}
return nil
}
// validateDNS1123Subdomain validates that a name is a valid DNS subdomain name (RFC 1123).
// Used for resource names which can contain dots.
func validateDNS1123Subdomain(name, field, entityType string) *ValidationError {
if len(name) > DNS1123SubdomainMaxLength {
return &ValidationError{
Field: field,
Message: fmt.Sprintf("%s '%s' exceeds maximum length of %d characters", entityType, name, DNS1123SubdomainMaxLength),
}
}
if !dns1123SubdomainRegexp.MatchString(name) {
return &ValidationError{
Field: field,
Message: fmt.Sprintf("%s '%s' is not a valid DNS subdomain name (must consist of lowercase alphanumeric characters, '-', or '.', start with alphanumeric, end with alphanumeric)", entityType, name),
}
}
return nil
}
// ValidatePort validates a port number and returns an error if invalid.
// This is a public function that can be used externally.
func ValidatePort(port int, name string) error {
if !IsValidPort(port) {
return fmt.Errorf("%s must be between %d and %d, got %d", name, MinPort, MaxPort, port)
}
return nil
}
// ValidateResourceFormat validates that a resource string is in the correct format.
// This is a public function that can be used externally.
func ValidateResourceFormat(resource string) error {
parts := strings.SplitN(resource, "/", 2)
if len(parts) != 2 {
return fmt.Errorf("resource must be in format 'type/name', got: %s", resource)
}
resourceType := parts[0]
if !isValidResourceType(resourceType) {
return fmt.Errorf("invalid resource type '%s' (must be one of: %s)", resourceType, strings.Join(validResourceTypes, ", "))
}
if parts[1] == "" {
return fmt.Errorf("resource name cannot be empty in '%s'", resource)
}
return nil
}
// ValidateDuration validates that a string is a valid duration.
// This is a public function that can be used externally.
func ValidateDuration(duration, name string) error {
if duration == "" {
return nil // Empty durations are allowed (will use defaults)
}
_, err := time.ParseDuration(duration)
if err != nil {
return fmt.Errorf("invalid %s '%s': %v", name, duration, err)
}
return nil
}