From 555f21c6f3bc7587b520c3ba1ff8e58109bbf344 Mon Sep 17 00:00:00 2001 From: Lukasz Raczylo Date: Sun, 23 Nov 2025 15:42:42 +0000 Subject: [PATCH] Add aliases and conversion from the kftray. --- .kportal.yaml | 5 + README.md | 76 ++++++++++- internal/config/config.go | 21 ++- internal/config/config_test.go | 24 ++++ internal/converter/kftray.go | 202 ++++++++++++++++++++++++++++ internal/converter/kftray_test.go | 212 ++++++++++++++++++++++++++++++ 6 files changed, 532 insertions(+), 8 deletions(-) create mode 100644 internal/converter/kftray.go create mode 100644 internal/converter/kftray_test.go diff --git a/.kportal.yaml b/.kportal.yaml index 9866cf4..e79aef6 100644 --- a/.kportal.yaml +++ b/.kportal.yaml @@ -12,12 +12,14 @@ contexts: protocol: tcp port: 8080 localPort: 8080 + alias: prod-api # Forward to PostgreSQL database - resource: service/postgres protocol: tcp port: 5432 localPort: 5432 + alias: prod-db - name: monitoring forwards: @@ -27,6 +29,7 @@ contexts: protocol: tcp port: 9090 localPort: 9090 + alias: prometheus # Staging context - name: staging @@ -38,9 +41,11 @@ contexts: protocol: tcp port: 8080 localPort: 8081 + alias: staging-http # Forward multiple ports from same pod - resource: pod/myapp protocol: tcp port: 9090 localPort: 9091 + alias: staging-metrics diff --git a/README.md b/README.md index cacd257..878ccc6 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,9 @@ go build -o kportal ./cmd/kportal # Validate configuration without starting ./kportal --check + +# Convert kftray JSON config to kportal YAML +./kportal --convert configs.json --convert-output .kportal.yaml ``` ### Configuration File @@ -65,12 +68,14 @@ contexts: protocol: tcp port: 8080 localPort: 8080 + alias: my-api # Optional: cleaner log output - # Service forwarding + # Service forwarding with alias - resource: service/postgres protocol: tcp port: 5432 localPort: 5432 + alias: prod-db - name: monitoring forwards: @@ -80,6 +85,7 @@ contexts: protocol: tcp port: 9090 localPort: 9090 + alias: prometheus - name: staging namespaces: @@ -89,10 +95,12 @@ contexts: - resource: pod/test-app port: 8080 localPort: 8081 + alias: test-http - resource: pod/test-app port: 9090 localPort: 9091 + alias: test-metrics ``` ### Resource Types @@ -122,6 +130,72 @@ Dynamically selects pods matching the label selector. ``` Most stable option - forwards to service endpoints. +#### Using Aliases + +Aliases provide cleaner, more readable log output: + +```yaml +- resource: service/victoria-metrics-cluster-vmselect + port: 8481 + localPort: 8481 + alias: vmetrics # Shows "vmetrics:8481→8481" instead of full path +``` + +**Without alias:** +``` +[home/monitoring/service/victoria-metrics-cluster-vmselect:8481] Forwarding... +``` + +**With alias:** +``` +[vmetrics:8481] Forwarding vmetrics:8481→8481 → localhost:8481 +``` + +### Converting from kftray + +kportal can automatically convert kftray JSON configurations to kportal YAML format: + +```bash +# Convert kftray config +kportal --convert kftray-config.json --convert-output .kportal.yaml + +# The converter will: +# 1. Read the kftray JSON format +# 2. Group forwards by context and namespace +# 3. Generate kportal YAML with all aliases preserved +# 4. Display a summary of the conversion +``` + +**Example kftray JSON:** +```json +[ + { + "service": "postgres", + "namespace": "default", + "local_port": 5432, + "remote_port": 5432, + "context": "production", + "workload_type": "service", + "protocol": "tcp", + "alias": "prod-db" + } +] +``` + +**Converts to kportal YAML:** +```yaml +contexts: + - name: production + namespaces: + - name: default + forwards: + - resource: service/postgres + protocol: tcp + port: 5432 + localPort: 5432 + alias: prod-db +``` + ## How It Works ### Pod Restart Handling diff --git a/internal/config/config.go b/internal/config/config.go index 8d8eb6a..faf3b32 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -26,11 +26,12 @@ type Namespace struct { // Forward represents a single port-forward configuration type Forward struct { - Resource string `yaml:"resource"` // e.g., "pod/my-app", "service/postgres", "pod" - Selector string `yaml:"selector"` // Label selector for pod resolution (e.g., "app=nginx,env=prod") - Protocol string `yaml:"protocol"` // tcp or udp - Port int `yaml:"port"` // Remote port - LocalPort int `yaml:"localPort"` // Local port + Resource string `yaml:"resource"` // e.g., "pod/my-app", "service/postgres", "pod" + Selector string `yaml:"selector"` // Label selector for pod resolution (e.g., "app=nginx,env=prod") + Protocol string `yaml:"protocol"` // tcp or udp + Port int `yaml:"port"` // Remote port + LocalPort int `yaml:"localPort"` // Local port + Alias string `yaml:"alias,omitempty"` // Optional human-readable alias for this forward // Runtime fields (not in YAML) contextName string @@ -38,14 +39,20 @@ type Forward struct { } // ID returns a unique identifier for this forward configuration. -// Format: context/namespace/resource:localPort +// Format: alias:localPort (if alias provided) or context/namespace/resource:localPort func (f *Forward) ID() string { + if f.Alias != "" { + return fmt.Sprintf("%s:%d", f.Alias, f.LocalPort) + } return fmt.Sprintf("%s/%s/%s:%d", f.contextName, f.namespaceName, f.Resource, f.LocalPort) } // String returns a human-readable representation of the forward. -// Format: context/namespace/resource:port→localPort +// Format: alias:port→localPort (if alias provided) or context/namespace/resource:port→localPort func (f *Forward) String() string { + if f.Alias != "" { + return fmt.Sprintf("%s:%d→%d", f.Alias, f.Port, f.LocalPort) + } if f.Selector != "" { return fmt.Sprintf("%s/%s/%s[%s]:%d→%d", f.contextName, f.namespaceName, f.Resource, f.Selector, f.Port, f.LocalPort) diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 0d478be..22ff031 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -140,6 +140,18 @@ func TestForward_ID(t *testing.T) { }, expectedID: "staging/web/pod:8081", }, + { + name: "forward with alias", + forward: Forward{ + Resource: "service/postgres", + Port: 5432, + LocalPort: 5432, + Alias: "shared-postgres", + contextName: "home", + namespaceName: "shared-resources", + }, + expectedID: "shared-postgres:5432", + }, } for _, tt := range tests { @@ -190,6 +202,18 @@ func TestForward_String(t *testing.T) { }, expectedString: "staging/web/pod[app=nginx,env=prod]:80→8081", }, + { + name: "forward with alias", + forward: Forward{ + Resource: "service/redis", + Port: 6379, + LocalPort: 6379, + Alias: "redis-at-home", + contextName: "home", + namespaceName: "shared-resources", + }, + expectedString: "redis-at-home:6379→6379", + }, } for _, tt := range tests { diff --git a/internal/converter/kftray.go b/internal/converter/kftray.go new file mode 100644 index 0000000..09b7cc4 --- /dev/null +++ b/internal/converter/kftray.go @@ -0,0 +1,202 @@ +package converter + +import ( + "encoding/json" + "fmt" + "os" + "sort" + + "github.com/nvm/kportal/internal/config" + "gopkg.in/yaml.v3" +) + +// KFTrayConfig represents a single port-forward entry from kftray JSON format +type KFTrayConfig struct { + Service string `json:"service"` + Namespace string `json:"namespace"` + LocalPort int `json:"local_port"` + RemotePort int `json:"remote_port"` + Context string `json:"context"` + WorkloadType string `json:"workload_type"` + Protocol string `json:"protocol"` + Alias string `json:"alias"` +} + +// ConvertKFTrayToKPortal converts kftray JSON configuration to kportal YAML format +func ConvertKFTrayToKPortal(inputFile, outputFile string) error { + // Read kftray JSON config + data, err := os.ReadFile(inputFile) + if err != nil { + return fmt.Errorf("failed to read input file: %w", err) + } + + var kftrayConfigs []KFTrayConfig + if err := json.Unmarshal(data, &kftrayConfigs); err != nil { + return fmt.Errorf("failed to parse JSON: %w", err) + } + + // Convert to kportal format + kportalConfig := convertToKPortal(kftrayConfigs) + + // Write kportal YAML config + yamlData, err := yaml.Marshal(kportalConfig) + if err != nil { + return fmt.Errorf("failed to generate YAML: %w", err) + } + + // Add header comment + header := "# kportal configuration converted from kftray format\n# Generated by kportal --convert\n\n" + yamlData = append([]byte(header), yamlData...) + + if err := os.WriteFile(outputFile, yamlData, 0644); err != nil { + return fmt.Errorf("failed to write output file: %w", err) + } + + return nil +} + +// GetConversionSummary returns statistics about the kftray configuration +func GetConversionSummary(inputFile string) (map[string]map[string]int, int, error) { + data, err := os.ReadFile(inputFile) + if err != nil { + return nil, 0, fmt.Errorf("failed to read input file: %w", err) + } + + var kftrayConfigs []KFTrayConfig + if err := json.Unmarshal(data, &kftrayConfigs); err != nil { + return nil, 0, fmt.Errorf("failed to parse JSON: %w", err) + } + + contextMap := make(map[string]map[string]int) + for _, cfg := range kftrayConfigs { + if _, ok := contextMap[cfg.Context]; !ok { + contextMap[cfg.Context] = make(map[string]int) + } + contextMap[cfg.Context][cfg.Namespace]++ + } + + return contextMap, len(kftrayConfigs), nil +} + +func convertToKPortal(kftrayConfigs []KFTrayConfig) config.Config { + // Group by context and namespace + contextMap := make(map[string]map[string][]forwardEntry) + + for _, cfg := range kftrayConfigs { + // Initialize context if not exists + if _, ok := contextMap[cfg.Context]; !ok { + contextMap[cfg.Context] = make(map[string][]forwardEntry) + } + + // Build resource string based on workload type + resource := fmt.Sprintf("%s/%s", cfg.WorkloadType, cfg.Service) + + // Create forward entry + forward := forwardEntry{ + Resource: resource, + Protocol: cfg.Protocol, + Port: cfg.RemotePort, + LocalPort: cfg.LocalPort, + Alias: cfg.Alias, + } + + // Add to namespace + contextMap[cfg.Context][cfg.Namespace] = append( + contextMap[cfg.Context][cfg.Namespace], + forward, + ) + } + + // Convert map to structured config + var contexts []contextEntry + + // Sort contexts for consistent output + contextNames := make([]string, 0, len(contextMap)) + for name := range contextMap { + contextNames = append(contextNames, name) + } + sort.Strings(contextNames) + + for _, contextName := range contextNames { + namespaceMap := contextMap[contextName] + + // Sort namespaces + namespaceNames := make([]string, 0, len(namespaceMap)) + for name := range namespaceMap { + namespaceNames = append(namespaceNames, name) + } + sort.Strings(namespaceNames) + + var namespaces []namespaceEntry + for _, namespaceName := range namespaceNames { + forwards := namespaceMap[namespaceName] + + // Sort forwards by local port for consistent output + sort.Slice(forwards, func(i, j int) bool { + return forwards[i].LocalPort < forwards[j].LocalPort + }) + + namespaces = append(namespaces, namespaceEntry{ + Name: namespaceName, + Forwards: forwards, + }) + } + + contexts = append(contexts, contextEntry{ + Name: contextName, + Namespaces: namespaces, + }) + } + + return config.Config{ + Contexts: convertToConfigContexts(contexts), + } +} + +// Internal types for conversion (to avoid circular dependencies) +type contextEntry struct { + Name string + Namespaces []namespaceEntry +} + +type namespaceEntry struct { + Name string + Forwards []forwardEntry +} + +type forwardEntry struct { + Resource string `yaml:"resource"` + Protocol string `yaml:"protocol"` + Port int `yaml:"port"` + LocalPort int `yaml:"localPort"` + Alias string `yaml:"alias,omitempty"` +} + +// Convert internal types to config package types +func convertToConfigContexts(contexts []contextEntry) []config.Context { + var result []config.Context + for _, ctx := range contexts { + var namespaces []config.Namespace + for _, ns := range ctx.Namespaces { + var forwards []config.Forward + for _, fwd := range ns.Forwards { + forwards = append(forwards, config.Forward{ + Resource: fwd.Resource, + Protocol: fwd.Protocol, + Port: fwd.Port, + LocalPort: fwd.LocalPort, + Alias: fwd.Alias, + }) + } + namespaces = append(namespaces, config.Namespace{ + Name: ns.Name, + Forwards: forwards, + }) + } + result = append(result, config.Context{ + Name: ctx.Name, + Namespaces: namespaces, + }) + } + return result +} diff --git a/internal/converter/kftray_test.go b/internal/converter/kftray_test.go new file mode 100644 index 0000000..ce1d905 --- /dev/null +++ b/internal/converter/kftray_test.go @@ -0,0 +1,212 @@ +package converter + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestConvertToKPortal_SingleContext(t *testing.T) { + kftrayConfigs := []KFTrayConfig{ + { + Service: "postgres", + Namespace: "default", + LocalPort: 5432, + RemotePort: 5432, + Context: "production", + WorkloadType: "service", + Protocol: "tcp", + Alias: "prod-db", + }, + } + + result := convertToKPortal(kftrayConfigs) + + assert.Len(t, result.Contexts, 1) + assert.Equal(t, "production", result.Contexts[0].Name) + assert.Len(t, result.Contexts[0].Namespaces, 1) + assert.Equal(t, "default", result.Contexts[0].Namespaces[0].Name) + assert.Len(t, result.Contexts[0].Namespaces[0].Forwards, 1) + + forward := result.Contexts[0].Namespaces[0].Forwards[0] + assert.Equal(t, "service/postgres", forward.Resource) + assert.Equal(t, "tcp", forward.Protocol) + assert.Equal(t, 5432, forward.Port) + assert.Equal(t, 5432, forward.LocalPort) + assert.Equal(t, "prod-db", forward.Alias) +} + +func TestConvertToKPortal_MultipleContexts(t *testing.T) { + kftrayConfigs := []KFTrayConfig{ + { + Service: "api", + Namespace: "default", + LocalPort: 8080, + RemotePort: 8080, + Context: "production", + WorkloadType: "service", + Protocol: "tcp", + Alias: "prod-api", + }, + { + Service: "api", + Namespace: "default", + LocalPort: 8081, + RemotePort: 8080, + Context: "staging", + WorkloadType: "service", + Protocol: "tcp", + Alias: "staging-api", + }, + } + + result := convertToKPortal(kftrayConfigs) + + assert.Len(t, result.Contexts, 2) + + // Contexts should be sorted alphabetically + assert.Equal(t, "production", result.Contexts[0].Name) + assert.Equal(t, "staging", result.Contexts[1].Name) +} + +func TestConvertToKPortal_MultipleNamespaces(t *testing.T) { + kftrayConfigs := []KFTrayConfig{ + { + Service: "api", + Namespace: "default", + LocalPort: 8080, + RemotePort: 8080, + Context: "production", + WorkloadType: "service", + Protocol: "tcp", + Alias: "api", + }, + { + Service: "postgres", + Namespace: "database", + LocalPort: 5432, + RemotePort: 5432, + Context: "production", + WorkloadType: "service", + Protocol: "tcp", + Alias: "db", + }, + } + + result := convertToKPortal(kftrayConfigs) + + assert.Len(t, result.Contexts, 1) + assert.Len(t, result.Contexts[0].Namespaces, 2) + + // Namespaces should be sorted alphabetically + assert.Equal(t, "database", result.Contexts[0].Namespaces[0].Name) + assert.Equal(t, "default", result.Contexts[0].Namespaces[1].Name) +} + +func TestConvertToKPortal_MultipleForwardsInNamespace(t *testing.T) { + kftrayConfigs := []KFTrayConfig{ + { + Service: "api", + Namespace: "default", + LocalPort: 8080, + RemotePort: 8080, + Context: "production", + WorkloadType: "service", + Protocol: "tcp", + Alias: "api", + }, + { + Service: "postgres", + Namespace: "default", + LocalPort: 5432, + RemotePort: 5432, + Context: "production", + WorkloadType: "service", + Protocol: "tcp", + Alias: "db", + }, + { + Service: "redis", + Namespace: "default", + LocalPort: 6379, + RemotePort: 6379, + Context: "production", + WorkloadType: "service", + Protocol: "tcp", + Alias: "redis", + }, + } + + result := convertToKPortal(kftrayConfigs) + + assert.Len(t, result.Contexts, 1) + assert.Len(t, result.Contexts[0].Namespaces, 1) + assert.Len(t, result.Contexts[0].Namespaces[0].Forwards, 3) + + // Forwards should be sorted by local port + forwards := result.Contexts[0].Namespaces[0].Forwards + assert.Equal(t, 5432, forwards[0].LocalPort) // postgres + assert.Equal(t, 6379, forwards[1].LocalPort) // redis + assert.Equal(t, 8080, forwards[2].LocalPort) // api +} + +func TestConvertToKPortal_PodWorkloadType(t *testing.T) { + kftrayConfigs := []KFTrayConfig{ + { + Service: "my-app", + Namespace: "default", + LocalPort: 8080, + RemotePort: 8080, + Context: "production", + WorkloadType: "pod", + Protocol: "tcp", + Alias: "app", + }, + } + + result := convertToKPortal(kftrayConfigs) + + forward := result.Contexts[0].Namespaces[0].Forwards[0] + assert.Equal(t, "pod/my-app", forward.Resource) +} + +func TestConvertToKPortal_WithoutAlias(t *testing.T) { + kftrayConfigs := []KFTrayConfig{ + { + Service: "postgres", + Namespace: "default", + LocalPort: 5432, + RemotePort: 5432, + Context: "production", + WorkloadType: "service", + Protocol: "tcp", + Alias: "", // No alias + }, + } + + result := convertToKPortal(kftrayConfigs) + + forward := result.Contexts[0].Namespaces[0].Forwards[0] + assert.Equal(t, "", forward.Alias) +} + +func TestConvertToKPortal_DifferentPorts(t *testing.T) { + kftrayConfigs := []KFTrayConfig{ + { + Service: "api", + Namespace: "default", + LocalPort: 8080, + RemotePort: 3000, + Context: "production", + WorkloadType: "service", + Protocol: "tcp", + Alias: "api", + }, + } + + result := convertToKPortal(kftrayConfigs) + + forward := result.Contexts[0].Namespaces[0].Forwards[0] + assert.Equal(t, 3000, forward.Port, "Remote port should be 3000") + assert.Equal(t, 8080, forward.LocalPort, "Local port should be 8080") +}