mirror of
https://github.com/lukaszraczylo/kportal.git
synced 2026-06-26 04:12:57 +00:00
Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0d03f228f9 | |||
| 2a44c6ff9c | |||
| 8672d932bb | |||
| 87317adb91 | |||
| ced7e80a06 | |||
| 13723733df | |||
| 9538623bcb | |||
| 8bb377909c | |||
| 263a0370d3 | |||
| 62eca4a9a1 | |||
| ea20a037b9 | |||
| 46db732f87 | |||
| a297ba7073 |
@@ -5,69 +5,13 @@ on:
|
|||||||
schedule:
|
schedule:
|
||||||
- cron: "0 3 * * *"
|
- cron: "0 3 * * *"
|
||||||
|
|
||||||
env:
|
permissions:
|
||||||
GO_VERSION: ">=1.21"
|
contents: write
|
||||||
|
actions: write
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
# This job is responsible for preparation of the build
|
autoupdate:
|
||||||
# environment variables.
|
uses: lukaszraczylo/shared-actions/.github/workflows/go-autoupdate.yaml@main
|
||||||
prepare:
|
with:
|
||||||
name: Preparing build context
|
go-version: ">=1.21"
|
||||||
runs-on: ubuntu-latest
|
release-workflow: "release.yml"
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout repo
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Install Go
|
|
||||||
uses: actions/setup-go@v5
|
|
||||||
id: cache
|
|
||||||
with:
|
|
||||||
go-version: ${{env.GO_VERSION}}
|
|
||||||
cache-dependency-path: "**/*.sum"
|
|
||||||
|
|
||||||
- name: Go get dependencies
|
|
||||||
if: steps.cache.outputs.cache-hit != 'true'
|
|
||||||
run: |
|
|
||||||
go get ./...
|
|
||||||
|
|
||||||
# This job is responsible for running tests and linting the codebase
|
|
||||||
test:
|
|
||||||
name: "Unit testing"
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
container: golang:1
|
|
||||||
needs: [prepare]
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 0 # Ensure full history is checked out
|
|
||||||
token: ${{ secrets.GHCR_TOKEN }}
|
|
||||||
|
|
||||||
- name: Install Go
|
|
||||||
uses: actions/setup-go@v5
|
|
||||||
with:
|
|
||||||
go-version: ${{env.GO_VERSION}}
|
|
||||||
cache-dependency-path: "**/*.sum"
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: |
|
|
||||||
apt-get update
|
|
||||||
apt-get install ca-certificates make -y
|
|
||||||
update-ca-certificates
|
|
||||||
go mod tidy
|
|
||||||
go get -u -v ./...
|
|
||||||
go mod tidy -v
|
|
||||||
|
|
||||||
- name: Run unit tests
|
|
||||||
run: |
|
|
||||||
CI_RUN=${CI} make test
|
|
||||||
git config --global --add safe.directory /__w/kportal/kportal
|
|
||||||
|
|
||||||
- name: Commit changes
|
|
||||||
uses: stefanzweifel/git-auto-commit-action@v5
|
|
||||||
with:
|
|
||||||
commit_message: "Update go.mod and go.sum"
|
|
||||||
commit_options: "--no-verify --signoff"
|
|
||||||
file_pattern: "go.mod go.sum"
|
|
||||||
|
|||||||
@@ -5,90 +5,18 @@ on:
|
|||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
paths:
|
paths:
|
||||||
- '**.go'
|
- "**.go"
|
||||||
- 'go.mod'
|
- "go.mod"
|
||||||
- 'go.sum'
|
- "go.sum"
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
test:
|
|
||||||
name: Test
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout code
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Set up Go
|
|
||||||
uses: actions/setup-go@v5
|
|
||||||
with:
|
|
||||||
go-version: '1.23'
|
|
||||||
|
|
||||||
- name: Run tests with race detector
|
|
||||||
run: go test -race -v ./...
|
|
||||||
|
|
||||||
version:
|
|
||||||
name: Calculate Version
|
|
||||||
needs: test
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
outputs:
|
|
||||||
version: ${{ steps.version_formatted.outputs.version }}
|
|
||||||
version_tag: ${{ steps.version_formatted.outputs.version_tag }}
|
|
||||||
steps:
|
|
||||||
- name: Checkout code
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- name: Calculate semantic version
|
|
||||||
id: semver
|
|
||||||
uses: lukaszraczylo/semver-generator@v1
|
|
||||||
with:
|
|
||||||
config_file: semver.yaml
|
|
||||||
repository_local: true
|
|
||||||
|
|
||||||
- name: Format version
|
|
||||||
id: version_formatted
|
|
||||||
run: |
|
|
||||||
VERSION="${{ steps.semver.outputs.semantic_version }}"
|
|
||||||
echo "version=${VERSION}" >> $GITHUB_OUTPUT
|
|
||||||
echo "version_tag=v${VERSION}" >> $GITHUB_OUTPUT
|
|
||||||
|
|
||||||
- name: Display version
|
|
||||||
run: |
|
|
||||||
echo "Calculated version: ${{ steps.version_formatted.outputs.version }}"
|
|
||||||
echo "Version tag: ${{ steps.version_formatted.outputs.version_tag }}"
|
|
||||||
|
|
||||||
release:
|
release:
|
||||||
name: Release with GoReleaser
|
uses: lukaszraczylo/shared-actions/.github/workflows/go-release.yaml@main
|
||||||
needs: version
|
with:
|
||||||
runs-on: ubuntu-latest
|
go-version: "1.23"
|
||||||
steps:
|
secrets:
|
||||||
- name: Checkout code
|
homebrew-tap-token: ${{ secrets.HOMEBREW_TAP_TOKEN }}
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- name: Set up Go
|
|
||||||
uses: actions/setup-go@v5
|
|
||||||
with:
|
|
||||||
go-version: '1.23'
|
|
||||||
|
|
||||||
- name: Create and push tag
|
|
||||||
run: |
|
|
||||||
git config user.name "github-actions[bot]"
|
|
||||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
|
||||||
git tag -a ${{ needs.version.outputs.version_tag }} -m "Release ${{ needs.version.outputs.version_tag }}"
|
|
||||||
git push origin ${{ needs.version.outputs.version_tag }}
|
|
||||||
|
|
||||||
- name: Run GoReleaser
|
|
||||||
uses: goreleaser/goreleaser-action@v6
|
|
||||||
with:
|
|
||||||
distribution: goreleaser
|
|
||||||
version: '~> v2'
|
|
||||||
args: release --clean
|
|
||||||
env:
|
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
HOMEBREW_TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }}
|
|
||||||
|
|||||||
+11
-9
@@ -23,11 +23,11 @@ builds:
|
|||||||
|
|
||||||
archives:
|
archives:
|
||||||
- id: kportal
|
- id: kportal
|
||||||
format: tar.gz
|
formats: [tar.gz]
|
||||||
name_template: "kportal-{{ .Version }}-{{ .Os }}-{{ .Arch }}"
|
name_template: "kportal-{{ .Version }}-{{ .Os }}-{{ .Arch }}"
|
||||||
format_overrides:
|
format_overrides:
|
||||||
- goos: windows
|
- goos: windows
|
||||||
format: zip
|
formats: [zip]
|
||||||
files:
|
files:
|
||||||
- LICENSE
|
- LICENSE
|
||||||
- README.md
|
- README.md
|
||||||
@@ -53,17 +53,19 @@ release:
|
|||||||
draft: false
|
draft: false
|
||||||
prerelease: auto
|
prerelease: auto
|
||||||
|
|
||||||
brews:
|
homebrew_casks:
|
||||||
- repository:
|
- repository:
|
||||||
owner: lukaszraczylo
|
owner: lukaszraczylo
|
||||||
name: homebrew-taps
|
name: homebrew-taps
|
||||||
token: "{{ .Env.HOMEBREW_TAP_TOKEN }}"
|
token: "{{ .Env.HOMEBREW_TAP_TOKEN }}"
|
||||||
directory: Formula
|
directory: Casks
|
||||||
homepage: https://lukaszraczylo.github.io/kportal
|
homepage: https://lukaszraczylo.github.io/kportal
|
||||||
description: "Modern Kubernetes port-forward manager with interactive TUI"
|
description: "Modern Kubernetes port-forward manager with interactive TUI"
|
||||||
license: MIT
|
license: MIT
|
||||||
test: |
|
hooks:
|
||||||
system "#{bin}/kportal", "--version"
|
post.install: |
|
||||||
dependencies:
|
if OS.mac?
|
||||||
- name: kubernetes-cli
|
system_command "/usr/bin/xattr",
|
||||||
type: optional
|
args: ["-dr", "com.apple.quarantine", "#{staged_path}/kportal"],
|
||||||
|
sudo: false
|
||||||
|
end
|
||||||
|
|||||||
@@ -54,12 +54,17 @@ kportal manages multiple Kubernetes port-forwards with an interactive terminal i
|
|||||||
|
|
||||||
## 📦 Installation
|
## 📦 Installation
|
||||||
|
|
||||||
### Homebrew (macOS/Linux)
|
### Homebrew (macOS)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
brew install lukaszraczylo/taps/kportal
|
brew install --cask lukaszraczylo/taps/kportal
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> **Note**: If you previously installed via `brew install lukaszraczylo/taps/kportal` (formula), uninstall first:
|
||||||
|
> ```bash
|
||||||
|
> brew uninstall kportal
|
||||||
|
> ```
|
||||||
|
|
||||||
### Quick Install
|
### Quick Install
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
+46
-4
@@ -1,6 +1,7 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bufio"
|
||||||
"context"
|
"context"
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -49,6 +50,23 @@ var (
|
|||||||
appVersion = "0.1.0" // Set via ldflags during build
|
appVersion = "0.1.0" // Set via ldflags during build
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// promptCreateConfig asks the user if they want to create a new config file.
|
||||||
|
// Returns true if the user answers yes, false otherwise.
|
||||||
|
func promptCreateConfig(path string) bool {
|
||||||
|
fmt.Printf("Configuration file not found: %s\n", path)
|
||||||
|
fmt.Print("Would you like to create an empty configuration? [Y/n] ")
|
||||||
|
|
||||||
|
reader := bufio.NewReader(os.Stdin)
|
||||||
|
response, err := reader.ReadString('\n')
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
response = strings.TrimSpace(strings.ToLower(response))
|
||||||
|
// Empty response (just Enter) defaults to yes
|
||||||
|
return response == "" || response == "y" || response == "yes"
|
||||||
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
|
|
||||||
@@ -173,14 +191,38 @@ func main() {
|
|||||||
|
|
||||||
// Load configuration
|
// Load configuration
|
||||||
cfg, err := config.LoadConfig(*configFile)
|
cfg, err := config.LoadConfig(*configFile)
|
||||||
|
configIsNew := false
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "Error loading config: %v\n", err)
|
if err == config.ErrConfigNotFound {
|
||||||
os.Exit(1)
|
// Config file doesn't exist - offer to create it
|
||||||
|
if !promptCreateConfig(*configFile) {
|
||||||
|
os.Exit(0)
|
||||||
|
}
|
||||||
|
// Create empty config file
|
||||||
|
if err := config.CreateEmptyConfigFile(*configFile); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error creating config file: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
fmt.Printf("Created %s\n", *configFile)
|
||||||
|
fmt.Println("Use 'n' in the UI to add port forwards, or edit the file manually.")
|
||||||
|
fmt.Println()
|
||||||
|
|
||||||
|
// Load the newly created config
|
||||||
|
cfg, err = config.LoadConfig(*configFile)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error loading config: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
configIsNew = true
|
||||||
|
} else {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error loading config: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate configuration
|
// Validate configuration (allow empty configs for newly created files)
|
||||||
validator := config.NewValidator()
|
validator := config.NewValidator()
|
||||||
if errs := validator.ValidateConfig(cfg); len(errs) > 0 {
|
if errs := validator.ValidateConfigWithOptions(cfg, configIsNew || cfg.IsEmpty()); len(errs) > 0 {
|
||||||
fmt.Fprint(os.Stderr, config.FormatValidationErrors(errs))
|
fmt.Fprint(os.Stderr, config.FormatValidationErrors(errs))
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-3
@@ -556,11 +556,11 @@
|
|||||||
<i class="fas fa-beer text-orange-500 dark:text-orange-400 text-2xl mr-3"></i>
|
<i class="fas fa-beer text-orange-500 dark:text-orange-400 text-2xl mr-3"></i>
|
||||||
<div>
|
<div>
|
||||||
<h3 class="text-xl font-semibold text-gray-900 dark:text-gray-100">Homebrew</h3>
|
<h3 class="text-xl font-semibold text-gray-900 dark:text-gray-100">Homebrew</h3>
|
||||||
<p class="text-gray-600 dark:text-gray-400 text-sm">macOS & Linux</p>
|
<p class="text-gray-600 dark:text-gray-400 text-sm">macOS</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div onclick="copyToClipboard('brew install lukaszraczylo/taps/kportal', this)" class="relative bg-gradient-to-br from-gray-900 to-gray-800 dark:from-gray-950 dark:to-black text-gray-100 p-4 rounded-lg text-sm cursor-pointer group overflow-x-auto border border-gray-700 hover:border-orange-500 transition-all duration-300">
|
<div onclick="copyToClipboard('brew install --cask lukaszraczylo/taps/kportal', this)" class="relative bg-gradient-to-br from-gray-900 to-gray-800 dark:from-gray-950 dark:to-black text-gray-100 p-4 rounded-lg text-sm cursor-pointer group overflow-x-auto border border-gray-700 hover:border-orange-500 transition-all duration-300">
|
||||||
<code class="block whitespace-nowrap font-mono">brew install lukaszraczylo/taps/kportal</code>
|
<code class="block whitespace-nowrap font-mono">brew install --cask lukaszraczylo/taps/kportal</code>
|
||||||
<div class="absolute top-3 right-3"><i class="fas fa-copy text-gray-500 group-hover:text-orange-400 transition-colors duration-300"></i></div>
|
<div class="absolute top-3 right-3"><i class="fas fa-copy text-gray-500 group-hover:text-orange-400 transition-colors duration-300"></i></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -20,10 +20,10 @@ require (
|
|||||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||||
github.com/cenkalti/backoff v2.2.1+incompatible // indirect
|
github.com/cenkalti/backoff v2.2.1+incompatible // indirect
|
||||||
github.com/charmbracelet/colorprofile v0.3.3 // indirect
|
github.com/charmbracelet/colorprofile v0.3.3 // indirect
|
||||||
github.com/charmbracelet/x/ansi v0.11.1 // indirect
|
github.com/charmbracelet/x/ansi v0.11.2 // indirect
|
||||||
github.com/charmbracelet/x/cellbuf v0.0.14 // indirect
|
github.com/charmbracelet/x/cellbuf v0.0.14 // indirect
|
||||||
github.com/charmbracelet/x/term v0.2.2 // indirect
|
github.com/charmbracelet/x/term v0.2.2 // indirect
|
||||||
github.com/clipperhouse/displaywidth v0.6.0 // indirect
|
github.com/clipperhouse/displaywidth v0.6.1 // indirect
|
||||||
github.com/clipperhouse/stringish v0.1.1 // indirect
|
github.com/clipperhouse/stringish v0.1.1 // indirect
|
||||||
github.com/clipperhouse/uax29/v2 v2.3.0 // indirect
|
github.com/clipperhouse/uax29/v2 v2.3.0 // indirect
|
||||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||||
|
|||||||
@@ -12,16 +12,16 @@ github.com/charmbracelet/colorprofile v0.3.3 h1:DjJzJtLP6/NZ8p7Cgjno0CKGr7wwRJGx
|
|||||||
github.com/charmbracelet/colorprofile v0.3.3/go.mod h1:nB1FugsAbzq284eJcjfah2nhdSLppN2NqvfotkfRYP4=
|
github.com/charmbracelet/colorprofile v0.3.3/go.mod h1:nB1FugsAbzq284eJcjfah2nhdSLppN2NqvfotkfRYP4=
|
||||||
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
|
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
|
||||||
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
|
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
|
||||||
github.com/charmbracelet/x/ansi v0.11.1 h1:iXAC8SyMQDJgtcz9Jnw+HU8WMEctHzoTAETIeA3JXMk=
|
github.com/charmbracelet/x/ansi v0.11.2 h1:XAG3FSjiVtFvgEgGrNBkCNNYrsucAt8c6bfxHyROLLs=
|
||||||
github.com/charmbracelet/x/ansi v0.11.1/go.mod h1:M49wjzpIujwPceJ+t5w3qh2i87+HRtHohgb5iTyepL0=
|
github.com/charmbracelet/x/ansi v0.11.2/go.mod h1:9tY2bzX5SiJCU0iWyskjBeI2BRQfvPqI+J760Mjf+Rg=
|
||||||
github.com/charmbracelet/x/cellbuf v0.0.14 h1:iUEMryGyFTelKW3THW4+FfPgi4fkmKnnaLOXuc+/Kj4=
|
github.com/charmbracelet/x/cellbuf v0.0.14 h1:iUEMryGyFTelKW3THW4+FfPgi4fkmKnnaLOXuc+/Kj4=
|
||||||
github.com/charmbracelet/x/cellbuf v0.0.14/go.mod h1:P447lJl49ywBbil/KjCk2HexGh4tEY9LH0/1QrZZ9rA=
|
github.com/charmbracelet/x/cellbuf v0.0.14/go.mod h1:P447lJl49ywBbil/KjCk2HexGh4tEY9LH0/1QrZZ9rA=
|
||||||
github.com/charmbracelet/x/exp/golden v0.0.0-20240806155701-69247e0abc2a h1:G99klV19u0QnhiizODirwVksQB91TJKV/UaTnACcG30=
|
github.com/charmbracelet/x/exp/golden v0.0.0-20240806155701-69247e0abc2a h1:G99klV19u0QnhiizODirwVksQB91TJKV/UaTnACcG30=
|
||||||
github.com/charmbracelet/x/exp/golden v0.0.0-20240806155701-69247e0abc2a/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
|
github.com/charmbracelet/x/exp/golden v0.0.0-20240806155701-69247e0abc2a/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
|
||||||
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
|
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
|
||||||
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
|
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
|
||||||
github.com/clipperhouse/displaywidth v0.6.0 h1:k32vueaksef9WIKCNcoqRNyKbyvkvkysNYnAWz2fN4s=
|
github.com/clipperhouse/displaywidth v0.6.1 h1:/zMlAezfDzT2xy6acHBzwIfyu2ic0hgkT83UX5EY2gY=
|
||||||
github.com/clipperhouse/displaywidth v0.6.0/go.mod h1:R+kHuzaYWFkTm7xoMmK1lFydbci4X2CicfbGstSGg0o=
|
github.com/clipperhouse/displaywidth v0.6.1/go.mod h1:R+kHuzaYWFkTm7xoMmK1lFydbci4X2CicfbGstSGg0o=
|
||||||
github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
|
github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
|
||||||
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
|
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
|
||||||
github.com/clipperhouse/uax29/v2 v2.3.0 h1:SNdx9DVUqMoBuBoW3iLOj4FQv3dN5mDtuqwuhIGpJy4=
|
github.com/clipperhouse/uax29/v2 v2.3.0 h1:SNdx9DVUqMoBuBoW3iLOj4FQv3dN5mDtuqwuhIGpJy4=
|
||||||
|
|||||||
@@ -221,10 +221,3 @@ func (r *Runner) makeRequest(ctx context.Context, cfg Config) (statusCode int, b
|
|||||||
|
|
||||||
return resp.StatusCode, int64(len(respBody)), bytesWritten, nil
|
return resp.StatusCode, int64(len(respBody)), bytesWritten, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Progress represents the current progress of a benchmark run
|
|
||||||
type Progress struct {
|
|
||||||
Completed int
|
|
||||||
Total int
|
|
||||||
Elapsed time.Duration
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package config
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -10,6 +11,9 @@ import (
|
|||||||
"gopkg.in/yaml.v3"
|
"gopkg.in/yaml.v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ErrConfigNotFound is returned when the configuration file does not exist
|
||||||
|
var ErrConfigNotFound = fmt.Errorf("config file not found")
|
||||||
|
|
||||||
const (
|
const (
|
||||||
// maxConfigSize is the maximum allowed configuration file size (10MB)
|
// maxConfigSize is the maximum allowed configuration file size (10MB)
|
||||||
maxConfigSize = 10 * 1024 * 1024
|
maxConfigSize = 10 * 1024 * 1024
|
||||||
@@ -282,6 +286,9 @@ func LoadConfig(path string) (*Config, error) {
|
|||||||
// Validate file size before reading
|
// Validate file size before reading
|
||||||
fileInfo, err := os.Stat(path)
|
fileInfo, err := os.Stat(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
|
return nil, ErrConfigNotFound
|
||||||
|
}
|
||||||
return nil, fmt.Errorf("failed to stat config file: %w", err)
|
return nil, fmt.Errorf("failed to stat config file: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -337,3 +344,58 @@ func (c *Config) GetAllForwards() []Forward {
|
|||||||
|
|
||||||
return forwards
|
return forwards
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NewEmptyConfig returns a minimal empty configuration with no forwards.
|
||||||
|
// This is used when creating a new config file for the first time.
|
||||||
|
func NewEmptyConfig() *Config {
|
||||||
|
return &Config{
|
||||||
|
Contexts: []Context{},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsEmpty returns true if the configuration has no forwards defined.
|
||||||
|
func (c *Config) IsEmpty() bool {
|
||||||
|
return len(c.Contexts) == 0 || len(c.GetAllForwards()) == 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateEmptyConfigFile creates a new empty configuration file at the given path.
|
||||||
|
// Returns an error if the file already exists or cannot be created.
|
||||||
|
func CreateEmptyConfigFile(path string) error {
|
||||||
|
// Check if file already exists
|
||||||
|
if _, err := os.Stat(path); err == nil {
|
||||||
|
return fmt.Errorf("config file already exists: %s", path)
|
||||||
|
} else if !errors.Is(err, os.ErrNotExist) {
|
||||||
|
return fmt.Errorf("failed to check config file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg := NewEmptyConfig()
|
||||||
|
data, err := yaml.Marshal(cfg)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to marshal empty config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add a helpful comment header
|
||||||
|
header := `# kportal configuration file
|
||||||
|
# Add port forwards using the 'n' key in the TUI, or manually add them below.
|
||||||
|
#
|
||||||
|
# Example forward:
|
||||||
|
# contexts:
|
||||||
|
# - name: my-cluster
|
||||||
|
# namespaces:
|
||||||
|
# - name: default
|
||||||
|
# forwards:
|
||||||
|
# - resource: service/my-service
|
||||||
|
# protocol: tcp
|
||||||
|
# port: 8080
|
||||||
|
# localPort: 8080
|
||||||
|
#
|
||||||
|
`
|
||||||
|
content := header + string(data)
|
||||||
|
|
||||||
|
// Write with restrictive permissions (0600)
|
||||||
|
if err := os.WriteFile(path, []byte(content), 0600); err != nil {
|
||||||
|
return fmt.Errorf("failed to write config file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -97,7 +97,7 @@ func TestLoadConfig_FileNotFound(t *testing.T) {
|
|||||||
cfg, err := LoadConfig("/non/existent/path/.kportal.yaml")
|
cfg, err := LoadConfig("/non/existent/path/.kportal.yaml")
|
||||||
assert.Error(t, err, "LoadConfig should fail with non-existent file")
|
assert.Error(t, err, "LoadConfig should fail with non-existent file")
|
||||||
assert.Nil(t, cfg, "config should be nil on error")
|
assert.Nil(t, cfg, "config should be nil on error")
|
||||||
assert.Contains(t, err.Error(), "failed to stat config file", "error should mention stat failure")
|
assert.Equal(t, ErrConfigNotFound, err, "should return ErrConfigNotFound")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestForward_ID(t *testing.T) {
|
func TestForward_ID(t *testing.T) {
|
||||||
@@ -397,3 +397,123 @@ func TestHTTPLogSpec_UnmarshalYAML(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestNewEmptyConfig(t *testing.T) {
|
||||||
|
cfg := NewEmptyConfig()
|
||||||
|
assert.NotNil(t, cfg, "NewEmptyConfig should return non-nil config")
|
||||||
|
assert.Empty(t, cfg.Contexts, "NewEmptyConfig should have empty contexts")
|
||||||
|
assert.True(t, cfg.IsEmpty(), "NewEmptyConfig should be considered empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConfig_IsEmpty(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
config *Config
|
||||||
|
expected bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "nil contexts",
|
||||||
|
config: &Config{},
|
||||||
|
expected: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty contexts slice",
|
||||||
|
config: &Config{Contexts: []Context{}},
|
||||||
|
expected: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "context with empty namespaces",
|
||||||
|
config: &Config{
|
||||||
|
Contexts: []Context{
|
||||||
|
{Name: "test", Namespaces: []Namespace{}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expected: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "context with namespace but no forwards",
|
||||||
|
config: &Config{
|
||||||
|
Contexts: []Context{
|
||||||
|
{
|
||||||
|
Name: "test",
|
||||||
|
Namespaces: []Namespace{
|
||||||
|
{Name: "default", Forwards: []Forward{}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expected: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "config with forward",
|
||||||
|
config: &Config{
|
||||||
|
Contexts: []Context{
|
||||||
|
{
|
||||||
|
Name: "test",
|
||||||
|
Namespaces: []Namespace{
|
||||||
|
{
|
||||||
|
Name: "default",
|
||||||
|
Forwards: []Forward{
|
||||||
|
{Resource: "pod/app", Port: 8080, LocalPort: 8080},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expected: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
assert.Equal(t, tt.expected, tt.config.IsEmpty())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateEmptyConfigFile(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
configPath := filepath.Join(tmpDir, ".kportal.yaml")
|
||||||
|
|
||||||
|
// Create empty config file
|
||||||
|
err := CreateEmptyConfigFile(configPath)
|
||||||
|
assert.NoError(t, err, "CreateEmptyConfigFile should succeed")
|
||||||
|
|
||||||
|
// Verify file exists
|
||||||
|
_, err = os.Stat(configPath)
|
||||||
|
assert.NoError(t, err, "config file should exist")
|
||||||
|
|
||||||
|
// Verify file is readable and parseable
|
||||||
|
cfg, err := LoadConfig(configPath)
|
||||||
|
assert.NoError(t, err, "should be able to load created config")
|
||||||
|
assert.NotNil(t, cfg, "config should not be nil")
|
||||||
|
assert.True(t, cfg.IsEmpty(), "created config should be empty")
|
||||||
|
|
||||||
|
// Verify file permissions (0600)
|
||||||
|
info, _ := os.Stat(configPath)
|
||||||
|
assert.Equal(t, os.FileMode(0600), info.Mode().Perm(), "file should have 0600 permissions")
|
||||||
|
|
||||||
|
// Verify file contains helpful header
|
||||||
|
content, _ := os.ReadFile(configPath)
|
||||||
|
assert.Contains(t, string(content), "# kportal configuration file", "should contain header comment")
|
||||||
|
assert.Contains(t, string(content), "Example forward", "should contain example")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateEmptyConfigFile_AlreadyExists(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
configPath := filepath.Join(tmpDir, ".kportal.yaml")
|
||||||
|
|
||||||
|
// Create existing file
|
||||||
|
err := os.WriteFile(configPath, []byte("existing content"), 0644)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Try to create config file - should fail
|
||||||
|
err = CreateEmptyConfigFile(configPath)
|
||||||
|
assert.Error(t, err, "CreateEmptyConfigFile should fail when file exists")
|
||||||
|
assert.Contains(t, err.Error(), "already exists")
|
||||||
|
|
||||||
|
// Verify original content is preserved
|
||||||
|
content, _ := os.ReadFile(configPath)
|
||||||
|
assert.Equal(t, "existing content", string(content))
|
||||||
|
}
|
||||||
|
|||||||
@@ -37,6 +37,13 @@ func NewValidator() *Validator {
|
|||||||
|
|
||||||
// ValidateConfig validates the entire configuration and returns all errors found.
|
// ValidateConfig validates the entire configuration and returns all errors found.
|
||||||
func (v *Validator) ValidateConfig(cfg *Config) []ValidationError {
|
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
|
var errs []ValidationError
|
||||||
|
|
||||||
if cfg == nil {
|
if cfg == nil {
|
||||||
@@ -46,6 +53,12 @@ func (v *Validator) ValidateConfig(cfg *Config) []ValidationError {
|
|||||||
}}
|
}}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
return errs
|
||||||
|
}
|
||||||
|
|
||||||
// Validate structure
|
// Validate structure
|
||||||
errs = append(errs, v.validateStructure(cfg)...)
|
errs = append(errs, v.validateStructure(cfg)...)
|
||||||
|
|
||||||
|
|||||||
@@ -972,3 +972,141 @@ func TestIsAlphanumeric(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestValidator_ValidateConfigWithOptions(t *testing.T) {
|
||||||
|
validator := NewValidator()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
config *Config
|
||||||
|
allowEmpty bool
|
||||||
|
expectErrors bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "empty config - strict mode",
|
||||||
|
config: &Config{Contexts: []Context{}},
|
||||||
|
allowEmpty: false,
|
||||||
|
expectErrors: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty config - allow empty",
|
||||||
|
config: &Config{Contexts: []Context{}},
|
||||||
|
allowEmpty: true,
|
||||||
|
expectErrors: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "nil contexts - allow empty",
|
||||||
|
config: &Config{},
|
||||||
|
allowEmpty: true,
|
||||||
|
expectErrors: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "context with no forwards - allow empty",
|
||||||
|
config: &Config{
|
||||||
|
Contexts: []Context{
|
||||||
|
{
|
||||||
|
Name: "dev",
|
||||||
|
Namespaces: []Namespace{
|
||||||
|
{Name: "default", Forwards: []Forward{}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
allowEmpty: true,
|
||||||
|
expectErrors: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "valid config - strict mode",
|
||||||
|
config: &Config{
|
||||||
|
Contexts: []Context{
|
||||||
|
{
|
||||||
|
Name: "dev-cluster",
|
||||||
|
Namespaces: []Namespace{
|
||||||
|
{
|
||||||
|
Name: "default",
|
||||||
|
Forwards: []Forward{
|
||||||
|
{
|
||||||
|
Resource: "pod/my-app",
|
||||||
|
Protocol: "tcp",
|
||||||
|
Port: 8080,
|
||||||
|
LocalPort: 8080,
|
||||||
|
contextName: "dev-cluster",
|
||||||
|
namespaceName: "default",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
allowEmpty: false,
|
||||||
|
expectErrors: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "valid config - allow empty (should still validate)",
|
||||||
|
config: &Config{
|
||||||
|
Contexts: []Context{
|
||||||
|
{
|
||||||
|
Name: "dev-cluster",
|
||||||
|
Namespaces: []Namespace{
|
||||||
|
{
|
||||||
|
Name: "default",
|
||||||
|
Forwards: []Forward{
|
||||||
|
{
|
||||||
|
Resource: "pod/my-app",
|
||||||
|
Protocol: "tcp",
|
||||||
|
Port: 8080,
|
||||||
|
LocalPort: 8080,
|
||||||
|
contextName: "dev-cluster",
|
||||||
|
namespaceName: "default",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
allowEmpty: true,
|
||||||
|
expectErrors: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid forward in non-empty config - allow empty still validates",
|
||||||
|
config: &Config{
|
||||||
|
Contexts: []Context{
|
||||||
|
{
|
||||||
|
Name: "dev-cluster",
|
||||||
|
Namespaces: []Namespace{
|
||||||
|
{
|
||||||
|
Name: "default",
|
||||||
|
Forwards: []Forward{
|
||||||
|
{
|
||||||
|
Resource: "pod/my-app",
|
||||||
|
Protocol: "tcp",
|
||||||
|
Port: 0, // Invalid port
|
||||||
|
LocalPort: 8080,
|
||||||
|
contextName: "dev-cluster",
|
||||||
|
namespaceName: "default",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
allowEmpty: true,
|
||||||
|
expectErrors: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
errs := validator.ValidateConfigWithOptions(tt.config, tt.allowEmpty)
|
||||||
|
|
||||||
|
if tt.expectErrors {
|
||||||
|
assert.NotEmpty(t, errs, "expected validation errors")
|
||||||
|
} else {
|
||||||
|
assert.Empty(t, errs, "expected no validation errors, got: %v", errs)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -169,8 +169,10 @@ func (m *Manager) Start(cfg *config.Config) error {
|
|||||||
// Get all forwards from config
|
// Get all forwards from config
|
||||||
forwards := cfg.GetAllForwards()
|
forwards := cfg.GetAllForwards()
|
||||||
|
|
||||||
|
// Empty config is valid - user can add forwards later via TUI
|
||||||
if len(forwards) == 0 {
|
if len(forwards) == 0 {
|
||||||
return fmt.Errorf("no forwards configured")
|
log.Printf("No forwards configured - use 'n' to add forwards")
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check port availability before starting
|
// Check port availability before starting
|
||||||
|
|||||||
@@ -124,8 +124,8 @@ func TestManager_Start_EmptyForwards(t *testing.T) {
|
|||||||
|
|
||||||
cfg := &config.Config{}
|
cfg := &config.Config{}
|
||||||
err = manager.Start(cfg)
|
err = manager.Start(cfg)
|
||||||
assert.Error(t, err)
|
// Empty config is now valid - allows users to add forwards via TUI
|
||||||
assert.Contains(t, err.Error(), "no forwards configured")
|
assert.NoError(t, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestManager_Reload_NilConfig tests reloading with nil config
|
// TestManager_Reload_NilConfig tests reloading with nil config
|
||||||
|
|||||||
@@ -89,6 +89,11 @@ func (p *Publisher) Register(forwardID, alias string, localPort int) error {
|
|||||||
p.servers[forwardID] = server
|
p.servers[forwardID] = server
|
||||||
p.aliases[forwardID] = alias
|
p.aliases[forwardID] = alias
|
||||||
|
|
||||||
|
// Allow zeroconf's internal goroutines (recv4, recv6) to fully initialize.
|
||||||
|
// This prevents a race condition where shutdown() could set connections to nil
|
||||||
|
// while recv goroutines are still starting up.
|
||||||
|
time.Sleep(startupSettleTime)
|
||||||
|
|
||||||
logger.Info("mDNS hostname registered", map[string]interface{}{
|
logger.Info("mDNS hostname registered", map[string]interface{}{
|
||||||
"forward_id": forwardID,
|
"forward_id": forwardID,
|
||||||
"hostname": GetHostname(alias),
|
"hostname": GetHostname(alias),
|
||||||
@@ -151,6 +156,13 @@ func (p *Publisher) Stop() {
|
|||||||
logger.Info("mDNS publisher stopped", nil)
|
logger.Info("mDNS publisher stopped", nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// startupSettleTime is a small delay after zeroconf registration to allow internal
|
||||||
|
// goroutines (recv4, recv6) to fully initialize before any shutdown can occur.
|
||||||
|
// This works around a race condition in grandcat/zeroconf where shutdown() sets
|
||||||
|
// connections to nil while recv goroutines may still be initializing.
|
||||||
|
// See: https://github.com/grandcat/zeroconf/issues/95
|
||||||
|
const startupSettleTime = 50 * time.Millisecond
|
||||||
|
|
||||||
// shutdownSettleTime is a small delay after zeroconf shutdown to allow internal
|
// shutdownSettleTime is a small delay after zeroconf shutdown to allow internal
|
||||||
// goroutines to exit cleanly. This works around a race condition in the
|
// goroutines to exit cleanly. This works around a race condition in the
|
||||||
// grandcat/zeroconf library where recv4() can access ipv4conn after shutdown()
|
// grandcat/zeroconf library where recv4() can access ipv4conn after shutdown()
|
||||||
|
|||||||
Reference in New Issue
Block a user