Add aliases and conversion from the kftray.

This commit is contained in:
2025-11-23 15:42:42 +00:00
parent f50f0a9b49
commit 555f21c6f3
6 changed files with 532 additions and 8 deletions
+5
View File
@@ -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
+75 -1
View File
@@ -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
+14 -7
View File
@@ -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)
+24
View File
@@ -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 {
+202
View File
@@ -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
}
+212
View File
@@ -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")
}