feat: support running OpenCost outside Kubernetes with external Prometheus

This commit is contained in:
ljluestc
2025-08-22 17:55:18 -07:00
parent 013b6bc252
commit 76697bd4b8
3 changed files with 326 additions and 0 deletions

149
cmd/kubectx/history.go Normal file
View File

@@ -0,0 +1,149 @@
// Copyright 2021 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package main
import (
"encoding/json"
"os"
"path/filepath"
"sort"
)
const (
maxHistoryLength = 10
)
// ContextHistory stores the history of used contexts
type ContextHistory struct {
Contexts []string `json:"contexts"`
}
// Add adds a context to history, moving it to the front if it already exists
func (h *ContextHistory) Add(ctx string) {
// Remove if exists
for i, c := range h.Contexts {
if c == ctx {
h.Contexts = append(h.Contexts[:i], h.Contexts[i+1:]...)
break
}
}
// Add to front
h.Contexts = append([]string{ctx}, h.Contexts...)
// Trim if needed
if len(h.Contexts) > maxHistoryLength {
h.Contexts = h.Contexts[:maxHistoryLength]
}
}
// getHistoryFilePath returns the path to the history file
var getHistoryFilePath = func() (string, error) {
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
return filepath.Join(home, ".kube", "kubectx_history.json"), nil
}
// loadHistory loads the context history from disk
func loadHistory() (*ContextHistory, error) {
path, err := getHistoryFilePath()
if err != nil {
return nil, err
}
// If file doesn't exist, return empty history
if _, err := os.Stat(path); os.IsNotExist(err) {
return &ContextHistory{Contexts: []string{}}, nil
}
// Read file
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
// Parse JSON
var history ContextHistory
if len(data) > 0 {
if err := json.Unmarshal(data, &history); err != nil {
return nil, err
}
} else {
history.Contexts = []string{}
}
return &history, nil
}
// saveHistory saves the context history to disk
func saveHistory(history *ContextHistory) error {
path, err := getHistoryFilePath()
if err != nil {
return err
}
// Create directory if needed
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0755); err != nil {
return err
}
// Serialize to JSON
data, err := json.Marshal(history)
if err != nil {
return err
}
// Write to file
return os.WriteFile(path, data, 0644)
}
// prioritizeContexts sorts contexts with recently used ones first
func prioritizeContexts(allContexts []string, historyContexts []string) []string {
// Create a map for O(1) lookup
seen := make(map[string]bool)
result := []string{}
// First add contexts from history that exist in allContexts
for _, ctx := range historyContexts {
if contains(allContexts, ctx) {
result = append(result, ctx)
seen[ctx] = true
}
}
// Then add remaining contexts in alphabetical order
remaining := []string{}
for _, ctx := range allContexts {
if !seen[ctx] {
remaining = append(remaining, ctx)
}
}
sort.Strings(remaining)
return append(result, remaining...)
}
// contains checks if a string is in a slice
func contains(slice []string, str string) bool {
for _, s := range slice {
if s == str {
return true
}
}
return false
}

82
cmd/kubectx/main_test.go Normal file
View File

@@ -0,0 +1,82 @@
package main
import (
"io/ioutil"
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
)
func TestContextHistory(t *testing.T) {
// Create a temporary directory for testing
tmpDir, err := ioutil.TempDir("", "kubectx-test")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
// Override history file path for testing
origHistoryFilePath := getHistoryFilePath
defer func() { getHistoryFilePath = origHistoryFilePath }()
historyPath := filepath.Join(tmpDir, "history.json")
getHistoryFilePath = func() (string, error) {
return historyPath, nil
}
t.Run("New history is empty", func(t *testing.T) {
history, err := loadHistory()
assert.NoError(t, err)
assert.NotNil(t, history)
assert.Empty(t, history.Contexts)
})
t.Run("Add context to history", func(t *testing.T) {
history := &ContextHistory{Contexts: []string{}}
history.Add("context1")
assert.Equal(t, []string{"context1"}, history.Contexts)
// Add the same context again
history.Add("context1")
assert.Equal(t, []string{"context1"}, history.Contexts)
// Add another context
history.Add("context2")
assert.Equal(t, []string{"context2", "context1"}, history.Contexts)
})
t.Run("Save and load history", func(t *testing.T) {
history := &ContextHistory{Contexts: []string{"context1", "context2"}}
err := saveHistory(history)
assert.NoError(t, err)
loaded, err := loadHistory()
assert.NoError(t, err)
assert.Equal(t, history.Contexts, loaded.Contexts)
})
t.Run("History respects max length", func(t *testing.T) {
history := &ContextHistory{Contexts: []string{}}
// Add more than maxHistoryLength contexts
for i := 0; i < maxHistoryLength+5; i++ {
history.Add(fmt.Sprintf("context%d", i))
}
assert.Equal(t, maxHistoryLength, len(history.Contexts))
assert.Equal(t, "context9", history.Contexts[0])
})
}
func TestPrioritizeContexts(t *testing.T) {
allContexts := []string{"a", "b", "c", "d", "e"}
historyContexts := []string{"c", "a", "f"} // Note: "f" is not in allContexts
result := prioritizeContexts(allContexts, historyContexts)
// Expected: c, a (from history, in order), then b, d, e alphabetically
expected := []string{"c", "a", "b", "d", "e"}
assert.Equal(t, expected, result)
}

95
internal/flags/flags.go Normal file
View File

@@ -0,0 +1,95 @@
package flags
import (
"fmt"
"strings"
)
// Flags contains parsed flags.
type Flags struct {
Delete string
Current bool
ShowHelp bool
Version bool
Unset bool
History bool
SelectedContext []string
NewContext []string
}
// parseArgs parses given command line arguments to flags.
func parseArgs(args []string) (Flags, error) {
var f Flags
// filter out first argument (program name)
if len(args) > 0 {
args = args[1:]
}
// parse flags
for i := 0; i < len(args); i++ {
if args[i] == "--help" || args[i] == "-h" {
f.ShowHelp = true
continue
}
if args[i] == "--current" || args[i] == "-c" {
f.Current = true
continue
}
if args[i] == "--unset" || args[i] == "-u" {
f.Unset = true
continue
}
if args[i] == "--version" || args[i] == "-V" {
f.Version = true
continue
}
if args[i] == "--history" {
f.History = true
continue
}
if (args[i] == "--delete" || args[i] == "-d") && i+1 < len(args) {
f.Delete = args[i+1]
i++
continue
}
// <NEW>=<OLD>
if strings.Contains(args[i], "=") {
a := strings.SplitN(args[i], "=", 2)
if len(a) != 2 {
return f, fmt.Errorf("invalid argument: %s", args[i])
}
new, old := a[0], a[1]
if new == "" || old == "" {
return f, fmt.Errorf("invalid argument: %s", args[i])
}
f.NewContext = []string{new, old}
continue
}
// <CONTEXT>
f.SelectedContext = append(f.SelectedContext, args[i])
}
return f, nil
}
// New returns empty flags.
func New() *Flags {
return &Flags{}
}
// Parse command line flags.
func (f *Flags) Parse(args []string) error {
fl, err := parseArgs(args)
if err != nil {
return err
}
*f = fl
return nil
}