diff --git a/plugins/kubectl/args.go b/plugins/kubectl/args.go new file mode 100644 index 00000000..48a65270 --- /dev/null +++ b/plugins/kubectl/args.go @@ -0,0 +1,273 @@ +package kubectl + +import ( + "regexp" + "strconv" + "strings" +) + +type parsedArgs struct { + flags map[string][]string + positionals []string + rest []string + ambiguous bool +} + +var valueFlags = map[string]bool{ + "as": true, + "as-group": true, + "as-uid": true, + "as-user-extra": true, + "cache-dir": true, + "certificate-authority": true, + "client-certificate": true, + "client-key": true, + "cluster": true, + "context": true, + "kubeconfig": true, + "kuberc": true, + "log-flush-frequency": true, + "namespace": true, + "password": true, + "profile": true, + "profile-output": true, + "proxy-url": true, + "request-timeout": true, + "server": true, + "tls-server-name": true, + "token": true, + "user": true, + "username": true, + "v": true, + "vmodule": true, +} + +var boolFlags = map[string]bool{ + "insecure-skip-tls-verify": true, + "match-server-version": true, + "disable-compression": true, + "warnings-as-errors": true, + "help": true, + "client": true, +} + +var shortFlags = map[string]string{"n": "namespace", "s": "server", "v": "v", "h": "help"} + +type argToken struct { + name string + value string + start int + end int + from int + to int + short bool +} + +func normalizeFlagName(name string) string { + return strings.ReplaceAll(name, "_", "-") +} + +func scanArgs(args []string) (tokens []argToken, positionals []string, terminator int, ambiguous bool) { + terminator = -1 + for i := 0; i < len(args); i++ { + arg := args[i] + switch { + case arg == "--": + return tokens, positionals, i, ambiguous + case strings.HasPrefix(arg, "--"): + name, value, hasValue := strings.Cut(arg[2:], "=") + name = normalizeFlagName(name) + tok := argToken{name: name, start: i, end: i + 1} + switch { + case hasValue: + tok.value = value + case valueFlags[name]: + if i+1 < len(args) { + i++ + tok.value = args[i] + tok.end = i + 1 + } + default: + tok.value = "true" + if !boolFlags[name] && i+1 < len(args) && isTargetingFlag(args[i+1]) { + ambiguous = true + } + } + tokens = append(tokens, tok) + case strings.HasPrefix(arg, "-") && len(arg) > 1: + var consumed int + tokens, consumed = scanShorthands(tokens, args, i) + i += consumed + if _, known := shortFlags[arg[1:2]]; !known && hidesTargetingShorthand(arg) { + ambiguous = true + } + default: + positionals = append(positionals, arg) + } + } + return tokens, positionals, terminator, ambiguous +} + +var targetingFlags = map[string]bool{ + "server": true, "context": true, "cluster": true, "kubeconfig": true, +} + +func isTargetingFlag(arg string) bool { + switch { + case strings.HasPrefix(arg, "--") && len(arg) > 2: + name, _, _ := strings.Cut(arg[2:], "=") + return targetingFlags[normalizeFlagName(name)] + case strings.HasPrefix(arg, "-") && len(arg) > 1: + return targetingFlags[shortFlags[arg[1:2]]] + } + return false +} + +var hostPortPattern = regexp.MustCompile(`:[0-9]+`) + +func hidesTargetingShorthand(arg string) bool { + for j := 2; j < len(arg); j++ { + if arg[j] != 's' { + continue + } + rest := arg[j+1:] + if strings.HasPrefix(rest, "=") || strings.Contains(rest, "://") || hostPortPattern.MatchString(rest) { + return true + } + } + return false +} + +func scanShorthands(tokens []argToken, args []string, i int) ([]argToken, int) { + arg := args[i] + if _, known := shortFlags[arg[1:2]]; !known { + return append(tokens, argToken{name: arg[1:2], value: "true", start: i, end: i + 1, from: 1, to: len(arg), short: true}), 0 + } + for j := 1; j < len(arg); j++ { + letter := arg[j : j+1] + name, known := shortFlags[letter] + tok := argToken{name: letter, value: "true", start: i, end: i + 1, from: j, to: j + 1, short: true} + if !known { + if j+1 < len(arg) && arg[j+1] == '=' { + tok.to = len(arg) + return append(tokens, tok), 0 + } + tokens = append(tokens, tok) + continue + } + tok.name = name + if valueFlags[name] { + tok.to = len(arg) + if j+1 < len(arg) { + tok.value = strings.TrimPrefix(arg[j+1:], "=") + return append(tokens, tok), 0 + } + tok.value = "" + consumed := 0 + if i+1 < len(args) { + tok.value = args[i+1] + tok.end = i + 2 + consumed = 1 + } + return append(tokens, tok), consumed + } + if j+1 < len(arg) && arg[j+1] == '=' { + tok.value = arg[j+2:] + tok.to = len(arg) + return append(tokens, tok), 0 + } + tokens = append(tokens, tok) + } + return tokens, 0 +} + +func parseArgs(args []string) parsedArgs { + tokens, positionals, terminator, ambiguous := scanArgs(args) + p := parsedArgs{flags: map[string][]string{}, positionals: positionals, ambiguous: ambiguous} + for _, tok := range tokens { + p.flags[tok.name] = append(p.flags[tok.name], tok.value) + } + if terminator >= 0 { + p.rest = append([]string(nil), args[terminator+1:]...) + } + return p +} + +func (p parsedArgs) value(name string) (string, bool) { + values := p.flags[normalizeFlagName(name)] + if len(values) == 0 { + return "", false + } + last := values[len(values)-1] + return last, last != "" +} + +func (p parsedArgs) has(name string) bool { + _, ok := p.flags[normalizeFlagName(name)] + return ok +} + +func (p parsedArgs) boolValue(name string) bool { + values := p.flags[normalizeFlagName(name)] + if len(values) == 0 { + return false + } + b, err := strconv.ParseBool(values[len(values)-1]) + return err == nil && b +} + +func (p parsedArgs) isAmbiguous() bool { return p.ambiguous } + +func (p parsedArgs) subcommand() string { + if len(p.positionals) == 0 { + return "" + } + return p.positionals[0] +} + +func removeFlag(args []string, name string) []string { + name = normalizeFlagName(name) + tokens, _, _, _ := scanArgs(args) + dropped := make([]bool, len(args)) + cuts := map[int][][2]int{} + for _, tok := range tokens { + if tok.name != name { + continue + } + if !tok.short { + for k := tok.start; k < tok.end; k++ { + dropped[k] = true + } + continue + } + cuts[tok.start] = append(cuts[tok.start], [2]int{tok.from, tok.to}) + for k := tok.start + 1; k < tok.end; k++ { + dropped[k] = true + } + } + out := make([]string, 0, len(args)) + for k, arg := range args { + if dropped[k] { + continue + } + if ranges, ok := cuts[k]; ok { + arg = cutRanges(arg, ranges) + if arg == "-" { + continue + } + } + out = append(out, arg) + } + return out +} + +func cutRanges(arg string, ranges [][2]int) string { + var b strings.Builder + pos := 0 + for _, r := range ranges { + b.WriteString(arg[pos:r[0]]) + pos = r[1] + } + b.WriteString(arg[pos:]) + return b.String() +} diff --git a/plugins/kubectl/args_test.go b/plugins/kubectl/args_test.go new file mode 100644 index 00000000..217bae3a --- /dev/null +++ b/plugins/kubectl/args_test.go @@ -0,0 +1,421 @@ +package kubectl + +import ( + "reflect" + "testing" +) + +func sameStrings(a, b []string) bool { + if len(a) == 0 && len(b) == 0 { + return true + } + return reflect.DeepEqual(a, b) +} + +func TestParseArgsTerminatorAndConsumedValues(t *testing.T) { + tests := []struct { + name string + args []string + positionals []string + rest []string + values map[string]string + absent []string + subcommand string + }{ + { + name: "terminator consumed as namespace value", + args: []string{"--namespace", "--", "version", "--server=https://x"}, + positionals: []string{"version"}, + values: map[string]string{"namespace": "--", "server": "https://x"}, + subcommand: "version", + }, + { + name: "flag-looking token consumed as namespace value", + args: []string{"--namespace", "--server=https://x", "version"}, + positionals: []string{"version"}, + values: map[string]string{"namespace": "--server=https://x"}, + absent: []string{"server"}, + subcommand: "version", + }, + { + name: "terminator at option boundary", + args: []string{"exec", "pod", "--", "df", "-h"}, + positionals: []string{"exec", "pod"}, + rest: []string{"df", "-h"}, + absent: []string{"help", "h"}, + subcommand: "exec", + }, + { + name: "flags after terminator are not parsed", + args: []string{"exec", "--context", "a", "pod", "--", "kubectl", "--context", "b"}, + positionals: []string{"exec", "pod"}, + rest: []string{"kubectl", "--context", "b"}, + values: map[string]string{"context": "a"}, + subcommand: "exec", + }, + { + name: "global flags before subcommand are skipped", + args: []string{"--context", "x", "-n", "ns", "config", "view"}, + positionals: []string{"config", "view"}, + subcommand: "config", + values: map[string]string{"context": "x", "namespace": "ns"}, + }, + { + name: "unknown long flag never consumes next token", + args: []string{"--frobnicate", "get", "pods"}, + positionals: []string{"get", "pods"}, + values: map[string]string{"frobnicate": "true"}, + subcommand: "get", + }, + { + name: "single dash is a positional", + args: []string{"apply", "-f", "-"}, + positionals: []string{"apply", "-"}, + values: map[string]string{"f": "true"}, + subcommand: "apply", + }, + { + name: "no args", + args: nil, + subcommand: "", + }, + { + name: "only terminator", + args: []string{"--"}, + subcommand: "", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + p := parseArgs(tt.args) + if !sameStrings(p.positionals, tt.positionals) { + t.Errorf("positionals = %q, want %q", p.positionals, tt.positionals) + } + if !sameStrings(p.rest, tt.rest) { + t.Errorf("rest = %q, want %q", p.rest, tt.rest) + } + for name, want := range tt.values { + if got, ok := p.value(name); !ok || got != want { + t.Errorf("value(%q) = %q, %v; want %q, true", name, got, ok, want) + } + } + for _, name := range tt.absent { + if p.has(name) { + t.Errorf("has(%q) = true, want false", name) + } + } + if got := p.subcommand(); got != tt.subcommand { + t.Errorf("subcommand() = %q, want %q", got, tt.subcommand) + } + }) + } +} + +func TestParseArgsFlagForms(t *testing.T) { + tests := []struct { + name string + args []string + flag string + want string + }{ + {"last long value wins", []string{"--context", "a", "--context=b"}, "context", "b"}, + {"last long value wins reversed", []string{"--context=b", "--context", "a"}, "context", "a"}, + {"short attached", []string{"-sX"}, "server", "X"}, + {"short separate", []string{"-s", "X"}, "server", "X"}, + {"short equals", []string{"-s=X"}, "server", "X"}, + {"long separate", []string{"--server", "X"}, "server", "X"}, + {"long equals", []string{"--server=X"}, "server", "X"}, + {"underscore normalised with equals", []string{"--client_certificate=p"}, "client-certificate", "p"}, + {"underscore normalised separate", []string{"--client_certificate", "p"}, "client-certificate", "p"}, + {"namespace short", []string{"-n", "ns"}, "namespace", "ns"}, + {"namespace short attached", []string{"-nns"}, "namespace", "ns"}, + {"verbosity short equals", []string{"-v=5"}, "v", "5"}, + {"verbosity short attached", []string{"-v5"}, "v", "5"}, + {"verbosity short separate", []string{"-v", "5"}, "v", "5"}, + {"verbosity long", []string{"--v=5"}, "v", "5"}, + {"equals value containing equals", []string{"--server=https://x/?a=b"}, "server", "https://x/?a=b"}, + {"as-user-extra repeated keeps last", []string{"--as-user-extra", "a=1", "--as-user-extra=b=2"}, "as-user-extra", "b=2"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + p := parseArgs(tt.args) + if got, ok := p.value(tt.flag); !ok || got != tt.want { + t.Errorf("value(%q) = %q, %v; want %q, true", tt.flag, got, ok, tt.want) + } + if len(p.positionals) != 0 { + t.Errorf("positionals = %q, want none", p.positionals) + } + }) + } +} + +func TestParseArgsRepeatedValuesInOrder(t *testing.T) { + p := parseArgs([]string{"--context", "a", "--context=b", "-s", "X"}) + if got, want := p.flags["context"], []string{"a", "b"}; !reflect.DeepEqual(got, want) { + t.Errorf("flags[context] = %q, want %q", got, want) + } +} + +func TestParseArgsMissingValue(t *testing.T) { + for _, args := range [][]string{{"--namespace"}, {"get", "-n"}, {"get", "--server"}} { + p := parseArgs(args) + for _, name := range []string{"namespace", "server"} { + if !p.has(name) { + continue + } + if got, ok := p.value(name); ok || got != "" { + t.Errorf("%q: value(%q) = %q, %v; want \"\", false", args, name, got, ok) + } + } + } + p := parseArgs([]string{"--namespace"}) + if !p.has("namespace") { + t.Errorf("has(namespace) = false, want true for trailing value flag") + } + p = parseArgs([]string{"--server="}) + if !p.has("server") { + t.Errorf("has(server) = false, want true for --server=") + } +} + +func TestBoolValue(t *testing.T) { + tests := []struct { + name string + args []string + flag string + want bool + }{ + {"client bare", []string{"version", "--client"}, "client", true}, + {"client true", []string{"version", "--client=true"}, "client", true}, + {"client then true positional", []string{"version", "--client", "true"}, "client", true}, + {"client false", []string{"version", "--client=false"}, "client", false}, + {"client last wins false", []string{"version", "--client=true", "--client=false"}, "client", false}, + {"client last wins true", []string{"version", "--client=false", "--client"}, "client", true}, + {"client absent", []string{"version"}, "client", false}, + {"short help", []string{"-h"}, "help", true}, + {"long help", []string{"--help"}, "help", true}, + {"short help false", []string{"-h=false"}, "help", false}, + {"long help false", []string{"--help=false"}, "help", false}, + {"help consumed as namespace value", []string{"--namespace", "--help"}, "help", false}, + {"help after terminator", []string{"exec", "pod", "--", "-h"}, "help", false}, + {"cluster help false", []string{"-hh=false"}, "help", false}, + {"cluster help then namespace", []string{"-hnns"}, "help", true}, + {"insecure bare", []string{"--insecure-skip-tls-verify", "get"}, "insecure-skip-tls-verify", true}, + {"insecure underscore", []string{"--insecure_skip_tls_verify=true"}, "insecure-skip-tls-verify", true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := parseArgs(tt.args).boolValue(tt.flag); got != tt.want { + t.Errorf("boolValue(%q) on %q = %v, want %v", tt.flag, tt.args, got, tt.want) + } + }) + } + + p := parseArgs([]string{"version", "--client", "true"}) + if !sameStrings(p.positionals, []string{"version", "true"}) { + t.Errorf("positionals = %q, want [version true]", p.positionals) + } +} + +func TestShorthandClusters(t *testing.T) { + p := parseArgs([]string{"-hnns", "get"}) + if !p.boolValue("help") { + t.Errorf("-hnns: boolValue(help) = false, want true") + } + if got, ok := p.value("namespace"); !ok || got != "ns" { + t.Errorf("-hnns: value(namespace) = %q, %v; want ns, true", got, ok) + } + if !sameStrings(p.positionals, []string{"get"}) { + t.Errorf("-hnns: positionals = %q, want [get]", p.positionals) + } + + p = parseArgs([]string{"-hn", "ns", "get"}) + if got, _ := p.value("namespace"); got != "ns" || !p.boolValue("help") { + t.Errorf("-hn ns: namespace = %q help = %v", got, p.boolValue("help")) + } + + p = parseArgs([]string{"-hn=ns"}) + if got, _ := p.value("namespace"); got != "ns" { + t.Errorf("-hn=ns: namespace = %q, want ns", got) + } + + p = parseArgs([]string{"-hh=false"}) + if p.boolValue("help") || !p.has("help") { + t.Errorf("-hh=false: boolValue(help) = %v has = %v, want false, true", p.boolValue("help"), p.has("help")) + } + + p = parseArgs([]string{"-hxs", "X"}) + if !p.has("x") || !p.boolValue("help") { + t.Errorf("-hxs X: unknown letter in a cluster must be recorded: %v", p.flags) + } + if got, _ := p.value("server"); got != "X" { + t.Errorf("-hxs X: server = %q, want X", got) + } + + p = parseArgs([]string{"get", "-ojson", "pods"}) + if p.has("server") || p.has("namespace") || !p.has("o") { + t.Errorf("-ojson must be one unknown flag, got %v", p.flags) + } + if !sameStrings(p.positionals, []string{"get", "pods"}) { + t.Errorf("-ojson: positionals = %q", p.positionals) + } +} + +func TestUnknownShortFlag(t *testing.T) { + p := parseArgs([]string{"get", "-o", "json", "-A"}) + if !p.has("o") || !p.has("A") { + t.Errorf("unknown short flags must be recorded under their letter: %v", p.flags) + } + if !sameStrings(p.positionals, []string{"get", "json"}) { + t.Errorf("positionals = %q, want [get json]", p.positionals) + } +} + +func TestRemoveFlag(t *testing.T) { + tests := []struct { + name string + args []string + flag string + want []string + }{ + {"separate value", []string{"--kubeconfig", "f", "get", "pods"}, "kubeconfig", []string{"get", "pods"}}, + {"equals value", []string{"--kubeconfig=f", "get", "pods"}, "kubeconfig", []string{"get", "pods"}}, + {"every occurrence", []string{"--kubeconfig", "a", "get", "--kubeconfig=b", "pods"}, "kubeconfig", []string{"get", "pods"}}, + {"underscore spelling", []string{"--client_certificate", "p", "get"}, "client-certificate", []string{"get"}}, + {"underscore in requested name", []string{"--client-certificate=p", "get"}, "client_certificate", []string{"get"}}, + {"after terminator untouched", []string{"exec", "pod", "--", "kubectl", "--kubeconfig", "f"}, "kubeconfig", []string{"exec", "pod", "--", "kubectl", "--kubeconfig", "f"}}, + {"before and after terminator", []string{"--kubeconfig=f", "exec", "--", "--kubeconfig=g"}, "kubeconfig", []string{"exec", "--", "--kubeconfig=g"}}, + {"no value", []string{"--kubeconfig"}, "kubeconfig", []string{}}, + {"short spelling", []string{"-n", "ns", "get", "-nother", "pods", "-n=x"}, "namespace", []string{"get", "pods"}}, + {"cluster remove namespace keeps help", []string{"-hnns", "get"}, "namespace", []string{"-h", "get"}}, + {"cluster remove help keeps namespace", []string{"-hnns", "get"}, "help", []string{"-nns", "get"}}, + {"cluster remove namespace with separate value", []string{"-hn", "ns", "get"}, "namespace", []string{"-h", "get"}}, + {"cluster remove help with trailing value flag", []string{"-hn", "ns", "get"}, "help", []string{"-n", "ns", "get"}}, + {"cluster remove only flag drops token", []string{"-hh=false", "get"}, "help", []string{"get"}}, + {"cluster remove namespace from -nhx", []string{"-nhx", "get"}, "namespace", []string{"get"}}, + {"consumed value is not a flag", []string{"--namespace", "--kubeconfig", "get"}, "kubeconfig", []string{"--namespace", "--kubeconfig", "get"}}, + {"other flags kept", []string{"--context", "c", "--kubeconfig", "f", "get"}, "kubeconfig", []string{"--context", "c", "get"}}, + {"absent", []string{"get", "pods"}, "kubeconfig", []string{"get", "pods"}}, + {"nil input", nil, "kubeconfig", []string{}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var before []string + if tt.args != nil { + before = append([]string{}, tt.args...) + } + got := removeFlag(tt.args, tt.flag) + if got == nil || !reflect.DeepEqual(got, tt.want) { + t.Errorf("removeFlag(%q, %q) = %#v, want %#v", tt.args, tt.flag, got, tt.want) + } + if tt.args != nil && !reflect.DeepEqual(tt.args, before) { + t.Errorf("input mutated: %q, was %q", tt.args, before) + } + }) + } +} + +func TestRemoveFlagDoesNotAliasInput(t *testing.T) { + in := []string{"get", "pods"} + out := removeFlag(in, "kubeconfig") + out[0] = "changed" + if in[0] != "get" { + t.Errorf("returned slice aliases the input") + } +} + +func TestFlagTables(t *testing.T) { + wantValue := []string{ + "as", "as-group", "as-uid", "as-user-extra", "cache-dir", "certificate-authority", + "client-certificate", "client-key", "cluster", "context", "kubeconfig", "kuberc", + "log-flush-frequency", "namespace", "password", "profile", "profile-output", + "proxy-url", "request-timeout", "server", "tls-server-name", "token", "user", + "username", "v", "vmodule", + } + wantBool := []string{ + "insecure-skip-tls-verify", "match-server-version", "disable-compression", + "warnings-as-errors", "help", "client", + } + if len(valueFlags) != len(wantValue) { + t.Errorf("valueFlags has %d entries, want %d", len(valueFlags), len(wantValue)) + } + for _, name := range wantValue { + if !valueFlags[name] { + t.Errorf("valueFlags missing %q", name) + } + if boolFlags[name] { + t.Errorf("boolFlags must not contain value flag %q", name) + } + } + if len(boolFlags) != len(wantBool) { + t.Errorf("boolFlags has %d entries, want %d", len(boolFlags), len(wantBool)) + } + for _, name := range wantBool { + if !boolFlags[name] { + t.Errorf("boolFlags missing %q", name) + } + if valueFlags[name] { + t.Errorf("valueFlags must not contain boolean flag %q", name) + } + } + wantShort := map[string]string{"n": "namespace", "s": "server", "v": "v", "h": "help"} + if !reflect.DeepEqual(shortFlags, wantShort) { + t.Errorf("shortFlags = %v, want %v", shortFlags, wantShort) + } +} + +func TestIsAmbiguous(t *testing.T) { + tests := []struct { + name string + args []string + want bool + }{ + {"unknown cluster hides server shorthand", []string{"get", "pods", "-Ashttps://other"}, true}, + {"unknown cluster with host and port", []string{"get", "pods", "-Asother.example.com:6443"}, true}, + {"label selector with dotted key", []string{"get", "pods", "-lapp.kubernetes.io/name=x"}, false}, + {"label selector with two terms", []string{"get", "pods", "-lapp=x,tier=db"}, false}, + {"unknown cluster with namespace equals", []string{"get", "pods", "-An=x"}, false}, + {"label selector env", []string{"get", "pods", "-lenv=prod"}, false}, + {"label selector run", []string{"get", "pods", "-lrun=x"}, false}, + {"unknown cluster with server equals", []string{"get", "pods", "-As=https://x"}, true}, + {"unknown long flag then server", []string{"get", "pods", "--template", "--server=https://x"}, true}, + {"unknown long flag then server separate", []string{"get", "pods", "--template", "--server", "https://x"}, true}, + {"unknown long flag then short server", []string{"get", "pods", "--template", "-s", "https://x"}, true}, + {"unknown long flag then short server attached", []string{"get", "pods", "--template", "-shttps://x"}, true}, + {"unknown long flag then kubeconfig", []string{"get", "pods", "--template", "--kubeconfig", "f"}, true}, + {"unknown long flag then cluster", []string{"get", "pods", "--template", "--cluster=c"}, true}, + {"show-labels then namespace short", []string{"get", "pods", "--show-labels", "-n", "foo"}, false}, + {"show-labels then namespace attached", []string{"get", "pods", "--show-labels", "-nfoo"}, false}, + {"all-namespaces then namespace long", []string{"get", "pods", "--all-namespaces", "--namespace", "foo"}, false}, + {"show-labels then context", []string{"get", "pods", "--show-labels", "--context=prod"}, true}, + {"unknown long flag then auth flags", []string{"get", "pods", "--watch", "--token=t", "--user", "u"}, false}, + {"unknown long flag then client key", []string{"get", "pods", "--template", "--client_key=k"}, false}, + {"unknown long flag then context", []string{"get", "pods", "--template", "--context=c"}, true}, + {"output flag cluster", []string{"get", "pods", "-ojson"}, false}, + {"all namespaces", []string{"get", "pods", "-A"}, false}, + {"template with separate value", []string{"get", "pods", "--template", "{{.}}", "--server=x"}, false}, + {"known value flag first", []string{"--output", "yaml", "get", "pods"}, false}, + {"unknown long flag then non-targeting flag", []string{"get", "pods", "--watch", "--output=yaml"}, false}, + {"unknown long flag with equals then server", []string{"get", "pods", "--template={{.}}", "--server=x"}, false}, + {"unknown long flag then terminator", []string{"exec", "pod", "--stdin", "--", "--server=x"}, false}, + {"after the terminator", []string{"exec", "pod", "--", "-Ashttps://x", "--template", "--server=x"}, false}, + {"known cluster parses fully", []string{"-hAshttps://x"}, false}, + {"known value flag consumes targeting-looking token", []string{"--namespace", "--server=x", "version"}, false}, + {"unknown long flag at end", []string{"get", "--template"}, false}, + {"no args", nil, false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := parseArgs(tt.args).isAmbiguous(); got != tt.want { + t.Errorf("isAmbiguous() on %q = %v, want %v", tt.args, got, tt.want) + } + }) + } +} + +func TestAmbiguousStillRecordsFlags(t *testing.T) { + p := parseArgs([]string{"get", "pods", "--template", "--server=https://x"}) + if got, _ := p.value("server"); got != "https://x" { + t.Errorf("value(server) = %q, want recorded despite ambiguity", got) + } +} diff --git a/plugins/kubectl/credentials.go b/plugins/kubectl/credentials.go new file mode 100644 index 00000000..81c99f28 --- /dev/null +++ b/plugins/kubectl/credentials.go @@ -0,0 +1,46 @@ +package kubectl + +import ( + "github.com/1Password/shell-plugins/sdk" + "github.com/1Password/shell-plugins/sdk/schema" + "github.com/1Password/shell-plugins/sdk/schema/credname" + "github.com/1Password/shell-plugins/sdk/schema/fieldname" +) + +func Credentials() schema.CredentialType { + return schema.CredentialType{ + Name: credname.Credentials, + DocsURL: sdk.URL("https://kubernetes.io/docs/reference/access-authn-authz/authentication/"), + ManagementURL: nil, + Fields: []schema.CredentialField{ + { + Name: fieldname.Address, + MarkdownDescription: "Kubernetes API server URL, as in the cluster's `server` field (for example https://prod.example.com:6443).", + }, + { + Name: fieldname.Token, + MarkdownDescription: "Bearer token used to authenticate to the Kubernetes API server.", + Secret: true, + Optional: true, + }, + { + Name: fieldname.Certificate, + MarkdownDescription: "Client certificate used to authenticate to the Kubernetes API server, as PEM or as the base64-encoded PEM found in `client-certificate-data`.", + Optional: true, + }, + { + Name: fieldname.PrivateKey, + MarkdownDescription: "Private key of the client certificate, as PEM or as the base64-encoded PEM found in `client-key-data`.", + Secret: true, + Optional: true, + }, + { + Name: fieldname.CertificateAuthority, + MarkdownDescription: "Certificate authority used to verify the Kubernetes API server, as PEM or as the base64-encoded PEM found in `certificate-authority-data`. Used only when no kubeconfig exists on the machine.", + Optional: true, + }, + }, + DefaultProvisioner: Provisioner(), + Importer: TryKubeconfigFiles(), + } +} diff --git a/plugins/kubectl/credentials_test.go b/plugins/kubectl/credentials_test.go new file mode 100644 index 00000000..96c58200 --- /dev/null +++ b/plugins/kubectl/credentials_test.go @@ -0,0 +1,45 @@ +package kubectl + +import ( + "testing" + + "github.com/1Password/shell-plugins/sdk" + "github.com/1Password/shell-plugins/sdk/plugintest" +) + +func TestCredentialsProvisionerSmoke(t *testing.T) { + clearKubeEnv(t) + plugintest.TestProvisioner(t, Credentials().DefaultProvisioner, map[string]plugintest.ProvisionCase{ + "explicit context leaves the command untouched": { + ItemFields: tokenItem, + CommandLine: []string{"kubectl", "--context", "x", "get", "pods"}, + ExpectedOutput: sdk.ProvisionOutput{CommandLine: []string{"kubectl", "--context", "x", "get", "pods"}}, + }, + }) +} + +func TestCredentialsImporterSmoke(t *testing.T) { + setImporterEnv(t, "") + plugintest.TestImporter(t, Credentials().Importer, map[string]plugintest.ImportCase{ + "home kubeconfig": { + Files: map[string]string{homeKubeconfig: plugintest.LoadFixture(t, "token.yaml")}, + ExpectedCandidates: []sdk.ImportCandidate{ + { + Fields: map[sdk.FieldName]string{ + "Address": prodImportAddr, + "Token": importToken, + "Certificate Authority": importCAData, + }, + NameHint: "prod", + }, + }, + }, + }) +} + +func TestKubectlCLINeedsAuthSmoke(t *testing.T) { + plugintest.TestNeedsAuth(t, KubectlCLI().NeedsAuth, map[string]plugintest.NeedsAuthCase{ + "get pods needs auth": {Args: []string{"get", "pods"}, ExpectedNeedsAuth: true}, + "help does not": {Args: []string{"--help"}, ExpectedNeedsAuth: false}, + }) +} diff --git a/plugins/kubectl/importer.go b/plugins/kubectl/importer.go new file mode 100644 index 00000000..4968e4c2 --- /dev/null +++ b/plugins/kubectl/importer.go @@ -0,0 +1,265 @@ +package kubectl + +import ( + "context" + "encoding/base64" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/1Password/shell-plugins/sdk" + "github.com/1Password/shell-plugins/sdk/importer" + "github.com/1Password/shell-plugins/sdk/schema/fieldname" +) + +const maxDisplayedPathLength = 512 + +type kubeconfigSource struct { + original string + open string +} + +type importDiagnostic struct { + key string + err error +} + +type materialSpec struct { + kind string + inlineLabel string + blockType string + data string + path string + entryName string + entrySource string +} + +// TryKubeconfigFiles offers one candidate per context of the kubeconfig kubectl would load: +// the KUBECONFIG files when that variable is set, otherwise ~/.kube/config. +func TryKubeconfigFiles() sdk.Importer { + return func(ctx context.Context, in sdk.ImportInput, out *sdk.ImportOutput) { + attempts := map[string]*sdk.ImportAttempt{} + spellings := map[string]string{} + var files []*kubeconfig + failed := false + + for _, src := range kubeconfigSources(in) { + if info, err := os.Stat(src.open); errors.Is(err, os.ErrNotExist) || (err == nil && info.Mode()&os.ModeCharDevice != 0) { + continue + } + k, err := readKubeconfig(src.open) + if errors.Is(err, errNotFound) { + continue + } + if err != nil { + attempt := out.NewAttempt(importer.SourceFile(src.original)) + if errors.Is(err, errInvalidKubeconfig) { + attempt.AddError(fmt.Errorf("parsing %s: not a valid kubeconfig", importDisplayPath(src.original))) + } else { + attempt.AddError(fmt.Errorf("reading %s: not a readable kubeconfig", importDisplayPath(src.original))) + } + failed = true + continue + } + attempts[src.open] = out.NewAttempt(importer.SourceFile(src.original)) + spellings[src.open] = src.original + files = append(files, k) + } + if failed || len(files) == 0 { + return + } + + merged := mergeKubeconfigs(files) + var added []sdk.ImportCandidate + reported := map[string]bool{} + for _, kctx := range merged.Contexts { + cl, ok := merged.cluster(kctx.Context.Cluster) + if !ok || cl.Cluster.Server == "" { + continue + } + u, ok := merged.user(kctx.Context.User) + if !ok { + continue + } + + var diags []importDiagnostic + fields, ok := importUserSecrets(in, kctx.Name, u, spellings[u.source], &diags) + if ok { + fields[fieldname.Address] = cl.Cluster.Server + ca, caOK, diag := importMaterial(in, kctx.Name, spellings[cl.source], materialSpec{ + kind: "certificate authority", + inlineLabel: "certificate-authority-data", + blockType: "CERTIFICATE", + data: cl.Cluster.CertificateAuthorityData, + path: cl.Cluster.CertificateAuthority, + entryName: cl.Name, + entrySource: cl.source, + }) + if caOK { + fields[fieldname.CertificateAuthority] = ca + } + if diag != nil { + diags = append(diags, *diag) + } + } + + candidate := sdk.ImportCandidate{ + Fields: fields, + NameHint: importer.SanitizeNameHint(kctx.Name), + } + if ok && isDuplicateCandidate(added, candidate) { + continue + } + + attempt := attempts[kctx.source] + for _, d := range diags { + if !reported[d.key] { + reported[d.key] = true + attempt.AddError(d.err) + } + } + if ok { + added = append(added, candidate) + attempt.AddCandidate(candidate) + } + } + } +} + +func kubeconfigSources(in sdk.ImportInput) []kubeconfigSource { + var sources []kubeconfigSource + if env := os.Getenv("KUBECONFIG"); env != "" { + for _, entry := range filepath.SplitList(env) { + if entry != "" { + sources = append(sources, kubeconfigSource{original: entry, open: fromRootDir(in, entry)}) + } + } + } else { + sources = append(sources, kubeconfigSource{original: "~/.kube/config", open: in.FromHomeDir(".kube", "config")}) + } + + seen := map[string]bool{} + var unique []kubeconfigSource + for _, src := range sources { + if seen[src.open] { + continue + } + seen[src.open] = true + unique = append(unique, src) + } + return unique +} + +func fromRootDir(in sdk.ImportInput, path string) string { + if strings.HasPrefix(path, "/") { + return strings.TrimSuffix(in.RootDir, "/") + path + } + return path +} + +func resolveReference(in sdk.ImportInput, source, path string) string { + if strings.HasPrefix(path, "/") { + return fromRootDir(in, path) + } + return filepath.Join(filepath.Dir(source), path) +} + +func importDisplayPath(path string) string { + if strings.Contains(path, "\n") || + strings.HasPrefix(path, "-----BEGIN") || + strings.HasPrefix(path, "LS0t") || + len(path) > maxDisplayedPathLength { + return "" + } + return path +} + +func importUserSecrets(in sdk.ImportInput, contextName string, nu namedUser, spelling string, diags *[]importDiagnostic) (map[sdk.FieldName]string, bool) { + u := nu.User + if u.Exec != nil || u.AuthProvider != nil || u.TokenFile != "" { + return nil, false + } + + fields := map[sdk.FieldName]string{} + if u.Token != "" { + fields[fieldname.Token] = u.Token + } + + cert, certOK, certDiag := importMaterial(in, contextName, spelling, materialSpec{ + kind: "client certificate", + inlineLabel: "client certificate", + blockType: "CERTIFICATE", + data: u.ClientCertificateData, + path: u.ClientCertificate, + entryName: nu.Name, + entrySource: nu.source, + }) + key, keyOK, keyDiag := importMaterial(in, contextName, spelling, materialSpec{ + kind: "client key", + inlineLabel: "client key", + blockType: "PRIVATE KEY", + data: u.ClientKeyData, + path: u.ClientKey, + entryName: nu.Name, + entrySource: nu.source, + }) + for _, d := range []*importDiagnostic{certDiag, keyDiag} { + if d != nil { + *diags = append(*diags, *d) + } + } + if certOK && keyOK { + fields[fieldname.Certificate] = cert + fields[fieldname.PrivateKey] = key + } + + if len(fields) == 0 { + return nil, false + } + return fields, true +} + +func importMaterial(in sdk.ImportInput, contextName, spelling string, m materialSpec) (string, bool, *importDiagnostic) { + if m.data != "" { + value, err := toBase64PEM(m.data, m.blockType) + if err != nil { + return "", false, &importDiagnostic{ + key: m.kind + "\x00inline\x00" + m.entrySource + "\x00" + m.entryName, + err: fmt.Errorf("context %q: %s in %s could not be imported", contextName, m.inlineLabel, importDisplayPath(spelling)), + } + } + return value, true, nil + } + if m.path == "" { + return "", false, nil + } + + resolved := resolveReference(in, m.entrySource, m.path) + key := m.kind + "\x00file\x00" + filepath.Clean(resolved) + contents, err := os.ReadFile(resolved) + if err != nil { + return "", false, &importDiagnostic{ + key: key, + err: fmt.Errorf("context %q: %s at %s could not be imported", contextName, m.kind, importDisplayPath(m.path)), + } + } + value, err := toBase64PEM(base64.StdEncoding.EncodeToString(contents), m.blockType) + if err != nil { + return "", false, &importDiagnostic{ + key: key, + err: fmt.Errorf("context %q: %s in %s could not be imported", contextName, m.kind, importDisplayPath(m.path)), + } + } + return value, true, nil +} + +func isDuplicateCandidate(added []sdk.ImportCandidate, candidate sdk.ImportCandidate) bool { + for _, existing := range added { + if existing.Equal(candidate) { + return true + } + } + return false +} diff --git a/plugins/kubectl/importer_test.go b/plugins/kubectl/importer_test.go new file mode 100644 index 00000000..5342dea8 --- /dev/null +++ b/plugins/kubectl/importer_test.go @@ -0,0 +1,751 @@ +package kubectl + +import ( + "encoding/base64" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/1Password/shell-plugins/sdk" + "github.com/1Password/shell-plugins/sdk/importer" + "github.com/1Password/shell-plugins/sdk/plugintest" + "github.com/1Password/shell-plugins/sdk/schema/fieldname" +) + +const ( + importToken = "test-token-not-a-secret" + importCertData = "LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tClptRnJaUT09Ci0tLS0tRU5EIENFUlRJRklDQVRFLS0tLS0K" + importKeyData = "LS0tLS1CRUdJTiBQUklWQVRFIEtFWS0tLS0tClptRnJaUT09Ci0tLS0tRU5EIFBSSVZBVEUgS0VZLS0tLS0K" + importCAData = "LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tClptRnJaUzFqWVE9PQotLS0tLUVORCBDRVJUSUZJQ0FURS0tLS0tCg==" + prodImportAddr = "https://prod.example.com:6443" + devImportAddr = "https://dev.example.com:6443" + homeKubeconfig = "~/.kube/config" +) + +func setImporterEnv(t *testing.T, kubeconfigValue string) { + t.Setenv("KUBECONFIG", kubeconfigValue) + t.Setenv("KUBERC", "") + t.Setenv("KUBERNETES_SERVICE_HOST", "") + t.Setenv("KUBERNETES_MASTER", "") +} + +func kubeconfigList(paths ...string) string { + return strings.Join(paths, string(filepath.ListSeparator)) +} + +func fixtureBase64(t *testing.T, name string) string { + return base64.StdEncoding.EncodeToString([]byte(plugintest.LoadFixture(t, name))) +} + +func importErrors(messages ...string) sdk.Diagnostics { + errs := make([]sdk.Error, 0, len(messages)) + for _, m := range messages { + errs = append(errs, sdk.Error{Message: m}) + } + return sdk.Diagnostics{Errors: errs} +} + +func tokenCandidate(address, token, nameHint string) sdk.ImportCandidate { + return sdk.ImportCandidate{ + Fields: map[sdk.FieldName]string{ + fieldname.Address: address, + fieldname.Token: token, + }, + NameHint: nameHint, + } +} + +func TestImporterHomeConfig(t *testing.T) { + setImporterEnv(t, "") + + plugintest.TestImporter(t, TryKubeconfigFiles(), map[string]plugintest.ImportCase{ + "I1 token user with certificate authority data": { + Files: map[string]string{ + homeKubeconfig: plugintest.LoadFixture(t, "token.yaml"), + }, + ExpectedCandidates: []sdk.ImportCandidate{ + { + Fields: map[sdk.FieldName]string{ + fieldname.Address: prodImportAddr, + fieldname.Token: importToken, + fieldname.CertificateAuthority: importCAData, + }, + NameHint: "prod", + }, + }, + }, + "I2 client certificate and key data stored verbatim": { + Files: map[string]string{ + homeKubeconfig: plugintest.LoadFixture(t, "cert-data.yaml"), + }, + ExpectedCandidates: []sdk.ImportCandidate{ + { + Fields: map[sdk.FieldName]string{ + fieldname.Address: devImportAddr, + fieldname.Certificate: importCertData, + fieldname.PrivateKey: importKeyData, + }, + NameHint: "dev", + }, + }, + }, + "I3 relative and absolute file references are read and base64 encoded": { + Files: map[string]string{ + homeKubeconfig: plugintest.LoadFixture(t, "cert-paths.yaml"), + "~/.kube/certs/client.crt": plugintest.LoadFixture(t, "client.crt"), + "~/.kube/certs/ca.crt": plugintest.LoadFixture(t, "ca.crt"), + "/etc/k8s/client.key": plugintest.LoadFixture(t, "client.key"), + }, + ExpectedCandidates: []sdk.ImportCandidate{ + { + Fields: map[sdk.FieldName]string{ + fieldname.Address: devImportAddr, + fieldname.Certificate: base64.StdEncoding.EncodeToString([]byte(plugintest.LoadFixture(t, "client.crt"))), + fieldname.PrivateKey: base64.StdEncoding.EncodeToString([]byte(plugintest.LoadFixture(t, "client.key"))), + fieldname.CertificateAuthority: base64.StdEncoding.EncodeToString([]byte(plugintest.LoadFixture(t, "ca.crt"))), + }, + NameHint: "dev", + }, + }, + }, + "I4 exec, auth-provider, basic auth, tokenFile and empty users are skipped": { + Files: map[string]string{ + homeKubeconfig: plugintest.LoadFixture(t, "unsupported.yaml"), + }, + ExpectedCandidates: []sdk.ImportCandidate{}, + }, + "I5 mixed file yields only the static token context": { + Files: map[string]string{ + homeKubeconfig: plugintest.LoadFixture(t, "mixed.yaml"), + }, + ExpectedCandidates: []sdk.ImportCandidate{ + { + Fields: map[sdk.FieldName]string{ + fieldname.Address: prodImportAddr, + fieldname.Token: importToken, + }, + NameHint: "prod", + }, + }, + }, + "I8 contexts sharing user and cluster give one candidate": { + Files: map[string]string{ + homeKubeconfig: plugintest.LoadFixture(t, "shared-user.yaml"), + }, + ExpectedCandidates: []sdk.ImportCandidate{ + { + Fields: map[sdk.FieldName]string{ + fieldname.Address: prodImportAddr, + fieldname.Token: importToken, + }, + NameHint: "prod", + }, + }, + }, + "I9 insecure cluster gives a candidate without certificate authority": { + Files: map[string]string{ + homeKubeconfig: plugintest.LoadFixture(t, "insecure.yaml"), + }, + ExpectedCandidates: []sdk.ImportCandidate{ + { + Fields: map[sdk.FieldName]string{ + fieldname.Address: "https://127.0.0.1:7443", + fieldname.Token: importToken, + }, + NameHint: "local", + }, + }, + }, + "I10 context named default gets an empty name hint": { + Files: map[string]string{ + homeKubeconfig: plugintest.LoadFixture(t, "default-context.yaml"), + }, + ExpectedCandidates: []sdk.ImportCandidate{ + { + Fields: map[sdk.FieldName]string{ + fieldname.Address: prodImportAddr, + fieldname.Token: importToken, + }, + NameHint: "", + }, + }, + }, + "I15 user with token and tokenFile is skipped": { + Files: map[string]string{ + homeKubeconfig: plugintest.LoadFixture(t, "token-and-token-file.yaml"), + }, + ExpectedCandidates: []sdk.ImportCandidate{}, + }, + "I16 token with a certificate but no key keeps only the token": { + Files: map[string]string{ + homeKubeconfig: plugintest.LoadFixture(t, "token-cert-only.yaml"), + }, + ExpectedCandidates: []sdk.ImportCandidate{ + { + Fields: map[sdk.FieldName]string{ + fieldname.Address: prodImportAddr, + fieldname.Token: importToken, + }, + NameHint: "prod", + }, + }, + }, + "inline PEM and wrapped base64 data are stored as canonical base64": { + Files: map[string]string{ + homeKubeconfig: plugintest.LoadFixture(t, "cert-data-pem.yaml"), + }, + ExpectedCandidates: []sdk.ImportCandidate{ + { + Fields: map[sdk.FieldName]string{ + fieldname.Address: devImportAddr, + fieldname.Certificate: importCertData, + fieldname.PrivateKey: importKeyData, + }, + NameHint: "dev", + }, + }, + }, + "no kubeconfig at all": { + ExpectedOutput: &sdk.ImportOutput{}, + }, + }) +} + +func TestImporterKubeconfigEnv(t *testing.T) { + setImporterEnv(t, "") + + plugintest.TestImporter(t, TryKubeconfigFiles(), map[string]plugintest.ImportCase{ + "I6 KUBECONFIG files are merged and the home config is not read": { + Environment: map[string]string{ + "KUBECONFIG": kubeconfigList("", "/kube/a.yaml", "/kube/missing.yaml", "", "/kube/b.yaml", ""), + }, + Files: map[string]string{ + homeKubeconfig: plugintest.LoadFixture(t, "token.yaml"), + "/kube/a.yaml": plugintest.LoadFixture(t, "merge-a.yaml"), + "/kube/b.yaml": plugintest.LoadFixture(t, "merge-b.yaml"), + }, + ExpectedOutput: &sdk.ImportOutput{ + Attempts: []*sdk.ImportAttempt{ + { + Source: importer.SourceFile("/kube/a.yaml"), + Candidates: []sdk.ImportCandidate{ + { + Fields: map[sdk.FieldName]string{ + fieldname.Address: "https://alpha.example.com:6443", + fieldname.Token: "test-token-alpha", + }, + NameHint: "alpha", + }, + }, + }, + { + Source: importer.SourceFile("/kube/b.yaml"), + Candidates: []sdk.ImportCandidate{ + { + Fields: map[sdk.FieldName]string{ + fieldname.Address: "https://beta.example.com:6443", + fieldname.Certificate: importCertData, + fieldname.PrivateKey: importKeyData, + }, + NameHint: "beta", + }, + }, + }, + }, + }, + }, + "I7 KUBECONFIG entry equal to the home path appears once": { + Environment: map[string]string{ + "KUBECONFIG": kubeconfigList("/~/.kube/config", "/~/.kube/config"), + }, + Files: map[string]string{ + homeKubeconfig: plugintest.LoadFixture(t, "token.yaml"), + }, + ExpectedOutput: &sdk.ImportOutput{ + Attempts: []*sdk.ImportAttempt{ + { + Source: importer.SourceFile("/~/.kube/config"), + Candidates: []sdk.ImportCandidate{ + { + Fields: map[sdk.FieldName]string{ + fieldname.Address: prodImportAddr, + fieldname.Token: importToken, + fieldname.CertificateAuthority: importCAData, + }, + NameHint: "prod", + }, + }, + }, + }, + }, + }, + "KUBECONFIG with only missing files yields nothing": { + Environment: map[string]string{ + "KUBECONFIG": kubeconfigList("/kube/missing.yaml"), + }, + Files: map[string]string{ + homeKubeconfig: plugintest.LoadFixture(t, "token.yaml"), + }, + ExpectedOutput: &sdk.ImportOutput{}, + }, + "I13 context resolves user and cluster defined in another file": { + Environment: map[string]string{ + "KUBECONFIG": kubeconfigList("/kube/a.yaml", "/kube/b.yaml"), + }, + Files: map[string]string{ + "/kube/a.yaml": plugintest.LoadFixture(t, "cross-a.yaml"), + "/kube/b.yaml": plugintest.LoadFixture(t, "cross-b.yaml"), + }, + ExpectedOutput: &sdk.ImportOutput{ + Attempts: []*sdk.ImportAttempt{ + { + Source: importer.SourceFile("/kube/a.yaml"), + Candidates: []sdk.ImportCandidate{ + { + Fields: map[sdk.FieldName]string{ + fieldname.Address: "https://remote.example.com:6443", + fieldname.Token: importToken, + }, + NameHint: "cross", + }, + }, + }, + { + Source: importer.SourceFile("/kube/b.yaml"), + }, + }, + }, + }, + "I14 first cluster definition wins and the candidate goes to the context's file": { + Environment: map[string]string{ + "KUBECONFIG": kubeconfigList("/kube/a.yaml", "/kube/b.yaml"), + }, + Files: map[string]string{ + "/kube/a.yaml": plugintest.LoadFixture(t, "conflict-a.yaml"), + "/kube/b.yaml": plugintest.LoadFixture(t, "conflict-b.yaml"), + }, + ExpectedOutput: &sdk.ImportOutput{ + Attempts: []*sdk.ImportAttempt{ + { + Source: importer.SourceFile("/kube/a.yaml"), + }, + { + Source: importer.SourceFile("/kube/b.yaml"), + Candidates: []sdk.ImportCandidate{ + { + Fields: map[sdk.FieldName]string{ + fieldname.Address: prodImportAddr, + fieldname.Token: importToken, + }, + NameHint: "shared", + }, + }, + }, + }, + }, + }, + "cross-file relative references resolve against the file defining the user or cluster": { + Environment: map[string]string{ + "KUBECONFIG": kubeconfigList("/kube/a/a.yaml", "/kube/b/b.yaml"), + }, + Files: map[string]string{ + "/kube/a/a.yaml": plugintest.LoadFixture(t, "cross-ref-a.yaml"), + "/kube/b/b.yaml": plugintest.LoadFixture(t, "cross-ref-b.yaml"), + "/kube/b/ca.crt": plugintest.LoadFixture(t, "ca.crt"), + "/kube/b/client.crt": plugintest.LoadFixture(t, "client.crt"), + "/kube/b/keys/client.key": plugintest.LoadFixture(t, "client.key"), + }, + ExpectedOutput: &sdk.ImportOutput{ + Attempts: []*sdk.ImportAttempt{ + { + Source: importer.SourceFile("/kube/a/a.yaml"), + Candidates: []sdk.ImportCandidate{ + { + Fields: map[sdk.FieldName]string{ + fieldname.Address: "https://remote.example.com:6443", + fieldname.Certificate: fixtureBase64(t, "client.crt"), + fieldname.PrivateKey: fixtureBase64(t, "client.key"), + fieldname.CertificateAuthority: fixtureBase64(t, "ca.crt"), + }, + NameHint: "cross-ref", + }, + }, + }, + { + Source: importer.SourceFile("/kube/b/b.yaml"), + }, + }, + }, + }, + "first user definition wins": { + Environment: map[string]string{ + "KUBECONFIG": kubeconfigList("/kube/a.yaml", "/kube/b.yaml"), + }, + Files: map[string]string{ + "/kube/a.yaml": plugintest.LoadFixture(t, "user-conflict-a.yaml"), + "/kube/b.yaml": plugintest.LoadFixture(t, "user-conflict-b.yaml"), + }, + ExpectedOutput: &sdk.ImportOutput{ + Attempts: []*sdk.ImportAttempt{ + { + Source: importer.SourceFile("/kube/a.yaml"), + }, + { + Source: importer.SourceFile("/kube/b.yaml"), + Candidates: []sdk.ImportCandidate{tokenCandidate(prodImportAddr, "test-token-first", "prod")}, + }, + }, + }, + }, + "first context definition wins": { + Environment: map[string]string{ + "KUBECONFIG": kubeconfigList("/kube/a.yaml", "/kube/b.yaml"), + }, + Files: map[string]string{ + "/kube/a.yaml": plugintest.LoadFixture(t, "context-conflict-a.yaml"), + "/kube/b.yaml": plugintest.LoadFixture(t, "context-conflict-b.yaml"), + }, + ExpectedOutput: &sdk.ImportOutput{ + Attempts: []*sdk.ImportAttempt{ + { + Source: importer.SourceFile("/kube/a.yaml"), + Candidates: []sdk.ImportCandidate{tokenCandidate(prodImportAddr, importToken, "shared")}, + }, + { + Source: importer.SourceFile("/kube/b.yaml"), + }, + }, + }, + }, + "a KUBECONFIG entry that is a directory aborts the run": { + Environment: map[string]string{ + "KUBECONFIG": kubeconfigList("/kube/dir", "", "/kube/a.yaml"), + }, + Files: map[string]string{ + "/kube/dir/placeholder": "x", + "/kube/a.yaml": plugintest.LoadFixture(t, "merge-a.yaml"), + }, + ExpectedOutput: &sdk.ImportOutput{ + Attempts: []*sdk.ImportAttempt{ + { + Source: importer.SourceFile("/kube/dir"), + Diagnostics: importErrors("reading /kube/dir: not a readable kubeconfig"), + }, + { + Source: importer.SourceFile("/kube/a.yaml"), + }, + }, + }, + }, + "I12 a parse error in any file yields no candidates": { + Environment: map[string]string{ + "KUBECONFIG": kubeconfigList("/kube/a.yaml", "/kube/broken.yaml"), + }, + Files: map[string]string{ + "/kube/a.yaml": plugintest.LoadFixture(t, "merge-a.yaml"), + "/kube/broken.yaml": plugintest.LoadFixture(t, "invalid.yaml"), + }, + ExpectedOutput: &sdk.ImportOutput{ + Attempts: []*sdk.ImportAttempt{ + { + Source: importer.SourceFile("/kube/a.yaml"), + }, + { + Source: importer.SourceFile("/kube/broken.yaml"), + Diagnostics: sdk.Diagnostics{Errors: []sdk.Error{ + {Message: "parsing /kube/broken.yaml: not a valid kubeconfig"}, + }}, + }, + }, + }, + }, + }) +} + +func TestImporterDiagnostics(t *testing.T) { + setImporterEnv(t, "") + + plugintest.TestImporter(t, TryKubeconfigFiles(), map[string]plugintest.ImportCase{ + "I11 missing referenced key file skips the context with an error": { + Files: map[string]string{ + homeKubeconfig: plugintest.LoadFixture(t, "missing-key-file.yaml"), + }, + ExpectedOutput: &sdk.ImportOutput{ + Attempts: []*sdk.ImportAttempt{ + { + Source: importer.SourceFile(homeKubeconfig), + Diagnostics: sdk.Diagnostics{Errors: []sdk.Error{ + {Message: `context "dev": client key at keys/missing.key could not be imported`}, + }}, + }, + }, + }, + }, + "I12 invalid YAML": { + Files: map[string]string{ + homeKubeconfig: plugintest.LoadFixture(t, "invalid.yaml"), + }, + ExpectedOutput: &sdk.ImportOutput{ + Attempts: []*sdk.ImportAttempt{ + { + Source: importer.SourceFile(homeKubeconfig), + Diagnostics: sdk.Diagnostics{Errors: []sdk.Error{ + {Message: "parsing ~/.kube/config: not a valid kubeconfig"}, + }}, + }, + }, + }, + }, + "I19 unreadable or invalid certificate authority is omitted with an error naming only the location": { + Files: map[string]string{ + homeKubeconfig: plugintest.LoadFixture(t, "unreadable-ca.yaml"), + }, + ExpectedOutput: &sdk.ImportOutput{ + Attempts: []*sdk.ImportAttempt{ + { + Source: importer.SourceFile(homeKubeconfig), + Candidates: []sdk.ImportCandidate{ + { + Fields: map[sdk.FieldName]string{ + fieldname.Address: prodImportAddr, + fieldname.Token: importToken, + }, + NameHint: "prod", + }, + { + Fields: map[sdk.FieldName]string{ + fieldname.Address: devImportAddr, + fieldname.Token: importToken, + }, + NameHint: "dev", + }, + }, + Diagnostics: sdk.Diagnostics{Errors: []sdk.Error{ + {Message: `context "prod": certificate authority at missing-ca.crt could not be imported`}, + {Message: `context "dev": certificate-authority-data in ~/.kube/config could not be imported`}, + }}, + }, + }, + }, + }, + "I17 invalid key data drops the pair, keeps the token and never echoes the value": { + Files: map[string]string{ + homeKubeconfig: plugintest.LoadFixture(t, "invalid-key-data.yaml"), + }, + ExpectedOutput: &sdk.ImportOutput{ + Attempts: []*sdk.ImportAttempt{ + { + Source: importer.SourceFile(homeKubeconfig), + Candidates: []sdk.ImportCandidate{tokenCandidate(prodImportAddr, importToken, "prod")}, + Diagnostics: importErrors( + `context "prod": client key in ~/.kube/config could not be imported`, + `context "dev": client key in ~/.kube/config could not be imported`, + ), + }, + }, + }, + }, + "unreadable key file with a token keeps the token and reports once": { + Files: map[string]string{ + homeKubeconfig: plugintest.LoadFixture(t, "missing-key-with-token.yaml"), + }, + ExpectedOutput: &sdk.ImportOutput{ + Attempts: []*sdk.ImportAttempt{ + { + Source: importer.SourceFile(homeKubeconfig), + Candidates: []sdk.ImportCandidate{tokenCandidate(prodImportAddr, importToken, "prod")}, + Diagnostics: importErrors(`context "prod": client key at keys/missing.key could not be imported`), + }, + }, + }, + }, + "a context dropped as a duplicate reports nothing": { + Files: map[string]string{ + homeKubeconfig: plugintest.LoadFixture(t, "duplicate-with-diagnostic.yaml"), + }, + ExpectedOutput: &sdk.ImportOutput{ + Attempts: []*sdk.ImportAttempt{ + { + Source: importer.SourceFile(homeKubeconfig), + Candidates: []sdk.ImportCandidate{tokenCandidate(prodImportAddr, importToken, "plain")}, + }, + }, + }, + }, + "a broken certificate authority shared by two candidates is reported once": { + Files: map[string]string{ + homeKubeconfig: plugintest.LoadFixture(t, "shared-broken-ca.yaml"), + }, + ExpectedOutput: &sdk.ImportOutput{ + Attempts: []*sdk.ImportAttempt{ + { + Source: importer.SourceFile(homeKubeconfig), + Candidates: []sdk.ImportCandidate{ + tokenCandidate(prodImportAddr, "test-token-one", "one"), + tokenCandidate(prodImportAddr, "test-token-two", "two"), + }, + Diagnostics: importErrors(`context "one": certificate authority at missing-ca.crt could not be imported`), + }, + }, + }, + }, + "invalid file content is reported and secret-looking paths are not echoed": { + Files: map[string]string{ + homeKubeconfig: plugintest.LoadFixture(t, "invalid-material.yaml"), + "~/.kube/bad-ca.crt": plugintest.LoadFixture(t, "not-pem.txt"), + "~/.kube/bad-cert.crt": plugintest.LoadFixture(t, "not-pem.txt"), + }, + ExpectedOutput: &sdk.ImportOutput{ + Attempts: []*sdk.ImportAttempt{ + { + Source: importer.SourceFile(homeKubeconfig), + Candidates: []sdk.ImportCandidate{ + tokenCandidate(prodImportAddr, "test-token-one", "pem-path"), + tokenCandidate(devImportAddr, "test-token-two", "bad-file"), + }, + Diagnostics: importErrors( + `context "pem-path": client key at could not be imported`, + `context "pem-path": certificate authority at could not be imported`, + `context "bad-file": client certificate in bad-cert.crt could not be imported`, + `context "bad-file": certificate authority in bad-ca.crt could not be imported`, + ), + }, + }, + }, + }, + }) +} + +func TestImportDisplayPath(t *testing.T) { + setImporterEnv(t, "") + + for in, want := range map[string]string{ + "certs/client.crt": "certs/client.crt", + "/etc/k8s/client.key": "/etc/k8s/client.key", + "certs/a\nb.crt": "", + "-----BEGIN PRIVATE KEY-----": "", + "LS0tLS1CRUdJTiBQUklWQVRFIEtFWS0t": "", + strings.Repeat("a", 512): strings.Repeat("a", 512), + strings.Repeat("a", 513): "", + } { + if got := importDisplayPath(in); got != want { + t.Errorf("importDisplayPath(%.40q) = %.40q, want %.40q", in, got, want) + } + } +} + +func TestImporterRelativeKubeconfigEntry(t *testing.T) { + setImporterEnv(t, "") + + cwd := t.TempDir() + t.Chdir(cwd) + for name, contents := range map[string]string{ + "kube/c.yaml": plugintest.LoadFixture(t, "relative.yaml"), + "kube/certs/client.crt": plugintest.LoadFixture(t, "client.crt"), + "kube/certs/client.key": plugintest.LoadFixture(t, "client.key"), + "~/.kube/config": plugintest.LoadFixture(t, "literal-tilde.yaml"), + "real/config": plugintest.LoadFixture(t, "through-link.yaml"), + "real/sub/.keep": "", + "config": plugintest.LoadFixture(t, "lexical.yaml"), + } { + path := filepath.Join(cwd, name) + if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(contents), 0600); err != nil { + t.Fatal(err) + } + } + + if err := os.Symlink(filepath.Join("real", "sub"), filepath.Join(cwd, "link")); err != nil { + t.Fatal(err) + } + if err := os.Symlink(os.DevNull, filepath.Join(cwd, "devnull")); err != nil { + t.Fatal(err) + } + + plugintest.TestImporter(t, TryKubeconfigFiles(), map[string]plugintest.ImportCase{ + "a literal ~/ KUBECONFIG entry is relative to the working directory": { + Environment: map[string]string{ + "KUBECONFIG": "~/.kube/config", + }, + Files: map[string]string{ + homeKubeconfig: plugintest.LoadFixture(t, "token.yaml"), + }, + ExpectedOutput: &sdk.ImportOutput{ + Attempts: []*sdk.ImportAttempt{ + { + Source: importer.SourceFile("~/.kube/config"), + Candidates: []sdk.ImportCandidate{tokenCandidate(prodImportAddr, importToken, "literal")}, + }, + }, + }, + }, + "the entry is opened as written so symlinks resolve before ..": { + Environment: map[string]string{ + "KUBECONFIG": "link/../config", + }, + ExpectedOutput: &sdk.ImportOutput{ + Attempts: []*sdk.ImportAttempt{ + { + Source: importer.SourceFile("link/../config"), + Candidates: []sdk.ImportCandidate{tokenCandidate(prodImportAddr, importToken, "through-link")}, + }, + }, + }, + }, + "a character device counts as an empty kubeconfig": { + Environment: map[string]string{ + "KUBECONFIG": kubeconfigList("devnull", "config"), + }, + ExpectedOutput: &sdk.ImportOutput{ + Attempts: []*sdk.ImportAttempt{ + { + Source: importer.SourceFile("config"), + Candidates: []sdk.ImportCandidate{tokenCandidate(prodImportAddr, "test-token-lexical", "lexical")}, + }, + }, + }, + }, + "different spellings that clean to the same path are different files": { + Environment: map[string]string{ + "KUBECONFIG": kubeconfigList("link/../config", "config"), + }, + ExpectedOutput: &sdk.ImportOutput{ + Attempts: []*sdk.ImportAttempt{ + { + Source: importer.SourceFile("link/../config"), + Candidates: []sdk.ImportCandidate{tokenCandidate(prodImportAddr, importToken, "through-link")}, + }, + { + Source: importer.SourceFile("config"), + Candidates: []sdk.ImportCandidate{tokenCandidate(prodImportAddr, "test-token-lexical", "lexical")}, + }, + }, + }, + }, + "I18 relative KUBECONFIG entry with references relative to that file": { + Environment: map[string]string{ + "KUBECONFIG": "./kube/c.yaml", + }, + ExpectedOutput: &sdk.ImportOutput{ + Attempts: []*sdk.ImportAttempt{ + { + Source: importer.SourceFile("./kube/c.yaml"), + Candidates: []sdk.ImportCandidate{ + { + Fields: map[sdk.FieldName]string{ + fieldname.Address: devImportAddr, + fieldname.Certificate: base64.StdEncoding.EncodeToString([]byte(plugintest.LoadFixture(t, "client.crt"))), + fieldname.PrivateKey: base64.StdEncoding.EncodeToString([]byte(plugintest.LoadFixture(t, "client.key"))), + }, + NameHint: "relative", + }, + }, + }, + }, + }, + }, + }) +} diff --git a/plugins/kubectl/kubeconfig.go b/plugins/kubectl/kubeconfig.go new file mode 100644 index 00000000..1b9f67b3 --- /dev/null +++ b/plugins/kubectl/kubeconfig.go @@ -0,0 +1,358 @@ +package kubectl + +import ( + "bytes" + "encoding/base64" + "encoding/pem" + "errors" + "fmt" + "net/netip" + "net/url" + "os" + "regexp" + "strings" + "unicode" + + "github.com/1Password/shell-plugins/sdk/importer" +) + +type kubeconfig struct { + APIVersion string `yaml:"apiVersion,omitempty"` + Kind string `yaml:"kind,omitempty"` + Preferences map[string]any `yaml:"preferences,omitempty"` + Clusters []namedCluster `yaml:"clusters,omitempty"` + Contexts []namedContext `yaml:"contexts,omitempty"` + Users []namedUser `yaml:"users,omitempty"` + CurrentContext string `yaml:"current-context,omitempty"` + Extensions []any `yaml:"extensions,omitempty"` +} + +type namedCluster struct { + Name string `yaml:"name"` + Cluster cluster `yaml:"cluster"` + source string +} + +type cluster struct { + Server string `yaml:"server,omitempty"` + CertificateAuthority string `yaml:"certificate-authority,omitempty"` + CertificateAuthorityData string `yaml:"certificate-authority-data,omitempty"` + InsecureSkipTLSVerify bool `yaml:"insecure-skip-tls-verify,omitempty"` + TLSServerName string `yaml:"tls-server-name,omitempty"` + ProxyURL string `yaml:"proxy-url,omitempty"` + DisableCompression bool `yaml:"disable-compression,omitempty"` + Extensions []any `yaml:"extensions,omitempty"` +} + +type namedContext struct { + Name string `yaml:"name"` + Context kubeContext `yaml:"context"` + source string +} + +type kubeContext struct { + Cluster string `yaml:"cluster"` + User string `yaml:"user"` + Namespace string `yaml:"namespace,omitempty"` + Extensions []any `yaml:"extensions,omitempty"` +} + +type namedUser struct { + Name string `yaml:"name"` + User user `yaml:"user"` + source string +} + +type user struct { + Token string `yaml:"token,omitempty"` + TokenFile string `yaml:"tokenFile,omitempty"` + ClientCertificate string `yaml:"client-certificate,omitempty"` + ClientCertificateData string `yaml:"client-certificate-data,omitempty"` + ClientKey string `yaml:"client-key,omitempty"` + ClientKeyData string `yaml:"client-key-data,omitempty"` + Username string `yaml:"username,omitempty"` + Password string `yaml:"password,omitempty"` + Exec map[string]any `yaml:"exec,omitempty"` + AuthProvider map[string]any `yaml:"auth-provider,omitempty"` + As string `yaml:"as,omitempty"` + AsUID string `yaml:"as-uid,omitempty"` + AsGroups []string `yaml:"as-groups,omitempty"` + AsUserExtra map[string][]string `yaml:"as-user-extra,omitempty"` + Extensions []any `yaml:"extensions,omitempty"` +} + +var errNotFound = errors.New("kubeconfig file not found") + +var errInvalidKubeconfig = errors.New("not a valid kubeconfig") + +func invalidKubeconfig(path string) error { + return fmt.Errorf("parsing %s: %w", path, errInvalidKubeconfig) +} + +func readKubeconfig(path string) (*kubeconfig, error) { + info, err := os.Stat(path) + if errors.Is(err, os.ErrNotExist) { + return nil, errNotFound + } + if err != nil { + return nil, fmt.Errorf("reading %s: %w", path, err) + } + if !info.Mode().IsRegular() { + return nil, fmt.Errorf("reading %s: not a regular file", path) + } + + contents, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("reading %s: %w", path, err) + } + + var k kubeconfig + if err := importer.FileContents(contents).ToYAML(&k); err != nil { + return nil, invalidKubeconfig(path) + } + + clusters := map[string]bool{} + for i := range k.Clusters { + if clusters[k.Clusters[i].Name] { + return nil, invalidKubeconfig(path) + } + clusters[k.Clusters[i].Name] = true + k.Clusters[i].source = path + } + contexts := map[string]bool{} + for i := range k.Contexts { + if contexts[k.Contexts[i].Name] { + return nil, invalidKubeconfig(path) + } + contexts[k.Contexts[i].Name] = true + k.Contexts[i].source = path + } + users := map[string]bool{} + for i := range k.Users { + if users[k.Users[i].Name] { + return nil, invalidKubeconfig(path) + } + users[k.Users[i].Name] = true + k.Users[i].source = path + } + + return &k, nil +} + +func mergeKubeconfigs(files []*kubeconfig) *kubeconfig { + merged := &kubeconfig{} + clusters := map[string]bool{} + contexts := map[string]bool{} + users := map[string]bool{} + + for _, f := range files { + if f == nil { + continue + } + if merged.APIVersion == "" { + merged.APIVersion = f.APIVersion + } + if merged.Kind == "" { + merged.Kind = f.Kind + } + if merged.CurrentContext == "" { + merged.CurrentContext = f.CurrentContext + } + if len(merged.Preferences) == 0 { + merged.Preferences = f.Preferences + } + if len(merged.Extensions) == 0 { + merged.Extensions = f.Extensions + } + for _, c := range f.Clusters { + if !clusters[c.Name] { + clusters[c.Name] = true + merged.Clusters = append(merged.Clusters, c) + } + } + for _, c := range f.Contexts { + if !contexts[c.Name] { + contexts[c.Name] = true + merged.Contexts = append(merged.Contexts, c) + } + } + for _, u := range f.Users { + if !users[u.Name] { + users[u.Name] = true + merged.Users = append(merged.Users, u) + } + } + } + + return merged +} + +func loadKubeconfig(paths []string) (*kubeconfig, error) { + var files []*kubeconfig + for _, path := range paths { + k, err := readKubeconfig(path) + if errors.Is(err, errNotFound) { + continue + } + if err != nil { + return nil, err + } + files = append(files, k) + } + if len(files) == 0 { + return nil, errNotFound + } + + return mergeKubeconfigs(files), nil +} + +func (k *kubeconfig) context(name string) (namedContext, bool) { + for _, c := range k.Contexts { + if c.Name == name { + return c, true + } + } + return namedContext{}, false +} + +func (k *kubeconfig) cluster(name string) (namedCluster, bool) { + for _, c := range k.Clusters { + if c.Name == name { + return c, true + } + } + return namedCluster{}, false +} + +func (k *kubeconfig) user(name string) (namedUser, bool) { + for _, u := range k.Users { + if u.Name == name { + return u, true + } + } + return namedUser{}, false +} + +func (k *kubeconfig) hasName(name string) bool { + if k.CurrentContext == name { + return true + } + for _, c := range k.Clusters { + if c.Name == name { + return true + } + } + for _, u := range k.Users { + if u.Name == name { + return true + } + } + for _, c := range k.Contexts { + if c.Name == name || c.Context.Cluster == name || c.Context.User == name { + return true + } + } + return false +} + +var schemePrefix = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9+.-]*://`) + +func normalizeServer(s string) (string, bool) { + s = strings.TrimSpace(s) + if !schemePrefix.MatchString(s) { + s = "https://" + s + } + + u, err := url.Parse(s) + if err != nil || u.User != nil { + return "", false + } + scheme := strings.ToLower(u.Scheme) + if scheme != "http" && scheme != "https" { + return "", false + } + + host := u.Hostname() + if host == "" { + return "", false + } + if strings.HasPrefix(u.Host, "[") { + addr, err := netip.ParseAddr(host) + if err != nil || !addr.Is6() { + return "", false + } + address, zone, hasZone := strings.Cut(host, "%") + host = address + if hasZone { + host += "%25" + escapeZone(zone) + } + host = "[" + host + "]" + } else { + if strings.Contains(host, ":") { + return "", false + } + host = strings.ToLower(host) + } + + port := u.Port() + if (scheme == "https" && port == "443") || (scheme == "http" && port == "80") { + port = "" + } + if port != "" { + host += ":" + port + } + + normalized := scheme + "://" + host + strings.TrimRight(u.EscapedPath(), "/") + if _, err := url.Parse(normalized); err != nil { + return "", false + } + + return normalized, true +} + +func escapeZone(zone string) string { + var b strings.Builder + for i := 0; i < len(zone); i++ { + c := zone[i] + switch { + case c >= 'a' && c <= 'z', c >= 'A' && c <= 'Z', c >= '0' && c <= '9', c == '-', c == '.', c == '_', c == '~': + b.WriteByte(c) + default: + fmt.Fprintf(&b, "%%%02X", c) + } + } + return b.String() +} + +func toBase64PEM(value string, blockTypes ...string) (string, error) { + var pemBytes []byte + if strings.HasPrefix(strings.TrimLeftFunc(value, unicode.IsSpace), "-----BEGIN") { + pemBytes = []byte(value) + } else { + compact := strings.Join(strings.FieldsFunc(value, unicode.IsSpace), "") + decoded, err := base64.StdEncoding.DecodeString(compact) + if err != nil { + return "", errors.New("value is neither PEM nor base64-encoded PEM") + } + pemBytes = decoded + } + + block, _ := pem.Decode(bytes.TrimLeftFunc(pemBytes, unicode.IsSpace)) + if block == nil { + return "", errors.New("value does not contain a PEM block") + } + if len(blockTypes) > 0 { + matched := false + for _, t := range blockTypes { + if strings.HasSuffix(block.Type, t) { + matched = true + break + } + } + if !matched { + return "", fmt.Errorf("PEM block is not of type %s", strings.Join(blockTypes, " or ")) + } + } + + return base64.StdEncoding.EncodeToString(pemBytes), nil +} diff --git a/plugins/kubectl/kubeconfig_test.go b/plugins/kubectl/kubeconfig_test.go new file mode 100644 index 00000000..5028f73f --- /dev/null +++ b/plugins/kubectl/kubeconfig_test.go @@ -0,0 +1,519 @@ +package kubectl + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "crypto/x509/pkix" + "encoding/base64" + "encoding/pem" + "errors" + "math/big" + "net/url" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/1Password/shell-plugins/sdk/schema/fieldname" + "github.com/stretchr/testify/require" + "gopkg.in/yaml.v2" +) + +const roundTripFixture = `apiVersion: v1 +kind: Config +current-context: prod +preferences: + colors: true + extensions: + - name: pref-ext + extension: + level: 3 +extensions: +- name: top-ext + extension: + flag: true +clusters: +- name: prod + cluster: + server: https://prod.example.com:6443 + certificate-authority-data: Q0E= + certificate-authority: /fake/ca.pem + insecure-skip-tls-verify: true + tls-server-name: api.internal.example.com + proxy-url: http://proxy.example.com:3128 + disable-compression: true + extensions: + - name: cl-ext + extension: + region: eu +contexts: +- name: prod + context: + cluster: prod + user: tokenuser + namespace: team-a + extensions: + - name: ctx-ext + extension: + nested: + list: [a, b] +users: +- name: tokenuser + user: + token: fake-token + tokenFile: /fake/token + as: someone + as-uid: uid-1 + as-groups: [g1, g2] + as-user-extra: + scopes: [s1, s2] + extensions: + - name: usr-ext + extension: + key: value +- name: certuser + user: + client-certificate-data: Q0VSVA== + client-key-data: S0VZ + client-certificate: /fake/c.pem + client-key: /fake/k.pem + username: fake-user + password: fake-pass + exec: + apiVersion: client.authentication.k8s.io/v1 + command: fake-cmd + args: [one, two] + auth-provider: + name: oidc + config: + idp-issuer-url: https://idp.example.com +` + +func writeFile(t *testing.T, dir, name, content string) string { + t.Helper() + path := filepath.Join(dir, name) + require.NoError(t, os.WriteFile(path, []byte(content), 0o600)) + return path +} + +func stripSources(k *kubeconfig) { + for i := range k.Clusters { + k.Clusters[i].source = "" + } + for i := range k.Contexts { + k.Contexts[i].source = "" + } + for i := range k.Users { + k.Users[i].source = "" + } +} + +func TestReadKubeconfigRoundTrip(t *testing.T) { + dir := t.TempDir() + path := writeFile(t, dir, "config", roundTripFixture) + + first, err := readKubeconfig(path) + require.NoError(t, err) + + require.Equal(t, "prod", first.CurrentContext) + require.Equal(t, true, first.Preferences["colors"]) + require.Len(t, first.Clusters, 1) + require.True(t, first.Clusters[0].Cluster.InsecureSkipTLSVerify) + require.Equal(t, "api.internal.example.com", first.Clusters[0].Cluster.TLSServerName) + require.Equal(t, "http://proxy.example.com:3128", first.Clusters[0].Cluster.ProxyURL) + require.True(t, first.Clusters[0].Cluster.DisableCompression) + require.Len(t, first.Clusters[0].Cluster.Extensions, 1) + require.Len(t, first.Extensions, 1) + require.Equal(t, "team-a", first.Contexts[0].Context.Namespace) + require.Len(t, first.Contexts[0].Context.Extensions, 1) + require.Len(t, first.Users, 2) + tokenUser := first.Users[0].User + require.Equal(t, "fake-token", tokenUser.Token) + require.Equal(t, "someone", tokenUser.As) + require.Equal(t, "uid-1", tokenUser.AsUID) + require.Equal(t, []string{"g1", "g2"}, tokenUser.AsGroups) + require.Equal(t, map[string][]string{"scopes": {"s1", "s2"}}, tokenUser.AsUserExtra) + require.Len(t, tokenUser.Extensions, 1) + certUser := first.Users[1].User + require.Equal(t, "Q0VSVA==", certUser.ClientCertificateData) + require.Equal(t, "S0VZ", certUser.ClientKeyData) + require.Equal(t, "fake-cmd", certUser.Exec["command"]) + require.Equal(t, "oidc", certUser.AuthProvider["name"]) + + out, err := yaml.Marshal(first) + require.NoError(t, err) + for _, want := range []string{ + "as-uid: uid-1", "as-groups:", "as-user-extra:", "extensions:", "preferences:", + "namespace: team-a", "insecure-skip-tls-verify: true", "tokenFile: /fake/token", + "client-certificate-data:", "auth-provider:", "exec:", "idp-issuer-url", + "tls-server-name:", "proxy-url:", "disable-compression: true", "top-ext", "cl-ext", + } { + require.Contains(t, string(out), want) + } + + path2 := writeFile(t, dir, "config2", string(out)) + second, err := readKubeconfig(path2) + require.NoError(t, err) + + stripSources(first) + stripSources(second) + require.Equal(t, first, second) +} + +func TestReadKubeconfigSourceAndErrors(t *testing.T) { + dir := t.TempDir() + + t.Run("source is set on every entry", func(t *testing.T) { + path := writeFile(t, dir, "config", roundTripFixture) + k, err := readKubeconfig(path) + require.NoError(t, err) + require.Equal(t, path, k.Clusters[0].source) + require.Equal(t, path, k.Contexts[0].source) + require.Equal(t, path, k.Users[0].source) + require.Equal(t, path, k.Users[1].source) + }) + + t.Run("missing file", func(t *testing.T) { + _, err := readKubeconfig(filepath.Join(dir, "nope")) + require.True(t, errors.Is(err, errNotFound)) + }) + + t.Run("directory", func(t *testing.T) { + _, err := readKubeconfig(dir) + require.Error(t, err) + require.False(t, errors.Is(err, errNotFound)) + }) + + t.Run("fifo", func(t *testing.T) { + fifo := filepath.Join(dir, "fifo") + if err := exec.Command("mkfifo", fifo).Run(); err != nil { + t.Skip("mkfifo unavailable") + } + _, err := readKubeconfig(fifo) + require.Error(t, err) + require.False(t, errors.Is(err, errNotFound)) + }) + + t.Run("duplicate names", func(t *testing.T) { + for kind, content := range map[string]string{ + "clusters": "clusters:\n- name: a\n cluster: {server: 'https://x'}\n- name: a\n cluster: {server: 'https://y'}\n", + "contexts": "contexts:\n- name: a\n context: {cluster: c, user: u}\n- name: a\n context: {cluster: c, user: u}\n", + "users": "users:\n- name: a\n user: {token: fake-secret-token}\n- name: a\n user: {token: other}\n", + } { + path := writeFile(t, dir, "dup-"+kind, content) + _, err := readKubeconfig(path) + require.Error(t, err, kind) + require.NotContains(t, err.Error(), "fake-secret-token") + } + }) + + t.Run("invalid yaml never leaks content", func(t *testing.T) { + path := writeFile(t, dir, "bad", "clusters: [fake-secret-token: {\n") + _, err := readKubeconfig(path) + require.Error(t, err) + require.Equal(t, "parsing "+path+": not a valid kubeconfig", err.Error()) + + path = writeFile(t, dir, "bad-type", "clusters: fake-secret-token\n") + _, err = readKubeconfig(path) + require.Equal(t, "parsing "+path+": not a valid kubeconfig", err.Error()) + }) +} + +func TestLoadKubeconfigMerge(t *testing.T) { + dir := t.TempDir() + a := writeFile(t, dir, "a", `current-context: "" +contexts: +- name: prod + context: {cluster: prod, user: alice} +clusters: +- name: other + cluster: {server: 'https://127.0.0.1:7443'} +preferences: + colors: true +`) + b := writeFile(t, dir, "b", `current-context: prod +contexts: +- name: prod + context: {cluster: prod, user: bob} +clusters: +- name: prod + cluster: {server: 'https://prod.example.com:6443'} +- name: other + cluster: {server: 'https://shadowed.example.com'} +users: +- name: bob + user: {token: fake} +preferences: + colors: false +`) + missing := filepath.Join(dir, "missing") + + k, err := loadKubeconfig([]string{missing, a, missing, b}) + require.NoError(t, err) + require.Equal(t, "prod", k.CurrentContext) + + ctx, ok := k.context("prod") + require.True(t, ok) + require.Equal(t, "alice", ctx.Context.User) + require.Equal(t, a, ctx.source) + + cl, ok := k.cluster("prod") + require.True(t, ok) + require.Equal(t, "https://prod.example.com:6443", cl.Cluster.Server) + require.Equal(t, b, cl.source) + + other, ok := k.cluster("other") + require.True(t, ok) + require.Equal(t, "https://127.0.0.1:7443", other.Cluster.Server) + require.Equal(t, a, other.source) + + u, ok := k.user("bob") + require.True(t, ok) + require.Equal(t, b, u.source) + + _, ok = k.context("nope") + require.False(t, ok) + _, ok = k.cluster("nope") + require.False(t, ok) + _, ok = k.user("nope") + require.False(t, ok) + + require.Equal(t, true, k.Preferences["colors"]) + + t.Run("all missing", func(t *testing.T) { + k, err := loadKubeconfig([]string{missing, filepath.Join(dir, "missing2")}) + require.Nil(t, k) + require.True(t, errors.Is(err, errNotFound)) + + k, err = loadKubeconfig(nil) + require.Nil(t, k) + require.True(t, errors.Is(err, errNotFound)) + }) + + t.Run("parse failure in any present file", func(t *testing.T) { + dup := writeFile(t, dir, "dup", "contexts:\n- name: x\n- name: x\n") + _, err := loadKubeconfig([]string{a, dup, b}) + require.Error(t, err) + require.False(t, errors.Is(err, errNotFound)) + }) + + t.Run("directory in the list", func(t *testing.T) { + _, err := loadKubeconfig([]string{a, dir}) + require.Error(t, err) + require.False(t, errors.Is(err, errNotFound)) + }) +} + +func TestMergeKubeconfigs(t *testing.T) { + first := &kubeconfig{CurrentContext: ""} + second := &kubeconfig{CurrentContext: "x", Preferences: map[string]any{"colors": true}, Extensions: []any{"e2"}} + third := &kubeconfig{CurrentContext: "y", Extensions: []any{"e3"}} + merged := mergeKubeconfigs([]*kubeconfig{first, nil, second, third}) + require.Equal(t, "x", merged.CurrentContext) + require.Equal(t, map[string]any{"colors": true}, merged.Preferences) + require.Equal(t, []any{"e2"}, merged.Extensions) +} + +func TestHasName(t *testing.T) { + k := &kubeconfig{ + CurrentContext: "current", + Clusters: []namedCluster{{Name: "c1"}}, + Contexts: []namedContext{{Name: "ctx1", Context: kubeContext{Cluster: "refc", User: "refu"}}}, + Users: []namedUser{{Name: "u1"}}, + } + for _, name := range []string{"c1", "ctx1", "u1", "refc", "refu", "current"} { + require.True(t, k.hasName(name), name) + } + require.False(t, k.hasName("absent")) + require.False(t, k.hasName("")) +} + +func TestNormalizeServer(t *testing.T) { + tests := []struct { + in string + want string + ok bool + }{ + {"HTTPS://Prod.Example.com:6443/", "https://prod.example.com:6443", true}, + {" https://prod.example.com:6443 ", "https://prod.example.com:6443", true}, + {"https://x:443", "https://x", true}, + {"http://x:80", "http://x", true}, + {"http://x:443", "http://x:443", true}, + {"https://x:80", "https://x:80", true}, + {"prod.example.com:6443", "https://prod.example.com:6443", true}, + {"prod.example.com", "https://prod.example.com", true}, + {"127.0.0.1:6443", "https://127.0.0.1:6443", true}, + {"https://rancher.example.com/k8s/clusters/c-abc/", "https://rancher.example.com/k8s/clusters/c-abc", true}, + {"https://x/a?b=c#d", "https://x/a", true}, + {"https://x:6443?b=c", "https://x:6443", true}, + {"https://[fe80::1%25en0]:6443", "https://[fe80::1%25en0]:6443", true}, + {"https://[fe80::1%25ETH0]:6443", "https://[fe80::1%25ETH0]:6443", true}, + {"example.com/path?redirect=https://other", "https://example.com/path", true}, + {"example.com/a://b", "https://example.com/a://b", true}, + {"example.com#frag://x", "https://example.com", true}, + {"https://[fe80::1%25en%25x]", "https://[fe80::1%25en%25x]", true}, + {"https://[fe80::1%25a%20b]:6443", "https://[fe80::1%25a%20b]:6443", true}, + {"https://[fe80::1%25a%252Fb]", "https://[fe80::1%25a%252Fb]", true}, + {"https://[fe80::1%25a%2Fb]:6443", "", false}, + {"https://[fe80::1%25]", "", false}, + {"https://[::1]:443", "https://[::1]", true}, + {"https://[::1]:6443", "https://[::1]:6443", true}, + {"http://[::1]:80/p/", "http://[::1]/p", true}, + {"https://user:pw@x", "", false}, + {"https://user@x", "", false}, + {"ftp://x", "", false}, + {"https://", "", false}, + {"https://:443", "", false}, + {"::::", "", false}, + {"", "", false}, + {" ", "", false}, + {"https://a b", "", false}, + {"https://x:notaport", "", false}, + {"https://[notanip]:6443", "", false}, + {"https://[1.2.3.4]", "", false}, + } + for _, tt := range tests { + got, ok := normalizeServer(tt.in) + require.Equal(t, tt.ok, ok, tt.in) + require.Equal(t, tt.want, got, tt.in) + if ok { + _, err := url.Parse(got) + require.NoError(t, err, got) + again, ok := normalizeServer(got) + require.True(t, ok, got) + require.Equal(t, got, again, got) + } + } + + httpSrv, _ := normalizeServer("http://x") + httpsSrv, _ := normalizeServer("https://x") + require.NotEqual(t, httpSrv, httpsSrv) +} + +func throwawayPEMs(t *testing.T) (certPEM, pkcs8PEM, ecPEM, rsaPEM string) { + t.Helper() + ecKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + tmpl := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: "throwaway"}, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(time.Hour), + } + der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &ecKey.PublicKey, ecKey) + require.NoError(t, err) + certPEM = string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})) + + pkcs8, err := x509.MarshalPKCS8PrivateKey(ecKey) + require.NoError(t, err) + pkcs8PEM = string(pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: pkcs8})) + + ecDER, err := x509.MarshalECPrivateKey(ecKey) + require.NoError(t, err) + ecPEM = string(pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: ecDER})) + + rsaKey, err := rsa.GenerateKey(rand.Reader, 1024) + require.NoError(t, err) + rsaPEM = string(pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(rsaKey)})) + return +} + +func wrapBase64(s string) string { + var b strings.Builder + for i := 0; i < len(s); i += 20 { + end := min(i+20, len(s)) + b.WriteString(s[i:end]) + if i%40 == 0 { + b.WriteString("\n") + } else { + b.WriteString(" ") + } + } + return " " + b.String() + "\n" +} + +func TestToBase64PEM(t *testing.T) { + certPEM, pkcs8PEM, ecPEM, rsaPEM := throwawayPEMs(t) + + t.Run("pem and base64 of the same pem agree", func(t *testing.T) { + want := base64.StdEncoding.EncodeToString([]byte(certPEM)) + + got, err := toBase64PEM(certPEM, "CERTIFICATE") + require.NoError(t, err) + require.Equal(t, want, got) + + got, err = toBase64PEM(want, "CERTIFICATE") + require.NoError(t, err) + require.Equal(t, want, got) + + got, err = toBase64PEM(wrapBase64(want), "CERTIFICATE") + require.NoError(t, err) + require.Equal(t, want, got) + + for _, padded := range []string{"\n" + certPEM, certPEM + "\n\n ", "\n\n" + certPEM + " \n"} { + got, err = toBase64PEM(padded, "CERTIFICATE") + require.NoError(t, err) + require.Equal(t, base64.StdEncoding.EncodeToString([]byte(padded)), got) + + viaBase64, err := toBase64PEM(base64.StdEncoding.EncodeToString([]byte(padded)), "CERTIFICATE") + require.NoError(t, err) + require.Equal(t, got, viaBase64) + } + }) + + t.Run("private key block types", func(t *testing.T) { + for _, p := range []string{pkcs8PEM, ecPEM, rsaPEM} { + got, err := toBase64PEM(p, "PRIVATE KEY") + require.NoError(t, err) + require.Equal(t, base64.StdEncoding.EncodeToString([]byte(p)), got) + + got, err = toBase64PEM(base64.StdEncoding.EncodeToString([]byte(p)), "PRIVATE KEY") + require.NoError(t, err) + require.Equal(t, base64.StdEncoding.EncodeToString([]byte(p)), got) + } + }) + + t.Run("block type mismatch", func(t *testing.T) { + _, err := toBase64PEM(certPEM, "PRIVATE KEY") + require.Error(t, err) + _, err = toBase64PEM(pkcs8PEM, "CERTIFICATE") + require.Error(t, err) + _, err = toBase64PEM(base64.StdEncoding.EncodeToString([]byte(certPEM)), "PRIVATE KEY") + require.Error(t, err) + }) + + t.Run("one of several types", func(t *testing.T) { + _, err := toBase64PEM(certPEM, "PRIVATE KEY", "CERTIFICATE") + require.NoError(t, err) + }) + + t.Run("invalid input never echoed", func(t *testing.T) { + secretish := base64.StdEncoding.EncodeToString([]byte("fake-secret-token-not-pem")) + for _, in := range []string{ + "not base64!", + secretish, + "-----BEGIN CERTIFICATE-----\nfake-secret-token\n-----END CERTIFICATE-----", + "-----BEGIN", + "", + } { + _, err := toBase64PEM(in, "CERTIFICATE") + require.Error(t, err, in) + if in != "" { + require.NotContains(t, err.Error(), in) + } + require.NotContains(t, err.Error(), "fake-secret-token") + } + _, err := toBase64PEM("not base64!", "CERTIFICATE") + require.EqualError(t, err, "value is neither PEM nor base64-encoded PEM") + }) +} + +func TestCertificateAuthorityFieldName(t *testing.T) { + require.Equal(t, "Certificate Authority", string(fieldname.CertificateAuthority)) + require.Contains(t, fieldname.ListAll(), fieldname.CertificateAuthority) +} diff --git a/plugins/kubectl/kubectl.go b/plugins/kubectl/kubectl.go new file mode 100644 index 00000000..2afffd00 --- /dev/null +++ b/plugins/kubectl/kubectl.go @@ -0,0 +1,21 @@ +package kubectl + +import ( + "github.com/1Password/shell-plugins/sdk" + "github.com/1Password/shell-plugins/sdk/schema" + "github.com/1Password/shell-plugins/sdk/schema/credname" +) + +func KubectlCLI() schema.Executable { + return schema.Executable{ + Name: "kubectl", + Runs: []string{"kubectl"}, + DocsURL: sdk.URL("https://kubernetes.io/docs/reference/kubectl/"), + NeedsAuth: needsAuth, + Uses: []schema.CredentialUsage{ + { + Name: credname.Credentials, + }, + }, + } +} diff --git a/plugins/kubectl/kuberc.go b/plugins/kubectl/kuberc.go new file mode 100644 index 00000000..a015b5fc --- /dev/null +++ b/plugins/kubectl/kuberc.go @@ -0,0 +1,127 @@ +package kubectl + +import ( + "bytes" + "errors" + "io" + "os" + "path/filepath" + "slices" + "strings" + + "gopkg.in/yaml.v2" +) + +type kubercOption struct { + Name string `yaml:"name"` +} + +type kubercCommandDefaults struct { + Options []kubercOption `yaml:"options"` + Flags []kubercOption `yaml:"flags"` +} + +type kubercAlias struct { + Name string `yaml:"name"` +} + +// kubercPreference covers v1beta1 (defaults/options) and v1alpha1 (overrides/flags). +type kubercPreference struct { + APIVersion string `yaml:"apiVersion"` + Kind string `yaml:"kind"` + Aliases []kubercAlias `yaml:"aliases"` + Defaults []kubercCommandDefaults `yaml:"defaults"` + Overrides []kubercCommandDefaults `yaml:"overrides"` +} + +var kubercTargetingOptions = map[string]bool{ + "context": true, + "cluster": true, + "server": true, + "kubeconfig": true, + "user": true, + "token": true, + "client-certificate": true, + "client-key": true, + "username": true, + "password": true, + "proxy-url": true, + "tls-server-name": true, + "insecure-skip-tls-verify": true, + "certificate-authority": true, +} + +// kubercInterferes reports whether a kuberc file in effect could change which cluster or +// credentials the command uses. kubectl applies defaults through cobra command aliases and +// parseArgs cannot see subcommand-local flag values, so the check ignores which command an +// entry is for and fails closed. +func kubercInterferes(homeDir string, args parsedArgs) bool { + if len(args.flags["kuberc"]) > 1 { + return true + } + path, ok := kubercPath(homeDir, args) + if !ok { + return false + } + + info, err := os.Stat(path) + if errors.Is(err, os.ErrNotExist) { + return false + } + if err != nil || !info.Mode().IsRegular() { + return true + } + contents, err := os.ReadFile(path) + if err != nil { + return true + } + + decoder := yaml.NewDecoder(bytes.NewReader(contents)) + for { + var pref kubercPreference + err := decoder.Decode(&pref) + if errors.Is(err, io.EOF) { + return false + } + if err != nil || pref.interferes(args) { + return true + } + } +} + +func kubercPath(homeDir string, args parsedArgs) (string, bool) { + if path, ok := args.value("kuberc"); ok { + return path, true + } + if env := os.Getenv("KUBERC"); env != "" { + return env, env != "off" + } + return filepath.Join(homeDir, ".kube", "kuberc"), true +} + +func (pref kubercPreference) interferes(args parsedArgs) bool { + if !strings.HasPrefix(pref.APIVersion, "kubectl.config.k8s.io/") || pref.Kind != "Preference" { + return true + } + for _, alias := range pref.Aliases { + if slices.Contains(args.positionals, alias.Name) { + return true + } + } + for _, d := range slices.Concat(pref.Defaults, pref.Overrides) { + for _, option := range slices.Concat(d.Options, d.Flags) { + if isTargetingOption(option.Name) { + return true + } + } + } + return false +} + +func isTargetingOption(name string) bool { + name = normalizeFlagName(strings.TrimLeft(name, "-")) + if long, ok := shortFlags[name]; ok { + name = long + } + return kubercTargetingOptions[name] +} diff --git a/plugins/kubectl/kuberc_test.go b/plugins/kubectl/kuberc_test.go new file mode 100644 index 00000000..ddaa38bd --- /dev/null +++ b/plugins/kubectl/kuberc_test.go @@ -0,0 +1,233 @@ +package kubectl + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const kubercHeader = "apiVersion: kubectl.config.k8s.io/v1beta1\nkind: Preference\n" + +func kubercDefaultsYAML(command, option string) string { + return kubercHeader + "defaults:\n- command: " + command + "\n options:\n - name: " + option + "\n default: x\n" +} + +func kubercAliasYAML(name string) string { + return kubercHeader + "aliases:\n- name: " + name + "\n command: get\n prependArgs:\n - pods\n" +} + +func TestKubercInterferes(t *testing.T) { + clearKubeEnv(t) + const homeKuberc = ".kube/kuberc" + cases := map[string]struct { + files map[string]string + fifo string + env string + args []string + want bool + }{ + "no kuberc anywhere": { + args: []string{"get", "pods"}, + }, + "alias named like the subcommand": { + files: map[string]string{homeKuberc: kubercAliasYAML("get")}, + args: []string{"get", "pods"}, + want: true, + }, + "alias named like a custom subcommand": { + files: map[string]string{homeKuberc: kubercAliasYAML("getp")}, + args: []string{"--context", "prod", "getp"}, + want: true, + }, + "alias named like a later positional": { + files: map[string]string{homeKuberc: kubercAliasYAML("pods")}, + args: []string{"-o", "yaml", "get", "pods"}, + want: true, + }, + "alias named like a word after the terminator": { + files: map[string]string{homeKuberc: kubercAliasYAML("sh")}, + args: []string{"exec", "pod", "--", "sh"}, + }, + "alias with another name": { + files: map[string]string{homeKuberc: kubercAliasYAML("getn")}, + args: []string{"get", "pods"}, + }, + "defaults with a harmless option": { + files: map[string]string{homeKuberc: kubercDefaultsYAML("get", "output")}, + args: []string{"get", "pods"}, + }, + "defaults for another subcommand with server": { + files: map[string]string{homeKuberc: kubercDefaultsYAML("get", "server")}, + args: []string{"describe", "pods"}, + want: true, + }, + "get server default with a local flag value before the subcommand": { + files: map[string]string{homeKuberc: kubercDefaultsYAML("get", "server")}, + args: []string{"-o", "yaml", "get", "pods"}, + want: true, + }, + "create configmap default reached through the cm alias": { + files: map[string]string{homeKuberc: kubercDefaultsYAML("create configmap", "context")}, + args: []string{"create", "cm", "x"}, + want: true, + }, + "defaults for a two-word command": { + files: map[string]string{homeKuberc: kubercDefaultsYAML("rollout status", "context")}, + args: []string{"rollout", "history", "deploy/x"}, + want: true, + }, + "namespace-only default": { + files: map[string]string{homeKuberc: kubercDefaultsYAML("get", "namespace")}, + args: []string{"get", "pods"}, + }, + "namespace shorthand default": { + files: map[string]string{homeKuberc: kubercDefaultsYAML("get", "n")}, + args: []string{"get", "pods"}, + }, + "namespace and server defaults": { + files: map[string]string{homeKuberc: kubercDefaultsYAML("get", "namespace") + " - name: server\n default: x\n"}, + args: []string{"get", "pods"}, + want: true, + }, + "option name with underscore": { + files: map[string]string{homeKuberc: kubercDefaultsYAML("get", "client_key")}, + args: []string{"get", "pods"}, + want: true, + }, + "option name as shorthand": { + files: map[string]string{homeKuberc: kubercDefaultsYAML("get", "s")}, + args: []string{"get", "pods"}, + want: true, + }, + "v1alpha1 overrides with flags": { + files: map[string]string{homeKuberc: "apiVersion: kubectl.config.k8s.io/v1alpha1\nkind: Preference\noverrides:\n- command: get\n flags:\n - name: server\n default: https://127.0.0.1:7443\n"}, + args: []string{"get", "pods"}, + want: true, + }, + "v1alpha1 overrides with a harmless flag": { + files: map[string]string{homeKuberc: "apiVersion: kubectl.config.k8s.io/v1alpha1\nkind: Preference\noverrides:\n- command: get\n flags:\n - name: output\n default: wide\n"}, + args: []string{"get", "pods"}, + }, + "interfering second document": { + files: map[string]string{homeKuberc: kubercHeader + "---\n" + kubercDefaultsYAML("get", "context")}, + args: []string{"get", "pods"}, + want: true, + }, + "wrong apiVersion": { + files: map[string]string{homeKuberc: "apiVersion: v1\nkind: Preference\n"}, + args: []string{"get", "pods"}, + want: true, + }, + "wrong kind": { + files: map[string]string{homeKuberc: "apiVersion: kubectl.config.k8s.io/v1beta1\nkind: Config\n"}, + args: []string{"get", "pods"}, + want: true, + }, + "second document without apiVersion": { + files: map[string]string{homeKuberc: kubercHeader + "---\naliases: []\n"}, + args: []string{"get", "pods"}, + want: true, + }, + "empty file": { + files: map[string]string{homeKuberc: ""}, + args: []string{"get", "pods"}, + }, + "unparseable file": { + files: map[string]string{homeKuberc: "aliases: [not: valid"}, + args: []string{"get", "pods"}, + want: true, + }, + "wrong shape": { + files: map[string]string{homeKuberc: kubercHeader + "defaults: just-a-string\n"}, + args: []string{"get", "pods"}, + want: true, + }, + "KUBERC=off ignores the home kuberc": { + files: map[string]string{homeKuberc: kubercAliasYAML("get")}, + env: "off", + args: []string{"get", "pods"}, + }, + "KUBERC points at an interfering file": { + files: map[string]string{"custom/kuberc": kubercDefaultsYAML("get", "context")}, + env: "{home}/custom/kuberc", + args: []string{"get", "pods"}, + want: true, + }, + "KUBERC points at a missing file and the home kuberc is not read": { + files: map[string]string{homeKuberc: kubercAliasYAML("get")}, + env: "{home}/missing", + args: []string{"get", "pods"}, + }, + "--kuberc wins over KUBERC": { + files: map[string]string{"benign": kubercHeader, "bad": kubercDefaultsYAML("get", "kubeconfig")}, + env: "{home}/benign", + args: []string{"--kuberc", "{home}/bad", "get", "pods"}, + want: true, + }, + "--kuberc pointing at a benign file shadows the home kuberc": { + files: map[string]string{homeKuberc: kubercAliasYAML("get"), "benign": kubercHeader}, + args: []string{"--kuberc={home}/benign", "get", "pods"}, + }, + "--kuberc given twice": { + files: map[string]string{"benign": kubercHeader}, + args: []string{"--kuberc", "{home}/benign", "--kuberc={home}/benign", "get", "pods"}, + want: true, + }, + "kuberc is a directory": { + files: map[string]string{homeKuberc + "/placeholder": ""}, + args: []string{"get", "pods"}, + want: true, + }, + "kuberc is a FIFO": { + fifo: "fifo", + env: "{home}/fifo", + args: []string{"get", "pods"}, + want: true, + }, + } + + for name, c := range cases { + t.Run(name, func(t *testing.T) { + home := t.TempDir() + t.Setenv("KUBERC", strings.ReplaceAll(c.env, "{home}", home)) + for path, contents := range c.files { + full := filepath.Join(home, path) + require.NoError(t, os.MkdirAll(filepath.Dir(full), 0o700)) + require.NoError(t, os.WriteFile(full, []byte(contents), 0o600)) + } + if c.fifo != "" { + if err := exec.Command("mkfifo", filepath.Join(home, c.fifo)).Run(); err != nil { + t.Skipf("mkfifo unavailable: %v", err) + } + } + args := make([]string, len(c.args)) + for i, a := range c.args { + args[i] = strings.ReplaceAll(a, "{home}", home) + } + + assert.Equal(t, c.want, kubercInterferes(home, parseArgs(args))) + }) + } +} + +func TestKubercInterferesForEveryTargetingOption(t *testing.T) { + clearKubeEnv(t) + for _, option := range []string{ + "context", "cluster", "server", "kubeconfig", "user", "token", "client-certificate", "client-key", + "username", "password", "proxy-url", "tls-server-name", "insecure-skip-tls-verify", "certificate-authority", + } { + t.Run(option, func(t *testing.T) { + home := t.TempDir() + t.Setenv("KUBERC", "") + require.NoError(t, os.MkdirAll(filepath.Join(home, ".kube"), 0o700)) + require.NoError(t, os.WriteFile(filepath.Join(home, ".kube", "kuberc"), []byte(kubercDefaultsYAML("apply", option)), 0o600)) + + assert.True(t, kubercInterferes(home, parseArgs([]string{"get", "pods"}))) + }) + } +} diff --git a/plugins/kubectl/needs_auth.go b/plugins/kubectl/needs_auth.go new file mode 100644 index 00000000..5216f6f3 --- /dev/null +++ b/plugins/kubectl/needs_auth.go @@ -0,0 +1,36 @@ +package kubectl + +import "github.com/1Password/shell-plugins/sdk" + +var localSubcommands = map[string]bool{ + "help": true, + "config": true, + "completion": true, + "options": true, + "plugin": true, + "kustomize": true, + "kuberc": true, + "__complete": true, + "__completeNoDesc": true, +} + +func needsAuth(in sdk.NeedsAuthenticationInput) bool { + p := parseArgs(in.CommandArgs) + sub := p.subcommand() + switch { + case sub == "": + return false + case p.boolValue("help"): + return false + case localSubcommands[sub]: + return false + case sub == "version" && p.boolValue("client"): + return false + } + for _, flag := range explicitAuthFlags { + if p.has(flag) { + return false + } + } + return true +} diff --git a/plugins/kubectl/needs_auth_test.go b/plugins/kubectl/needs_auth_test.go new file mode 100644 index 00000000..9a6253cc --- /dev/null +++ b/plugins/kubectl/needs_auth_test.go @@ -0,0 +1,57 @@ +package kubectl + +import ( + "testing" + + "github.com/1Password/shell-plugins/sdk/plugintest" +) + +func TestKubectlNeedsAuth(t *testing.T) { + yes := func(args ...string) plugintest.NeedsAuthCase { + return plugintest.NeedsAuthCase{Args: args, ExpectedNeedsAuth: true} + } + no := func(args ...string) plugintest.NeedsAuthCase { + return plugintest.NeedsAuthCase{Args: args, ExpectedNeedsAuth: false} + } + plugintest.TestNeedsAuth(t, needsAuth, map[string]plugintest.NeedsAuthCase{ + "yes get pods": yes("get", "pods"), + "yes context get pods": yes("--context", "prod", "get", "pods"), + "yes short namespace get pods": yes("-n", "kube-system", "get", "pods"), + "yes kubeconfig apply": yes("--kubeconfig", "f", "apply", "-f", "x"), + "yes version": yes("version"), + "yes version client false": yes("version", "--client=false"), + "yes version client last wins": yes("version", "--client=true", "--client=false"), + "yes api-resources": yes("api-resources"), + "yes explain": yes("explain", "pods"), + "yes cluster-info": yes("cluster-info"), + "yes auth can-i": yes("auth", "can-i", "get", "pods"), + "yes exec after terminator": yes("exec", "pod", "--", "df", "-h"), + "yes exec help after terminator": yes("exec", "pod", "--", "sh", "-c", "help"), + "yes unknown subcommand": yes("foo"), + "yes help consumed as value": yes("--namespace", "--help", "version"), + "yes terminator consumed as value": yes("--namespace", "--", "version"), + "yes version help false": yes("version", "--help=false"), + "no explicit token": no("get", "pods", "--token=t"), + "no explicit user": no("--user", "other", "get", "pods"), + "no explicit client key": no("get", "pods", "--client_key=k"), + "no empty": no(), + "no long help": no("--help"), + "no short help": no("-h"), + "no get -h": no("get", "-h"), + "no help": no("help"), + "no help get": no("help", "get"), + "no config view": no("config", "view"), + "no config use-context": no("--context", "x", "config", "use-context", "y"), + "no completion": no("completion", "zsh"), + "no options": no("options"), + "no plugin list": no("plugin", "list"), + "no kustomize": no("kustomize", "."), + "no kuberc": no("kuberc", "view"), + "no version client": no("version", "--client"), + "no version client true": no("version", "--client=true"), + "no version client space true": no("version", "--client", "true"), + "no __complete": no("__complete", "get", ""), + "no __completeNoDesc": no("__completeNoDesc", "get", ""), + "no verbosity alone": no("-v=5"), + }) +} diff --git a/plugins/kubectl/plugin.go b/plugins/kubectl/plugin.go new file mode 100644 index 00000000..7bec00e2 --- /dev/null +++ b/plugins/kubectl/plugin.go @@ -0,0 +1,22 @@ +package kubectl + +import ( + "github.com/1Password/shell-plugins/sdk" + "github.com/1Password/shell-plugins/sdk/schema" +) + +func New() schema.Plugin { + return schema.Plugin{ + Name: "kubectl", + Platform: schema.PlatformInfo{ + Name: "Kubernetes", + Homepage: sdk.URL("https://kubernetes.io"), + }, + Credentials: []schema.CredentialType{ + Credentials(), + }, + Executables: []schema.Executable{ + KubectlCLI(), + }, + } +} diff --git a/plugins/kubectl/provisioner.go b/plugins/kubectl/provisioner.go new file mode 100644 index 00000000..f2efc2e7 --- /dev/null +++ b/plugins/kubectl/provisioner.go @@ -0,0 +1,339 @@ +package kubectl + +import ( + "context" + "crypto/rand" + "encoding/hex" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/1Password/shell-plugins/sdk" + "github.com/1Password/shell-plugins/sdk/schema/fieldname" + "gopkg.in/yaml.v2" +) + +const ( + overlayNamePrefix = "1password-shell-plugin-" + maxNameAttempts = 16 +) + +var ( + errNameGeneration = errors.New("kubectl: could not generate a name for the kubeconfig overlay") + errNameTaken = errors.New("kubectl: could not choose a unique name for the kubeconfig overlay") + randRead = rand.Read +) + +var explicitAuthFlags = []string{"token", "client-certificate", "client-key", "username", "password", "user"} + +type kubeconfigProvisioner struct { + newName func() string +} + +func Provisioner() sdk.Provisioner { + return kubeconfigProvisioner{} +} + +func (p kubeconfigProvisioner) name() (string, error) { + if p.newName != nil { + return p.newName(), nil + } + b := make([]byte, 8) + if _, err := randRead(b); err != nil { + return "", errNameGeneration + } + return overlayNamePrefix + hex.EncodeToString(b), nil +} + +func (p kubeconfigProvisioner) Provision(ctx context.Context, in sdk.ProvisionInput, out *sdk.ProvisionOutput) { + if len(out.CommandLine) == 0 { + return + } + args := parseArgs(out.CommandLine[1:]) + sep := string(os.PathListSeparator) + + if args.isAmbiguous() { + return + } + for _, flag := range explicitAuthFlags { + if args.has(flag) { + return + } + } + + envKubeconfig := os.Getenv("KUBECONFIG") + homeConfig := in.FromHomeDir(".kube", "config") + explicitPath, explicit := args.value("kubeconfig") + var sources []string + switch { + case explicit: + if strings.Contains(explicitPath, sep) { + return + } + sources = []string{explicitPath} + case envKubeconfig != "": + for _, path := range filepath.SplitList(envKubeconfig) { + if path != "" { + sources = append(sources, path) + } + } + default: + if strings.Contains(homeConfig, sep) { + return + } + sources = []string{homeConfig} + } + + if kubercInterferes(in.HomeDir, args) { + return + } + + if !explicit && envKubeconfig == "" && isFreshMachine(in, args) { + p.provisionStandalone(in, args, out) + return + } + + merged, err := loadKubeconfig(sources) + if err != nil { + return + } + + ctxName, ok := args.value("context") + if !ok { + ctxName = merged.CurrentContext + } + target, ok := merged.context(ctxName) + if ctxName == "" || !ok { + return + } + clusterName, ok := args.value("cluster") + if !ok { + clusterName = target.Context.Cluster + } + server, ok := args.value("server") + if !ok { + c, _ := merged.cluster(clusterName) + server = c.Cluster.Server + } + if server == "" { + return + } + + address := in.ItemFields[fieldname.Address] + if address == "" { + out.AddError(errors.New("kubectl: the 1Password item has no Address; set it to the cluster server URL")) + return + } + if !sameServer(server, address) { + return + } + + credentials, errs := itemUser(in.ItemFields) + if len(errs) > 0 { + for _, err := range errs { + out.AddError(err) + } + return + } + if original, ok := merged.user(target.Context.User); ok { + credentials.As = original.User.As + credentials.AsUID = original.User.AsUID + credentials.AsGroups = original.User.AsGroups + credentials.AsUserExtra = original.User.AsUserExtra + credentials.Extensions = original.User.Extensions + } + + name, err := p.uniqueName(merged) + if err != nil { + out.AddError(err) + return + } + + overlay := kubeconfig{ + APIVersion: "v1", + Kind: "Config", + Contexts: []namedContext{{ + Name: ctxName, + Context: kubeContext{ + Cluster: target.Context.Cluster, + User: name, + Namespace: target.Context.Namespace, + Extensions: target.Context.Extensions, + }, + }}, + Users: []namedUser{{Name: name, User: credentials}}, + } + + var rest string + switch { + case explicit: + rest = explicitPath + case envKubeconfig != "": + rest = envKubeconfig + default: + rest = homeConfig + } + if !writeOverlay(in, out, overlay, rest) { + return + } + if explicit { + out.CommandLine = append([]string{out.CommandLine[0]}, removeFlag(out.CommandLine[1:], "kubeconfig")...) + } +} + +func (p kubeconfigProvisioner) Deprovision(ctx context.Context, in sdk.DeprovisionInput, out *sdk.DeprovisionOutput) { + // The SDK removes the temp dir holding the overlay. +} + +func (p kubeconfigProvisioner) Description() string { + return "Provision a temporary kubeconfig overlay for the context that targets the item's cluster" +} + +func (p kubeconfigProvisioner) uniqueName(merged *kubeconfig) (string, error) { + for i := 0; i < maxNameAttempts; i++ { + name, err := p.name() + if err != nil { + return "", err + } + if !merged.hasName(name) { + return name, nil + } + } + return "", errNameTaken +} + +// isFreshMachine reports whether kubectl has no kubeconfig and no in-cluster config to fall +// back on, so that it would otherwise contact localhost:8080. +func isFreshMachine(in sdk.ProvisionInput, args parsedArgs) bool { + for _, path := range []string{in.FromHomeDir(".kube", "config"), in.FromHomeDir(".kube", ".kubeconfig")} { + if _, err := os.Lstat(path); !errors.Is(err, os.ErrNotExist) { + return false + } + } + if os.Getenv("KUBERNETES_SERVICE_HOST") != "" || os.Getenv("KUBERNETES_MASTER") != "" { + return false + } + return !args.has("context") && !args.has("cluster") && !args.has("kubeconfig") +} + +func (p kubeconfigProvisioner) provisionStandalone(in sdk.ProvisionInput, args parsedArgs, out *sdk.ProvisionOutput) { + address := strings.TrimSpace(in.ItemFields[fieldname.Address]) + if address == "" { + out.AddError(errors.New("kubectl: the 1Password item has no Address; set it to the cluster server URL")) + return + } + if _, ok := normalizeServer(address); !ok { + out.AddError(errors.New("kubectl: the 1Password item's Address is not a valid https:// or http:// server URL")) + return + } + c := cluster{Server: address} + if !schemePrefix.MatchString(address) { + c.Server = "https://" + address + } + if server, ok := args.value("server"); ok && !sameServer(server, c.Server) { + return + } + + credentials, errs := itemUser(in.ItemFields) + if ca := in.ItemFields[fieldname.CertificateAuthority]; ca != "" { + data, err := toBase64PEM(ca, "CERTIFICATE") + if err != nil { + errs = append(errs, fieldError(fieldname.CertificateAuthority, err)) + } + c.CertificateAuthorityData = data + } + if len(errs) > 0 { + for _, err := range errs { + out.AddError(err) + } + return + } + + name, err := p.name() + if err != nil { + out.AddError(err) + return + } + standalone := kubeconfig{ + APIVersion: "v1", + Kind: "Config", + Clusters: []namedCluster{{Name: name, Cluster: c}}, + Contexts: []namedContext{{Name: name, Context: kubeContext{Cluster: name, User: name}}}, + Users: []namedUser{{Name: name, User: credentials}}, + CurrentContext: name, + } + writeOverlay(in, out, standalone, "") +} + +// writeOverlay adds the overlay file and points KUBECONFIG at it, followed by rest when set. +func writeOverlay(in sdk.ProvisionInput, out *sdk.ProvisionOutput, config kubeconfig, rest string) bool { + sep := string(os.PathListSeparator) + path := in.FromTempDir("kubeconfig") + if strings.Contains(path, sep) { + return false + } + contents, err := yaml.Marshal(config) + if err != nil { + out.AddError(errors.New("kubectl: could not build the kubeconfig overlay")) + return false + } + + out.AddSecretFile(path, contents) + if rest != "" { + path += sep + rest + } + out.AddEnvVar("KUBECONFIG", path) + return true +} + +func itemUser(fields map[sdk.FieldName]string) (user, []error) { + var u user + var errs []error + u.Token = strings.TrimSpace(fields[fieldname.Token]) + + cert := fields[fieldname.Certificate] + key := fields[fieldname.PrivateKey] + switch { + case (cert == "") != (key == ""): + errs = append(errs, errors.New("kubectl: the 1Password item needs both Certificate and Private Key")) + case cert != "": + var err error + if u.ClientCertificateData, err = toBase64PEM(cert, "CERTIFICATE"); err != nil { + errs = append(errs, fieldError(fieldname.Certificate, err)) + } + if u.ClientKeyData, err = toBase64PEM(key, "PRIVATE KEY"); err != nil { + errs = append(errs, fieldError(fieldname.PrivateKey, err)) + } + case u.Token == "": + errs = append(errs, errors.New("kubectl: the 1Password item has neither Token nor Certificate and Private Key")) + } + return u, errs +} + +// fieldError wraps a toBase64PEM error, whose message never contains the value. +func fieldError(field sdk.FieldName, err error) error { + return fmt.Errorf("kubectl: %s in the 1Password item: %w", field, err) +} + +// sameServer requires both sides to be scheme-less or both to carry a scheme: kubectl +// talks plain http to a scheme-less server that has no TLS material, so "host:port" must +// not match "https://host:port". +func sameServer(a, b string) bool { + a, b = strings.TrimSpace(a), strings.TrimSpace(b) + if schemePrefix.MatchString(a) != schemePrefix.MatchString(b) { + return false + } + na, okA := normalizeServer(a) + nb, okB := normalizeServer(b) + if !okA || !okB || na != nb { + return false + } + // Without a scheme kubectl may use http, so the :443 default that normalizeServer + // drops is not a default here; the written host:port must agree as well. + if !schemePrefix.MatchString(a) { + return strings.EqualFold(strings.TrimSuffix(a, "/"), strings.TrimSuffix(b, "/")) + } + return true +} diff --git a/plugins/kubectl/provisioner_test.go b/plugins/kubectl/provisioner_test.go new file mode 100644 index 00000000..4fc715ab --- /dev/null +++ b/plugins/kubectl/provisioner_test.go @@ -0,0 +1,644 @@ +package kubectl + +import ( + "context" + "encoding/base64" + "errors" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/1Password/shell-plugins/sdk" + "github.com/1Password/shell-plugins/sdk/plugintest" + "github.com/1Password/shell-plugins/sdk/schema/fieldname" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const ( + prodServer = "https://prod.example.com:6443" + localServer = "https://127.0.0.1:7443" + itemToken = "test-token-not-a-secret" + testName = "1password-shell-plugin-test" + overlayPath = "/tmp/kubeconfig" + fakeCertPEM = "-----BEGIN CERTIFICATE-----\nZmFrZQ==\n-----END CERTIFICATE-----\n" + fakeKeyPEM = "-----BEGIN PRIVATE KEY-----\nZmFrZQ==\n-----END PRIVATE KEY-----\n" +) + +var ( + fakeCertB64 = base64.StdEncoding.EncodeToString([]byte(fakeCertPEM)) + fakeKeyB64 = base64.StdEncoding.EncodeToString([]byte(fakeKeyPEM)) + + tokenItem = map[sdk.FieldName]string{fieldname.Address: prodServer, fieldname.Token: itemToken} + pemItem = map[sdk.FieldName]string{fieldname.Address: prodServer, fieldname.Certificate: fakeCertPEM, fieldname.PrivateKey: fakeKeyPEM} + + tokenUser = " token: " + itemToken + "\n" + certKeyUser = " client-certificate-data: " + fakeCertB64 + "\n client-key-data: " + fakeKeyB64 + "\n" + prodCtxBody = " cluster: prod\n user: " + testName + "\n" + homeEnv = map[string]string{"KUBECONFIG": overlayPath + ":{home}/.kube/config"} + notTouched = map[string]string{} + twoContexts = map[string]string{".kube/config": kubeconfigYAML("prod", "", "")} + currentLocal = map[string]string{".kube/config": kubeconfigYAML("local", "", "")} +) + +// kubeconfigYAML builds a config with contexts prod and local; prodUserExtra is appended to +// the prod user and prodCtxExtra to the prod context. +func kubeconfigYAML(current, prodCtxExtra, prodUserExtra string) string { + return "apiVersion: v1\nkind: Config\n" + + "clusters:\n" + + "- name: prod\n cluster:\n server: " + prodServer + "\n" + + "- name: local\n cluster:\n server: " + localServer + "\n" + + "contexts:\n" + + "- name: prod\n context:\n cluster: prod\n user: prod-user\n" + prodCtxExtra + + "- name: local\n context:\n cluster: local\n user: local-user\n" + + "users:\n" + + "- name: prod-user\n user:\n token: stale-prod-token\n" + prodUserExtra + + "- name: local-user\n user:\n token: local-token\n" + + "current-context: " + current + "\n" +} + +func overlayYAML(ctxName, ctxBody, userName, userBody string) string { + return "apiVersion: v1\nkind: Config\n" + + "contexts:\n- name: " + ctxName + "\n context:\n" + ctxBody + + "users:\n- name: " + userName + "\n user:\n" + userBody +} + +func standaloneYAML(server, caLine, userBody string) string { + return "apiVersion: v1\nkind: Config\n" + + "clusters:\n- name: " + testName + "\n cluster:\n server: " + server + "\n" + caLine + + "contexts:\n- name: " + testName + "\n context:\n cluster: " + testName + "\n user: " + testName + "\n" + + "users:\n- name: " + testName + "\n user:\n" + userBody + + "current-context: " + testName + "\n" +} + +func withFields(base map[sdk.FieldName]string, changes map[sdk.FieldName]string) map[sdk.FieldName]string { + out := map[sdk.FieldName]string{} + for k, v := range base { + out[k] = v + } + for k, v := range changes { + if v == "" { + delete(out, k) + continue + } + out[k] = v + } + return out +} + +type provisionerCase struct { + files map[string]string // relative to the home directory; "{home}" is replaced in every string + env map[string]string + args []string + item map[sdk.FieldName]string + names []string // generator results in order; default {testName} + wantEnv map[string]string + wantArgs []string // nil means args unchanged + wantFile string // "" means no files + wantErrs []string + homeDir string // subdirectory of the test's temp dir used as home; default the temp dir itself + tempDir string // default "/tmp" + random bool // use the default name generator instead of the injected sequence +} + +func clearKubeEnv(t *testing.T) { + t.Helper() + for _, key := range []string{"KUBECONFIG", "KUBERC", "KUBERNETES_SERVICE_HOST", "KUBERNETES_MASTER"} { + t.Setenv(key, "") + } +} + +func runProvisionerCases(t *testing.T, cases map[string]provisionerCase) { + t.Helper() + clearKubeEnv(t) + for name, c := range cases { + t.Run(name, func(t *testing.T) { + home := filepath.Join(t.TempDir(), c.homeDir) + sub := func(s string) string { return strings.ReplaceAll(s, "{home}", home) } + subAll := func(in []string) []string { + out := make([]string, len(in)) + for i, s := range in { + out[i] = sub(s) + } + return out + } + + for _, key := range []string{"KUBECONFIG", "KUBERC", "KUBERNETES_SERVICE_HOST", "KUBERNETES_MASTER"} { + t.Setenv(key, sub(c.env[key])) + } + for path, contents := range c.files { + full := filepath.Join(home, path) + require.NoError(t, os.MkdirAll(filepath.Dir(full), 0o700)) + require.NoError(t, os.WriteFile(full, []byte(sub(contents)), 0o600)) + } + + names := c.names + if names == nil { + names = []string{testName} + } + calls := 0 + provisioner := kubeconfigProvisioner{newName: func() string { + name := names[calls%len(names)] + calls++ + return name + }} + if c.random { + provisioner = kubeconfigProvisioner{} + } + tempDir := c.tempDir + if tempDir == "" { + tempDir = "/tmp" + } + + args := append([]string{"kubectl"}, subAll(c.args)...) + out := sdk.ProvisionOutput{ + Environment: map[string]string{}, + Files: map[string]sdk.OutputFile{}, + CommandLine: append([]string(nil), args...), + } + provisioner.Provision(context.Background(), sdk.ProvisionInput{ + ItemFields: c.item, + HomeDir: home, + TempDir: tempDir, + }, &out) + + want := sdk.ProvisionOutput{ + Environment: map[string]string{}, + Files: map[string]sdk.OutputFile{}, + CommandLine: args, + } + for k, v := range c.wantEnv { + want.Environment[k] = sub(v) + } + if c.wantArgs != nil { + want.CommandLine = append([]string{"kubectl"}, subAll(c.wantArgs)...) + } + if c.wantFile != "" { + want.Files[overlayPath] = sdk.OutputFile{Contents: []byte(c.wantFile)} + } + for _, msg := range c.wantErrs { + want.Diagnostics.Errors = append(want.Diagnostics.Errors, sdk.Error{Message: msg}) + } + + assert.Equal(t, want, out) + }) + } +} + +func TestKubeconfigProvisionerMatching(t *testing.T) { + tokenOverlay := overlayYAML("prod", prodCtxBody, testName, tokenUser) + schemeless := map[string]string{".kube/config": strings.ReplaceAll(kubeconfigYAML("prod", "", ""), prodServer, "prod.example.com:6443")} + schemelessItem := withFields(tokenItem, map[sdk.FieldName]string{fieldname.Address: "prod.example.com:6443"}) + runProvisionerCases(t, map[string]provisionerCase{ + "P1 token item, current-context matches": { + files: twoContexts, item: tokenItem, + wantEnv: homeEnv, wantFile: tokenOverlay, + }, + "P2 cert and key item in PEM": { + files: twoContexts, item: pemItem, + wantEnv: homeEnv, wantFile: overlayYAML("prod", prodCtxBody, testName, certKeyUser), + }, + "P3 cert and key item in base64": { + files: twoContexts, + item: withFields(pemItem, map[sdk.FieldName]string{fieldname.Certificate: fakeCertB64, fieldname.PrivateKey: fakeKeyB64}), + wantEnv: homeEnv, wantFile: overlayYAML("prod", prodCtxBody, testName, certKeyUser), + }, + "P4 token, cert and key": { + files: twoContexts, item: withFields(pemItem, map[sdk.FieldName]string{fieldname.Token: itemToken}), + wantEnv: homeEnv, wantFile: overlayYAML("prod", prodCtxBody, testName, tokenUser+certKeyUser), + }, + "P5 current-context targets another cluster": { + files: currentLocal, item: tokenItem, wantEnv: notTouched, + }, + "P6 --context local": { + files: twoContexts, item: tokenItem, args: []string{"--context", "local", "get", "pods"}, wantEnv: notTouched, + }, + "P6 --context=local": { + files: twoContexts, item: tokenItem, args: []string{"get", "pods", "--context=local"}, wantEnv: notTouched, + }, + "P7 --context prod while current is local": { + files: currentLocal, item: tokenItem, args: []string{"--context", "prod", "get", "pods"}, + wantEnv: homeEnv, wantFile: tokenOverlay, + }, + "P8 -s other server": { + files: twoContexts, item: tokenItem, args: []string{"-s", localServer, "get", "pods"}, wantEnv: notTouched, + }, + "P8 --server=other server": { + files: twoContexts, item: tokenItem, args: []string{"--server=" + localServer, "get", "pods"}, wantEnv: notTouched, + }, + "P8 -s attached other server": { + files: twoContexts, item: tokenItem, args: []string{"-s" + localServer, "get", "pods"}, wantEnv: notTouched, + }, + "--server pointing at the item's cluster provisions the selected context": { + files: currentLocal, item: tokenItem, args: []string{"--server", prodServer, "get", "pods"}, + wantEnv: homeEnv, wantFile: overlayYAML("local", " cluster: local\n user: "+testName+"\n", testName, tokenUser), + }, + "P9 --cluster local": { + files: twoContexts, item: tokenItem, args: []string{"--cluster", "local", "get", "pods"}, wantEnv: notTouched, + }, + "--cluster prod while current is local keeps the original cluster in the overlay": { + files: currentLocal, item: tokenItem, args: []string{"--cluster=prod", "get", "pods"}, + wantEnv: homeEnv, wantFile: overlayYAML("local", " cluster: local\n user: "+testName+"\n", testName, tokenUser), + }, + "P10 --context after the terminator is ignored": { + files: twoContexts, item: tokenItem, args: []string{"exec", "pod", "--", "kubectl", "--context", "local"}, + wantEnv: homeEnv, wantFile: tokenOverlay, + }, + "unknown --context": { + files: twoContexts, item: tokenItem, args: []string{"--context", "nope", "get", "pods"}, wantEnv: notTouched, + }, + "empty current-context": { + files: map[string]string{".kube/config": kubeconfigYAML("", "", "")}, item: tokenItem, wantEnv: notTouched, + }, + "P19 address normalisation": { + files: twoContexts, + item: withFields(tokenItem, map[sdk.FieldName]string{fieldname.Address: "HTTPS://Prod.Example.com:6443/"}), + wantEnv: homeEnv, wantFile: tokenOverlay, + }, + "http and https are different endpoints": { + files: twoContexts, item: withFields(tokenItem, map[sdk.FieldName]string{fieldname.Address: "http://prod.example.com:6443"}), wantEnv: notTouched, + }, + "ambiguous argv provisions nothing": { + files: twoContexts, item: tokenItem, args: []string{"get", "pods", "--template", "--server=" + prodServer}, wantEnv: notTouched, + }, + "ambiguous short cluster provisions nothing": { + files: twoContexts, item: tokenItem, args: []string{"get", "pods", "-As" + prodServer}, wantEnv: notTouched, + }, + "B scheme-less kubeconfig server does not match an https Address": { + files: schemeless, item: tokenItem, wantEnv: notTouched, + }, + "B scheme-less kubeconfig server matches a scheme-less Address": { + files: schemeless, item: schemelessItem, + wantEnv: homeEnv, wantFile: tokenOverlay, + }, + "B scheme-less server with :443 does not match a scheme-less Address without it": { + files: map[string]string{".kube/config": strings.ReplaceAll(kubeconfigYAML("prod", "", ""), prodServer, "prod.example.com:443")}, + item: withFields(tokenItem, map[sdk.FieldName]string{fieldname.Address: "prod.example.com"}), wantEnv: notTouched, + }, + "B https kubeconfig server does not match a scheme-less Address": { + files: twoContexts, item: schemelessItem, wantEnv: notTouched, + }, + "B scheme-less -s does not match an https Address": { + files: twoContexts, item: tokenItem, args: []string{"-s", "prod.example.com:6443", "get", "pods"}, wantEnv: notTouched, + }, + "B https -s matches an https Address": { + files: currentLocal, item: tokenItem, args: []string{"-s", "https://prod.example.com:6443", "get", "pods"}, + wantEnv: homeEnv, wantFile: overlayYAML("local", " cluster: local\n user: "+testName+"\n", testName, tokenUser), + }, + "F context user missing from the config": { + files: map[string]string{".kube/config": strings.Replace(kubeconfigYAML("prod", "", " as: limited\n"), "user: prod-user", "user: ghost", 1)}, + item: tokenItem, + wantEnv: homeEnv, wantFile: overlayYAML("prod", prodCtxBody, testName, tokenUser), + }, + "P23 context namespace copied": { + files: map[string]string{".kube/config": kubeconfigYAML("prod", " namespace: team\n", "")}, + item: tokenItem, + wantEnv: homeEnv, wantFile: overlayYAML("prod", prodCtxBody+" namespace: team\n", testName, tokenUser), + }, + "P24 impersonation and user extensions copied": { + files: map[string]string{".kube/config": kubeconfigYAML("prod", "", + " as: limited\n as-uid: \"42\"\n as-groups:\n - readers\n as-user-extra:\n scopes:\n - view\n"+ + " extensions:\n - name: user-ext\n extension:\n level: 3\n")}, + item: tokenItem, + wantEnv: homeEnv, + wantFile: overlayYAML("prod", prodCtxBody, testName, tokenUser+ + " as: limited\n as-uid: \"42\"\n as-groups:\n - readers\n as-user-extra:\n scopes:\n - view\n"+ + " extensions:\n - extension:\n level: 3\n name: user-ext\n"), + }, + "P25 context extensions copied": { + files: map[string]string{".kube/config": kubeconfigYAML("prod", " extensions:\n - name: ctx-ext\n extension:\n team: a\n", "")}, + item: tokenItem, + wantEnv: homeEnv, + wantFile: overlayYAML("prod", prodCtxBody+" extensions:\n - extension:\n team: a\n name: ctx-ext\n", testName, tokenUser), + }, + "P26 generated name already used in the config": { + files: map[string]string{".kube/config": strings.ReplaceAll(kubeconfigYAML("prod", "", ""), "local-user", testName)}, + item: tokenItem, names: []string{testName, testName + "-2"}, + wantEnv: homeEnv, wantFile: overlayYAML("prod", " cluster: prod\n user: "+testName+"-2\n", testName+"-2", tokenUser), + }, + "generator never yields a free name": { + files: map[string]string{".kube/config": strings.ReplaceAll(kubeconfigYAML("prod", "", ""), "local-user", testName)}, + item: tokenItem, wantEnv: notTouched, + wantErrs: []string{"kubectl: could not choose a unique name for the kubeconfig overlay"}, + }, + }) +} + +func TestKubeconfigProvisionerExplicitAuth(t *testing.T) { + cases := map[string]provisionerCase{} + for name, args := range map[string][]string{ + "P11 --user": {"--user", "x", "get", "pods"}, + "P11 --token": {"--token", "t", "get", "pods"}, + "P11 --client-certificate": {"--client-certificate", "p", "get", "pods"}, + "--client-key": {"--client-key=k", "get", "pods"}, + "--username": {"--username", "u", "get", "pods"}, + "--password": {"--password=p", "get", "pods"}, + "P32 --token= empty": {"--token=", "get", "pods"}, + "P33 --client_key=k": {"--client_key=k", "get", "pods"}, + } { + cases[name] = provisionerCase{files: twoContexts, item: tokenItem, args: args, wantEnv: notTouched} + } + cases["explicit auth with an item without Address reports nothing"] = provisionerCase{ + files: twoContexts, item: map[sdk.FieldName]string{fieldname.Token: itemToken}, + args: []string{"--token", "t", "get", "pods"}, wantEnv: notTouched, + } + runProvisionerCases(t, cases) +} + +func TestKubeconfigProvisionerSources(t *testing.T) { + tokenOverlay := overlayYAML("prod", prodCtxBody, testName, tokenUser) + prodOnly := "apiVersion: v1\nkind: Config\nclusters:\n- name: prod\n cluster:\n server: " + prodServer + "\n" + + "contexts:\n- name: prod\n context:\n cluster: prod\n user: prod-user\n" + + "users:\n- name: prod-user\n user:\n token: stale\ncurrent-context: prod\n" + explicitEnv := map[string]string{"KUBECONFIG": overlayPath + ":{home}/prod.yaml"} + withProdFile := map[string]string{".kube/config": kubeconfigYAML("local", "", ""), "prod.yaml": prodOnly} + + runProvisionerCases(t, map[string]provisionerCase{ + "P12 --kubeconfig F": { + files: withProdFile, item: tokenItem, + args: []string{"--kubeconfig", "{home}/prod.yaml", "get", "pods"}, + wantArgs: []string{"get", "pods"}, wantEnv: explicitEnv, wantFile: tokenOverlay, + }, + "P12 --kubeconfig=F": { + files: withProdFile, item: tokenItem, + args: []string{"get", "--kubeconfig={home}/prod.yaml", "pods", "--", "--kubeconfig", "kept"}, + wantArgs: []string{"get", "pods", "--", "--kubeconfig", "kept"}, wantEnv: explicitEnv, wantFile: tokenOverlay, + }, + "--kubeconfig wins over KUBECONFIG": { + files: map[string]string{"local.yaml": kubeconfigYAML("local", "", ""), "prod.yaml": prodOnly}, + env: map[string]string{"KUBECONFIG": "{home}/local.yaml"}, item: tokenItem, + args: []string{"--kubeconfig", "{home}/prod.yaml", "get", "pods"}, + wantArgs: []string{"get", "pods"}, wantEnv: explicitEnv, wantFile: tokenOverlay, + }, + "P13 --kubeconfig missing": { + files: twoContexts, item: tokenItem, args: []string{"--kubeconfig", "{home}/missing", "get", "pods"}, wantEnv: notTouched, + }, + "P30 --kubeconfig path with a list separator": { + files: map[string]string{"a:b": prodOnly}, item: tokenItem, args: []string{"--kubeconfig", "{home}/a:b", "get", "pods"}, wantEnv: notTouched, + }, + "P31 --kubeconfig directory": { + files: twoContexts, item: tokenItem, args: []string{"--kubeconfig", "{home}/.kube", "get", "pods"}, wantEnv: notTouched, + }, + "KUBECONFIG list": { + files: map[string]string{"a.yaml": prodOnly, "b.yaml": kubeconfigYAML("local", "", "")}, + env: map[string]string{"KUBECONFIG": "{home}/a.yaml:{home}/b.yaml"}, item: tokenItem, + wantEnv: map[string]string{"KUBECONFIG": overlayPath + ":{home}/a.yaml:{home}/b.yaml"}, wantFile: tokenOverlay, + }, + "P14 first non-empty current-context wins": { + files: map[string]string{"a.yaml": "apiVersion: v1\nkind: Config\ncurrent-context: local\n", "b.yaml": kubeconfigYAML("prod", "", "")}, + env: map[string]string{"KUBECONFIG": "{home}/a.yaml:{home}/b.yaml"}, item: tokenItem, wantEnv: notTouched, + }, + "P15 first definition of a context wins": { + files: map[string]string{ + "a.yaml": "apiVersion: v1\nkind: Config\nclusters:\n- name: elsewhere\n cluster:\n server: " + localServer + "\n" + + "contexts:\n- name: prod\n context:\n cluster: elsewhere\n user: prod-user\n", + "b.yaml": kubeconfigYAML("prod", "", ""), + }, + env: map[string]string{"KUBECONFIG": "{home}/a.yaml:{home}/b.yaml"}, item: tokenItem, wantEnv: notTouched, + }, + "P16 empty and missing entries kept verbatim": { + files: map[string]string{"b.yaml": kubeconfigYAML("prod", "", "")}, + env: map[string]string{"KUBECONFIG": ":{home}/missing:{home}/b.yaml"}, item: tokenItem, + wantEnv: map[string]string{"KUBECONFIG": overlayPath + "::{home}/missing:{home}/b.yaml"}, wantFile: tokenOverlay, + }, + "P17 KUBECONFIG set but every file missing": { + env: map[string]string{"KUBECONFIG": "{home}/missing-a:{home}/missing-b"}, item: tokenItem, wantEnv: notTouched, + }, + "C home config path containing the list separator": { + homeDir: "a:b", files: twoContexts, item: tokenItem, wantEnv: notTouched, + }, + "F overlay path containing the list separator": { + tempDir: "/tmp/a:b", files: twoContexts, item: tokenItem, wantEnv: notTouched, + }, + "P18 unparseable home config": { + files: map[string]string{".kube/config": "contexts: [not: valid"}, item: tokenItem, wantEnv: notTouched, + }, + "KUBECONFIG with one unparseable file": { + files: map[string]string{"a.yaml": "contexts: [not: valid", "b.yaml": kubeconfigYAML("prod", "", "")}, + env: map[string]string{"KUBECONFIG": "{home}/a.yaml:{home}/b.yaml"}, item: tokenItem, wantEnv: notTouched, + }, + "home config present but in-cluster variables set": { + files: twoContexts, env: map[string]string{"KUBERNETES_SERVICE_HOST": "10.0.0.1"}, item: tokenItem, + wantEnv: homeEnv, wantFile: tokenOverlay, + }, + }) +} + +func TestKubeconfigProvisionerItemValidation(t *testing.T) { + runProvisionerCases(t, map[string]provisionerCase{ + "P20 certificate without private key": { + files: twoContexts, item: withFields(pemItem, map[sdk.FieldName]string{fieldname.PrivateKey: ""}), wantEnv: notTouched, + wantErrs: []string{"kubectl: the 1Password item needs both Certificate and Private Key"}, + }, + "private key without certificate": { + files: twoContexts, item: withFields(pemItem, map[sdk.FieldName]string{fieldname.Certificate: ""}), wantEnv: notTouched, + wantErrs: []string{"kubectl: the 1Password item needs both Certificate and Private Key"}, + }, + "P21 invalid base64 certificate": { + files: twoContexts, item: withFields(pemItem, map[sdk.FieldName]string{fieldname.Certificate: "not-base64-" + itemToken}), wantEnv: notTouched, + wantErrs: []string{"kubectl: Certificate in the 1Password item: value is neither PEM nor base64-encoded PEM"}, + }, + "certificate in the private key field": { + files: twoContexts, item: withFields(pemItem, map[sdk.FieldName]string{fieldname.PrivateKey: fakeCertPEM}), wantEnv: notTouched, + wantErrs: []string{"kubectl: Private Key in the 1Password item: PEM block is not of type PRIVATE KEY"}, + }, + "neither token nor certificate": { + files: twoContexts, item: map[sdk.FieldName]string{fieldname.Address: prodServer}, wantEnv: notTouched, + wantErrs: []string{"kubectl: the 1Password item has neither Token nor Certificate and Private Key"}, + }, + "P22 no match with a broken item": { + files: currentLocal, item: withFields(pemItem, map[sdk.FieldName]string{fieldname.PrivateKey: ""}), wantEnv: notTouched, + }, + "item without Address": { + files: twoContexts, item: map[sdk.FieldName]string{fieldname.Token: itemToken}, wantEnv: notTouched, + wantErrs: []string{"kubectl: the 1Password item has no Address; set it to the cluster server URL"}, + }, + "item Address that cannot be normalised": { + files: twoContexts, item: withFields(tokenItem, map[sdk.FieldName]string{fieldname.Address: "ftp://prod.example.com:6443"}), wantEnv: notTouched, + }, + }) +} + +func TestKubeconfigProvisionerKuberc(t *testing.T) { + tokenOverlay := overlayYAML("prod", prodCtxBody, testName, tokenUser) + runProvisionerCases(t, map[string]provisionerCase{ + "P27 kuberc alias named like the subcommand": { + files: map[string]string{".kube/config": kubeconfigYAML("prod", "", ""), ".kube/kuberc": kubercAliasYAML("get")}, + item: tokenItem, args: []string{"get", "pods"}, wantEnv: notTouched, + }, + "P28 kuberc defaults for get with server": { + files: map[string]string{".kube/config": kubeconfigYAML("prod", "", ""), ".kube/kuberc": kubercDefaultsYAML("get", "server")}, + item: tokenItem, args: []string{"get", "pods"}, wantEnv: notTouched, + }, + "P28 kuberc defaults for get with output only": { + files: map[string]string{".kube/config": kubeconfigYAML("prod", "", ""), ".kube/kuberc": kubercDefaultsYAML("get", "output")}, + item: tokenItem, args: []string{"get", "pods"}, wantEnv: homeEnv, wantFile: tokenOverlay, + }, + "kuberc namespace-only default": { + files: map[string]string{".kube/config": kubeconfigYAML("prod", "", ""), ".kube/kuberc": kubercDefaultsYAML("get", "namespace")}, + item: tokenItem, args: []string{"get", "pods"}, wantEnv: homeEnv, wantFile: tokenOverlay, + }, + "kuberc namespace and server defaults": { + files: map[string]string{".kube/config": kubeconfigYAML("prod", "", ""), ".kube/kuberc": kubercDefaultsYAML("get", "namespace") + " - name: server\n default: x\n"}, + item: tokenItem, args: []string{"get", "pods"}, wantEnv: notTouched, + }, + "P29 KUBERC=off ignores the home kuberc": { + files: map[string]string{".kube/config": kubeconfigYAML("prod", "", ""), ".kube/kuberc": kubercAliasYAML("get")}, + env: map[string]string{"KUBERC": "off"}, + item: tokenItem, args: []string{"get", "pods"}, wantEnv: homeEnv, wantFile: tokenOverlay, + }, + "--kuberc pointing at an interfering file": { + files: map[string]string{".kube/config": kubeconfigYAML("prod", "", ""), "rc": kubercDefaultsYAML("get", "context")}, + item: tokenItem, args: []string{"--kuberc", "{home}/rc", "get", "pods"}, wantEnv: notTouched, + }, + "interfering kuberc blocks the fresh-machine branch": { + files: map[string]string{".kube/kuberc": kubercDefaultsYAML("get", "server")}, + item: tokenItem, args: []string{"get", "pods"}, wantEnv: notTouched, + }, + }) +} + +func TestKubeconfigProvisionerFreshMachine(t *testing.T) { + clearKubeEnv(t) + + freshEnv := map[string]string{"KUBECONFIG": overlayPath} + tokenStandalone := standaloneYAML(prodServer, "", tokenUser) + plugintest.TestProvisioner(t, kubeconfigProvisioner{newName: func() string { return testName }}, map[string]plugintest.ProvisionCase{ + "T1 token item": { + ItemFields: tokenItem, + CommandLine: []string{"kubectl", "get", "pods"}, + ExpectedOutput: sdk.ProvisionOutput{ + Environment: freshEnv, + CommandLine: []string{"kubectl", "get", "pods"}, + Files: map[string]sdk.OutputFile{overlayPath: {Contents: []byte(tokenStandalone)}}, + }, + }, + "T2 certificate authority in PEM": { + ItemFields: withFields(tokenItem, map[sdk.FieldName]string{fieldname.CertificateAuthority: fakeCertPEM}), + CommandLine: []string{"kubectl", "get", "pods"}, + ExpectedOutput: sdk.ProvisionOutput{ + Environment: freshEnv, + CommandLine: []string{"kubectl", "get", "pods"}, + Files: map[string]sdk.OutputFile{overlayPath: {Contents: []byte( + standaloneYAML(prodServer, " certificate-authority-data: "+fakeCertB64+"\n", tokenUser))}}, + }, + }, + "T3 Address without scheme": { + ItemFields: withFields(tokenItem, map[sdk.FieldName]string{fieldname.Address: "prod.example.com:6443"}), + CommandLine: []string{"kubectl", "get", "pods"}, + ExpectedOutput: sdk.ProvisionOutput{ + Environment: freshEnv, + CommandLine: []string{"kubectl", "get", "pods"}, + Files: map[string]sdk.OutputFile{overlayPath: {Contents: []byte(tokenStandalone)}}, + }, + }, + "T4 --context given": { + ItemFields: tokenItem, + CommandLine: []string{"kubectl", "--context", "x", "get", "pods"}, + ExpectedOutput: sdk.ProvisionOutput{CommandLine: []string{"kubectl", "--context", "x", "get", "pods"}}, + }, + "T5 -s other server": { + ItemFields: tokenItem, + CommandLine: []string{"kubectl", "-s", localServer, "get", "pods"}, + ExpectedOutput: sdk.ProvisionOutput{CommandLine: []string{"kubectl", "-s", localServer, "get", "pods"}}, + }, + "T6 item without Address": { + ItemFields: map[sdk.FieldName]string{fieldname.Token: itemToken}, + CommandLine: []string{"kubectl", "get", "pods"}, + ExpectedOutput: sdk.ProvisionOutput{ + CommandLine: []string{"kubectl", "get", "pods"}, + Diagnostics: sdk.Diagnostics{Errors: []sdk.Error{{Message: "kubectl: the 1Password item has no Address; set it to the cluster server URL"}}}, + }, + }, + }) +} + +func TestKubeconfigProvisionerFreshMachineGuards(t *testing.T) { + freshEnv := map[string]string{"KUBECONFIG": overlayPath} + runProvisionerCases(t, map[string]provisionerCase{ + "T7 legacy ~/.kube/.kubeconfig exists": { + files: map[string]string{".kube/.kubeconfig": "apiVersion: v1\nkind: Config\n"}, item: tokenItem, wantEnv: notTouched, + }, + "T8 KUBERNETES_MASTER set": { + env: map[string]string{"KUBERNETES_MASTER": "https://10.0.0.1"}, item: tokenItem, wantEnv: notTouched, + }, + "KUBERNETES_SERVICE_HOST set": { + env: map[string]string{"KUBERNETES_SERVICE_HOST": "10.0.0.1"}, item: tokenItem, wantEnv: notTouched, + }, + "--cluster given": { + item: tokenItem, args: []string{"--cluster", "x", "get", "pods"}, wantEnv: notTouched, + }, + "--kubeconfig to a missing file": { + item: tokenItem, args: []string{"--kubeconfig", "{home}/missing", "get", "pods"}, wantEnv: notTouched, + }, + "-s matching the Address": { + item: tokenItem, args: []string{"-s", "https://PROD.example.com:6443/", "get", "pods"}, + wantEnv: freshEnv, wantFile: standaloneYAML(prodServer, "", tokenUser), + }, + "empty --kubeconfig= given": { + item: tokenItem, args: []string{"--kubeconfig=", "get", "pods"}, wantEnv: notTouched, + }, + "E Address that does not normalise": { + item: withFields(tokenItem, map[sdk.FieldName]string{fieldname.Address: "ftp://prod.example.com:6443"}), wantEnv: notTouched, + wantErrs: []string{"kubectl: the 1Password item's Address is not a valid https:// or http:// server URL"}, + }, + "scheme-less -s against the https server written for a scheme-less Address": { + item: withFields(tokenItem, map[sdk.FieldName]string{fieldname.Address: "prod.example.com:6443"}), + args: []string{"-s", "prod.example.com:6443", "get", "pods"}, wantEnv: notTouched, + }, + "https -s against the https server written for a scheme-less Address": { + item: withFields(tokenItem, map[sdk.FieldName]string{fieldname.Address: "prod.example.com:6443"}), + args: []string{"-s", "https://prod.example.com:6443", "get", "pods"}, + wantEnv: freshEnv, wantFile: standaloneYAML(prodServer, "", tokenUser), + }, + "-s that cannot be normalised": { + item: tokenItem, args: []string{"-s", "ftp://prod.example.com:6443", "get", "pods"}, wantEnv: notTouched, + }, + "invalid certificate authority": { + item: withFields(tokenItem, map[sdk.FieldName]string{fieldname.CertificateAuthority: fakeKeyPEM}), wantEnv: notTouched, + wantErrs: []string{"kubectl: Certificate Authority in the 1Password item: PEM block is not of type CERTIFICATE"}, + }, + "cert and key item": { + item: pemItem, + wantEnv: freshEnv, wantFile: standaloneYAML(prodServer, "", certKeyUser), + }, + "item without secrets": { + item: map[sdk.FieldName]string{fieldname.Address: prodServer}, + wantEnv: notTouched, + wantErrs: []string{"kubectl: the 1Password item has neither Token nor Certificate and Private Key"}, + }, + }) +} + +func TestKubeconfigProvisionerDescription(t *testing.T) { + clearKubeEnv(t) + assert.Equal(t, "Provision a temporary kubeconfig overlay for the context that targets the item's cluster", Provisioner().Description()) +} + +func TestKubeconfigProvisionerRandomName(t *testing.T) { + clearKubeEnv(t) + name, err := kubeconfigProvisioner{}.name() + require.NoError(t, err) + assert.Regexp(t, `^1password-shell-plugin-[0-9a-f]{16}$`, name) + other, err := kubeconfigProvisioner{}.name() + require.NoError(t, err) + assert.NotEqual(t, name, other) +} + +func TestKubeconfigProvisionerNameGenerationFailure(t *testing.T) { + original := randRead + randRead = func([]byte) (int, error) { return 0, errors.New("entropy unavailable") } + t.Cleanup(func() { randRead = original }) + + runProvisionerCases(t, map[string]provisionerCase{ + "D overlay for a matching context": { + files: twoContexts, item: tokenItem, random: true, wantEnv: notTouched, + wantErrs: []string{"kubectl: could not generate a name for the kubeconfig overlay"}, + }, + "D standalone config on a fresh machine": { + item: tokenItem, random: true, wantEnv: notTouched, + wantErrs: []string{"kubectl: could not generate a name for the kubeconfig overlay"}, + }, + }) +} diff --git a/plugins/kubectl/test-fixtures/ca.crt b/plugins/kubectl/test-fixtures/ca.crt new file mode 100644 index 00000000..789f07ac --- /dev/null +++ b/plugins/kubectl/test-fixtures/ca.crt @@ -0,0 +1,3 @@ +-----BEGIN CERTIFICATE----- +ZmFrZS1jYQ== +-----END CERTIFICATE----- diff --git a/plugins/kubectl/test-fixtures/cert-data-pem.yaml b/plugins/kubectl/test-fixtures/cert-data-pem.yaml new file mode 100644 index 00000000..41535ad1 --- /dev/null +++ b/plugins/kubectl/test-fixtures/cert-data-pem.yaml @@ -0,0 +1,19 @@ +apiVersion: v1 +kind: Config +clusters: +- name: dev + cluster: + server: https://dev.example.com:6443 +contexts: +- name: dev + context: + cluster: dev + user: dev-admin +users: +- name: dev-admin + user: + client-certificate-data: | + -----BEGIN CERTIFICATE----- + ZmFrZQ== + -----END CERTIFICATE----- + client-key-data: "LS0tLS1CRUdJTiBQUklWQVRFIEtFWS0tLS0tClptRnJaUT09Ci0t\n LS0tRU5EIFBSSVZBVEUgS0VZLS0tLS0K" diff --git a/plugins/kubectl/test-fixtures/cert-data.yaml b/plugins/kubectl/test-fixtures/cert-data.yaml new file mode 100644 index 00000000..ec2926e1 --- /dev/null +++ b/plugins/kubectl/test-fixtures/cert-data.yaml @@ -0,0 +1,16 @@ +apiVersion: v1 +kind: Config +clusters: +- name: dev + cluster: + server: https://dev.example.com:6443 +contexts: +- name: dev + context: + cluster: dev + user: dev-admin +users: +- name: dev-admin + user: + client-certificate-data: LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tClptRnJaUT09Ci0tLS0tRU5EIENFUlRJRklDQVRFLS0tLS0K + client-key-data: LS0tLS1CRUdJTiBQUklWQVRFIEtFWS0tLS0tClptRnJaUT09Ci0tLS0tRU5EIFBSSVZBVEUgS0VZLS0tLS0K diff --git a/plugins/kubectl/test-fixtures/cert-paths.yaml b/plugins/kubectl/test-fixtures/cert-paths.yaml new file mode 100644 index 00000000..8ae616cf --- /dev/null +++ b/plugins/kubectl/test-fixtures/cert-paths.yaml @@ -0,0 +1,17 @@ +apiVersion: v1 +kind: Config +clusters: +- name: dev + cluster: + server: https://dev.example.com:6443 + certificate-authority: certs/ca.crt +contexts: +- name: dev + context: + cluster: dev + user: dev-admin +users: +- name: dev-admin + user: + client-certificate: certs/client.crt + client-key: /etc/k8s/client.key diff --git a/plugins/kubectl/test-fixtures/client.crt b/plugins/kubectl/test-fixtures/client.crt new file mode 100644 index 00000000..5e6f5fd5 --- /dev/null +++ b/plugins/kubectl/test-fixtures/client.crt @@ -0,0 +1,3 @@ +-----BEGIN CERTIFICATE----- +ZmFrZQ== +-----END CERTIFICATE----- diff --git a/plugins/kubectl/test-fixtures/client.key b/plugins/kubectl/test-fixtures/client.key new file mode 100644 index 00000000..b1fb620a --- /dev/null +++ b/plugins/kubectl/test-fixtures/client.key @@ -0,0 +1,3 @@ +-----BEGIN PRIVATE KEY----- +ZmFrZQ== +-----END PRIVATE KEY----- diff --git a/plugins/kubectl/test-fixtures/conflict-a.yaml b/plugins/kubectl/test-fixtures/conflict-a.yaml new file mode 100644 index 00000000..550ae3d0 --- /dev/null +++ b/plugins/kubectl/test-fixtures/conflict-a.yaml @@ -0,0 +1,6 @@ +apiVersion: v1 +kind: Config +clusters: +- name: shared + cluster: + server: https://prod.example.com:6443 diff --git a/plugins/kubectl/test-fixtures/conflict-b.yaml b/plugins/kubectl/test-fixtures/conflict-b.yaml new file mode 100644 index 00000000..334bebf2 --- /dev/null +++ b/plugins/kubectl/test-fixtures/conflict-b.yaml @@ -0,0 +1,15 @@ +apiVersion: v1 +kind: Config +clusters: +- name: shared + cluster: + server: https://dev.example.com:6443 +contexts: +- name: shared + context: + cluster: shared + user: shared-admin +users: +- name: shared-admin + user: + token: test-token-not-a-secret diff --git a/plugins/kubectl/test-fixtures/context-conflict-a.yaml b/plugins/kubectl/test-fixtures/context-conflict-a.yaml new file mode 100644 index 00000000..129c2dc3 --- /dev/null +++ b/plugins/kubectl/test-fixtures/context-conflict-a.yaml @@ -0,0 +1,15 @@ +apiVersion: v1 +kind: Config +clusters: +- name: prod + cluster: + server: https://prod.example.com:6443 +contexts: +- name: shared + context: + cluster: prod + user: admin +users: +- name: admin + user: + token: test-token-not-a-secret diff --git a/plugins/kubectl/test-fixtures/context-conflict-b.yaml b/plugins/kubectl/test-fixtures/context-conflict-b.yaml new file mode 100644 index 00000000..e24a0be2 --- /dev/null +++ b/plugins/kubectl/test-fixtures/context-conflict-b.yaml @@ -0,0 +1,11 @@ +apiVersion: v1 +kind: Config +clusters: +- name: dev + cluster: + server: https://dev.example.com:6443 +contexts: +- name: shared + context: + cluster: dev + user: admin diff --git a/plugins/kubectl/test-fixtures/cross-a.yaml b/plugins/kubectl/test-fixtures/cross-a.yaml new file mode 100644 index 00000000..b975e85f --- /dev/null +++ b/plugins/kubectl/test-fixtures/cross-a.yaml @@ -0,0 +1,7 @@ +apiVersion: v1 +kind: Config +contexts: +- name: cross + context: + cluster: remote + user: remote-admin diff --git a/plugins/kubectl/test-fixtures/cross-b.yaml b/plugins/kubectl/test-fixtures/cross-b.yaml new file mode 100644 index 00000000..5bd0a060 --- /dev/null +++ b/plugins/kubectl/test-fixtures/cross-b.yaml @@ -0,0 +1,10 @@ +apiVersion: v1 +kind: Config +clusters: +- name: remote + cluster: + server: https://remote.example.com:6443 +users: +- name: remote-admin + user: + token: test-token-not-a-secret diff --git a/plugins/kubectl/test-fixtures/cross-ref-a.yaml b/plugins/kubectl/test-fixtures/cross-ref-a.yaml new file mode 100644 index 00000000..0cab9efb --- /dev/null +++ b/plugins/kubectl/test-fixtures/cross-ref-a.yaml @@ -0,0 +1,7 @@ +apiVersion: v1 +kind: Config +contexts: +- name: cross-ref + context: + cluster: ref-cluster + user: ref-user diff --git a/plugins/kubectl/test-fixtures/cross-ref-b.yaml b/plugins/kubectl/test-fixtures/cross-ref-b.yaml new file mode 100644 index 00000000..9e9e63bc --- /dev/null +++ b/plugins/kubectl/test-fixtures/cross-ref-b.yaml @@ -0,0 +1,12 @@ +apiVersion: v1 +kind: Config +clusters: +- name: ref-cluster + cluster: + server: https://remote.example.com:6443 + certificate-authority: ca.crt +users: +- name: ref-user + user: + client-certificate: client.crt + client-key: keys/client.key diff --git a/plugins/kubectl/test-fixtures/default-context.yaml b/plugins/kubectl/test-fixtures/default-context.yaml new file mode 100644 index 00000000..f73fc374 --- /dev/null +++ b/plugins/kubectl/test-fixtures/default-context.yaml @@ -0,0 +1,15 @@ +apiVersion: v1 +kind: Config +clusters: +- name: prod + cluster: + server: https://prod.example.com:6443 +contexts: +- name: default + context: + cluster: prod + user: prod-admin +users: +- name: prod-admin + user: + token: test-token-not-a-secret diff --git a/plugins/kubectl/test-fixtures/duplicate-with-diagnostic.yaml b/plugins/kubectl/test-fixtures/duplicate-with-diagnostic.yaml new file mode 100644 index 00000000..9d543c64 --- /dev/null +++ b/plugins/kubectl/test-fixtures/duplicate-with-diagnostic.yaml @@ -0,0 +1,24 @@ +apiVersion: v1 +kind: Config +clusters: +- name: prod + cluster: + server: https://prod.example.com:6443 +contexts: +- name: plain + context: + cluster: prod + user: plain +- name: same-token + context: + cluster: prod + user: same-token +users: +- name: plain + user: + token: test-token-not-a-secret +- name: same-token + user: + token: test-token-not-a-secret + client-certificate-data: LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tClptRnJaUT09Ci0tLS0tRU5EIENFUlRJRklDQVRFLS0tLS0K + client-key: keys/missing.key diff --git a/plugins/kubectl/test-fixtures/insecure.yaml b/plugins/kubectl/test-fixtures/insecure.yaml new file mode 100644 index 00000000..9107affc --- /dev/null +++ b/plugins/kubectl/test-fixtures/insecure.yaml @@ -0,0 +1,16 @@ +apiVersion: v1 +kind: Config +clusters: +- name: local + cluster: + server: https://127.0.0.1:7443 + insecure-skip-tls-verify: true +contexts: +- name: local + context: + cluster: local + user: local-admin +users: +- name: local-admin + user: + token: test-token-not-a-secret diff --git a/plugins/kubectl/test-fixtures/invalid-key-data.yaml b/plugins/kubectl/test-fixtures/invalid-key-data.yaml new file mode 100644 index 00000000..a8074b64 --- /dev/null +++ b/plugins/kubectl/test-fixtures/invalid-key-data.yaml @@ -0,0 +1,28 @@ +apiVersion: v1 +kind: Config +clusters: +- name: prod + cluster: + server: https://prod.example.com:6443 +- name: dev + cluster: + server: https://dev.example.com:6443 +contexts: +- name: prod + context: + cluster: prod + user: with-token +- name: dev + context: + cluster: dev + user: without-token +users: +- name: with-token + user: + token: test-token-not-a-secret + client-certificate-data: LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tClptRnJaUT09Ci0tLS0tRU5EIENFUlRJRklDQVRFLS0tLS0K + client-key-data: "not!base64" +- name: without-token + user: + client-certificate-data: LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tClptRnJaUT09Ci0tLS0tRU5EIENFUlRJRklDQVRFLS0tLS0K + client-key-data: "not!base64" diff --git a/plugins/kubectl/test-fixtures/invalid-material.yaml b/plugins/kubectl/test-fixtures/invalid-material.yaml new file mode 100644 index 00000000..a05d433a --- /dev/null +++ b/plugins/kubectl/test-fixtures/invalid-material.yaml @@ -0,0 +1,31 @@ +apiVersion: v1 +kind: Config +clusters: +- name: prod + cluster: + server: https://prod.example.com:6443 + certificate-authority: "-----BEGIN CERTIFICATE-----" +- name: dev + cluster: + server: https://dev.example.com:6443 + certificate-authority: bad-ca.crt +contexts: +- name: pem-path + context: + cluster: prod + user: pem-path +- name: bad-file + context: + cluster: dev + user: bad-file +users: +- name: pem-path + user: + token: test-token-one + client-certificate-data: LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tClptRnJaUT09Ci0tLS0tRU5EIENFUlRJRklDQVRFLS0tLS0K + client-key: LS0tLS1CRUdJTiBQUklWQVRFIEtFWS0tLS0tClptRnJaUT09Ci0tLS0tRU5EIFBSSVZBVEUgS0VZLS0tLS0K +- name: bad-file + user: + token: test-token-two + client-certificate: bad-cert.crt + client-key-data: LS0tLS1CRUdJTiBQUklWQVRFIEtFWS0tLS0tClptRnJaUT09Ci0tLS0tRU5EIFBSSVZBVEUgS0VZLS0tLS0K diff --git a/plugins/kubectl/test-fixtures/invalid.yaml b/plugins/kubectl/test-fixtures/invalid.yaml new file mode 100644 index 00000000..4e417a6b --- /dev/null +++ b/plugins/kubectl/test-fixtures/invalid.yaml @@ -0,0 +1,3 @@ +apiVersion: v1 +clusters: [unclosed + - name: prod diff --git a/plugins/kubectl/test-fixtures/lexical.yaml b/plugins/kubectl/test-fixtures/lexical.yaml new file mode 100644 index 00000000..0ae3b60a --- /dev/null +++ b/plugins/kubectl/test-fixtures/lexical.yaml @@ -0,0 +1,15 @@ +apiVersion: v1 +kind: Config +clusters: +- name: prod + cluster: + server: https://prod.example.com:6443 +contexts: +- name: lexical + context: + cluster: prod + user: lexical-admin +users: +- name: lexical-admin + user: + token: test-token-lexical diff --git a/plugins/kubectl/test-fixtures/literal-tilde.yaml b/plugins/kubectl/test-fixtures/literal-tilde.yaml new file mode 100644 index 00000000..b0850f88 --- /dev/null +++ b/plugins/kubectl/test-fixtures/literal-tilde.yaml @@ -0,0 +1,15 @@ +apiVersion: v1 +kind: Config +clusters: +- name: prod + cluster: + server: https://prod.example.com:6443 +contexts: +- name: literal + context: + cluster: prod + user: admin +users: +- name: admin + user: + token: test-token-not-a-secret diff --git a/plugins/kubectl/test-fixtures/merge-a.yaml b/plugins/kubectl/test-fixtures/merge-a.yaml new file mode 100644 index 00000000..9dcc9153 --- /dev/null +++ b/plugins/kubectl/test-fixtures/merge-a.yaml @@ -0,0 +1,15 @@ +apiVersion: v1 +kind: Config +clusters: +- name: alpha + cluster: + server: https://alpha.example.com:6443 +contexts: +- name: alpha + context: + cluster: alpha + user: alpha-admin +users: +- name: alpha-admin + user: + token: test-token-alpha diff --git a/plugins/kubectl/test-fixtures/merge-b.yaml b/plugins/kubectl/test-fixtures/merge-b.yaml new file mode 100644 index 00000000..551c1fd3 --- /dev/null +++ b/plugins/kubectl/test-fixtures/merge-b.yaml @@ -0,0 +1,16 @@ +apiVersion: v1 +kind: Config +clusters: +- name: beta + cluster: + server: https://beta.example.com:6443 +contexts: +- name: beta + context: + cluster: beta + user: beta-admin +users: +- name: beta-admin + user: + client-certificate-data: LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tClptRnJaUT09Ci0tLS0tRU5EIENFUlRJRklDQVRFLS0tLS0K + client-key-data: LS0tLS1CRUdJTiBQUklWQVRFIEtFWS0tLS0tClptRnJaUT09Ci0tLS0tRU5EIFBSSVZBVEUgS0VZLS0tLS0K diff --git a/plugins/kubectl/test-fixtures/missing-key-file.yaml b/plugins/kubectl/test-fixtures/missing-key-file.yaml new file mode 100644 index 00000000..f50d1f34 --- /dev/null +++ b/plugins/kubectl/test-fixtures/missing-key-file.yaml @@ -0,0 +1,16 @@ +apiVersion: v1 +kind: Config +clusters: +- name: dev + cluster: + server: https://dev.example.com:6443 +contexts: +- name: dev + context: + cluster: dev + user: dev-admin +users: +- name: dev-admin + user: + client-certificate-data: LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tClptRnJaUT09Ci0tLS0tRU5EIENFUlRJRklDQVRFLS0tLS0K + client-key: keys/missing.key diff --git a/plugins/kubectl/test-fixtures/missing-key-with-token.yaml b/plugins/kubectl/test-fixtures/missing-key-with-token.yaml new file mode 100644 index 00000000..88913891 --- /dev/null +++ b/plugins/kubectl/test-fixtures/missing-key-with-token.yaml @@ -0,0 +1,22 @@ +apiVersion: v1 +kind: Config +clusters: +- name: prod + cluster: + server: https://prod.example.com:6443 +contexts: +- name: prod + context: + cluster: prod + user: prod-admin +- name: prod-kube-system + context: + cluster: prod + user: prod-admin + namespace: kube-system +users: +- name: prod-admin + user: + token: test-token-not-a-secret + client-certificate-data: LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tClptRnJaUT09Ci0tLS0tRU5EIENFUlRJRklDQVRFLS0tLS0K + client-key: keys/missing.key diff --git a/plugins/kubectl/test-fixtures/mixed.yaml b/plugins/kubectl/test-fixtures/mixed.yaml new file mode 100644 index 00000000..f5bb68d1 --- /dev/null +++ b/plugins/kubectl/test-fixtures/mixed.yaml @@ -0,0 +1,27 @@ +apiVersion: v1 +kind: Config +clusters: +- name: prod + cluster: + server: https://prod.example.com:6443 +- name: eks + cluster: + server: https://eks.example.com +contexts: +- name: prod + context: + cluster: prod + user: prod-admin +- name: eks + context: + cluster: eks + user: eks +users: +- name: prod-admin + user: + token: test-token-not-a-secret +- name: eks + user: + exec: + apiVersion: client.authentication.k8s.io/v1beta1 + command: aws diff --git a/plugins/kubectl/test-fixtures/not-pem.txt b/plugins/kubectl/test-fixtures/not-pem.txt new file mode 100644 index 00000000..2716bf65 --- /dev/null +++ b/plugins/kubectl/test-fixtures/not-pem.txt @@ -0,0 +1 @@ +not a certificate diff --git a/plugins/kubectl/test-fixtures/relative.yaml b/plugins/kubectl/test-fixtures/relative.yaml new file mode 100644 index 00000000..247dd5b5 --- /dev/null +++ b/plugins/kubectl/test-fixtures/relative.yaml @@ -0,0 +1,16 @@ +apiVersion: v1 +kind: Config +clusters: +- name: dev + cluster: + server: https://dev.example.com:6443 +contexts: +- name: relative + context: + cluster: dev + user: dev-admin +users: +- name: dev-admin + user: + client-certificate: certs/client.crt + client-key: certs/client.key diff --git a/plugins/kubectl/test-fixtures/shared-broken-ca.yaml b/plugins/kubectl/test-fixtures/shared-broken-ca.yaml new file mode 100644 index 00000000..4398a890 --- /dev/null +++ b/plugins/kubectl/test-fixtures/shared-broken-ca.yaml @@ -0,0 +1,23 @@ +apiVersion: v1 +kind: Config +clusters: +- name: prod + cluster: + server: https://prod.example.com:6443 + certificate-authority: missing-ca.crt +contexts: +- name: one + context: + cluster: prod + user: one +- name: two + context: + cluster: prod + user: two +users: +- name: one + user: + token: test-token-one +- name: two + user: + token: test-token-two diff --git a/plugins/kubectl/test-fixtures/shared-user.yaml b/plugins/kubectl/test-fixtures/shared-user.yaml new file mode 100644 index 00000000..6c750a36 --- /dev/null +++ b/plugins/kubectl/test-fixtures/shared-user.yaml @@ -0,0 +1,20 @@ +apiVersion: v1 +kind: Config +clusters: +- name: prod + cluster: + server: https://prod.example.com:6443 +contexts: +- name: prod + context: + cluster: prod + user: prod-admin +- name: prod-kube-system + context: + cluster: prod + user: prod-admin + namespace: kube-system +users: +- name: prod-admin + user: + token: test-token-not-a-secret diff --git a/plugins/kubectl/test-fixtures/through-link.yaml b/plugins/kubectl/test-fixtures/through-link.yaml new file mode 100644 index 00000000..313a08c4 --- /dev/null +++ b/plugins/kubectl/test-fixtures/through-link.yaml @@ -0,0 +1,15 @@ +apiVersion: v1 +kind: Config +clusters: +- name: prod + cluster: + server: https://prod.example.com:6443 +contexts: +- name: through-link + context: + cluster: prod + user: admin +users: +- name: admin + user: + token: test-token-not-a-secret diff --git a/plugins/kubectl/test-fixtures/token-and-token-file.yaml b/plugins/kubectl/test-fixtures/token-and-token-file.yaml new file mode 100644 index 00000000..7bd557d5 --- /dev/null +++ b/plugins/kubectl/test-fixtures/token-and-token-file.yaml @@ -0,0 +1,16 @@ +apiVersion: v1 +kind: Config +clusters: +- name: prod + cluster: + server: https://prod.example.com:6443 +contexts: +- name: prod + context: + cluster: prod + user: prod-admin +users: +- name: prod-admin + user: + token: test-token-not-a-secret + tokenFile: /var/run/secrets/token diff --git a/plugins/kubectl/test-fixtures/token-cert-only.yaml b/plugins/kubectl/test-fixtures/token-cert-only.yaml new file mode 100644 index 00000000..6c6356bb --- /dev/null +++ b/plugins/kubectl/test-fixtures/token-cert-only.yaml @@ -0,0 +1,16 @@ +apiVersion: v1 +kind: Config +clusters: +- name: prod + cluster: + server: https://prod.example.com:6443 +contexts: +- name: prod + context: + cluster: prod + user: prod-admin +users: +- name: prod-admin + user: + token: test-token-not-a-secret + client-certificate-data: LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tClptRnJaUT09Ci0tLS0tRU5EIENFUlRJRklDQVRFLS0tLS0K diff --git a/plugins/kubectl/test-fixtures/token.yaml b/plugins/kubectl/test-fixtures/token.yaml new file mode 100644 index 00000000..07b518c8 --- /dev/null +++ b/plugins/kubectl/test-fixtures/token.yaml @@ -0,0 +1,18 @@ +apiVersion: v1 +kind: Config +current-context: prod +clusters: +- name: prod + cluster: + server: https://prod.example.com:6443 + certificate-authority-data: LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tClptRnJaUzFqWVE9PQotLS0tLUVORCBDRVJUSUZJQ0FURS0tLS0tCg== +contexts: +- name: prod + context: + cluster: prod + user: prod-admin + namespace: team +users: +- name: prod-admin + user: + token: test-token-not-a-secret diff --git a/plugins/kubectl/test-fixtures/unreadable-ca.yaml b/plugins/kubectl/test-fixtures/unreadable-ca.yaml new file mode 100644 index 00000000..94020773 --- /dev/null +++ b/plugins/kubectl/test-fixtures/unreadable-ca.yaml @@ -0,0 +1,24 @@ +apiVersion: v1 +kind: Config +clusters: +- name: prod + cluster: + server: https://prod.example.com:6443 + certificate-authority: missing-ca.crt +- name: dev + cluster: + server: https://dev.example.com:6443 + certificate-authority-data: "not!base64" +contexts: +- name: prod + context: + cluster: prod + user: admin +- name: dev + context: + cluster: dev + user: admin +users: +- name: admin + user: + token: test-token-not-a-secret diff --git a/plugins/kubectl/test-fixtures/unsupported.yaml b/plugins/kubectl/test-fixtures/unsupported.yaml new file mode 100644 index 00000000..bcfb1cf0 --- /dev/null +++ b/plugins/kubectl/test-fixtures/unsupported.yaml @@ -0,0 +1,51 @@ +apiVersion: v1 +kind: Config +clusters: +- name: prod + cluster: + server: https://prod.example.com:6443 +contexts: +- name: eks + context: + cluster: prod + user: eks +- name: oidc + context: + cluster: prod + user: oidc +- name: basic + context: + cluster: prod + user: basic +- name: token-file + context: + cluster: prod + user: token-file +- name: nothing + context: + cluster: prod + user: nothing +users: +- name: eks + user: + token: test-token-not-a-secret + exec: + apiVersion: client.authentication.k8s.io/v1beta1 + command: aws + args: [eks, get-token, --cluster-name, prod] +- name: oidc + user: + token: test-token-not-a-secret + auth-provider: + name: oidc + config: + client-id: kubernetes +- name: basic + user: + username: admin + password: test-password-not-a-secret +- name: token-file + user: + tokenFile: /var/run/secrets/token +- name: nothing + user: {} diff --git a/plugins/kubectl/test-fixtures/user-conflict-a.yaml b/plugins/kubectl/test-fixtures/user-conflict-a.yaml new file mode 100644 index 00000000..0597fc6b --- /dev/null +++ b/plugins/kubectl/test-fixtures/user-conflict-a.yaml @@ -0,0 +1,6 @@ +apiVersion: v1 +kind: Config +users: +- name: admin + user: + token: test-token-first diff --git a/plugins/kubectl/test-fixtures/user-conflict-b.yaml b/plugins/kubectl/test-fixtures/user-conflict-b.yaml new file mode 100644 index 00000000..02fb6256 --- /dev/null +++ b/plugins/kubectl/test-fixtures/user-conflict-b.yaml @@ -0,0 +1,15 @@ +apiVersion: v1 +kind: Config +clusters: +- name: prod + cluster: + server: https://prod.example.com:6443 +contexts: +- name: prod + context: + cluster: prod + user: admin +users: +- name: admin + user: + token: test-token-second diff --git a/sdk/schema/fieldname/names.go b/sdk/schema/fieldname/names.go index 67019d97..c9615c81 100644 --- a/sdk/schema/fieldname/names.go +++ b/sdk/schema/fieldname/names.go @@ -4,64 +4,65 @@ import "github.com/1Password/shell-plugins/sdk" // Credential field names. const ( - AccountKey = sdk.FieldName("Account Key") - APIHost = sdk.FieldName("API Host") - APIUrl = sdk.FieldName("API URL") - APIKey = sdk.FieldName("API Key") - APIKeyID = sdk.FieldName("API Key ID") - APISecret = sdk.FieldName("API Secret") - AccessKeyID = sdk.FieldName("Access Key ID") - AccessToken = sdk.FieldName("Access Token") - Account = sdk.FieldName("Account") - AccountID = sdk.FieldName("Account ID") - AccountSID = sdk.FieldName("Account SID") - Address = sdk.FieldName("Address") - AppKey = sdk.FieldName("App Key") - AppSecret = sdk.FieldName("App Secret") - AppToken = sdk.FieldName("App Token") - AuthToken = sdk.FieldName("Auth Token") - Authtoken = sdk.FieldName("Authtoken") - Cert = sdk.FieldName("Cert") - Certificate = sdk.FieldName("Certificate") - ClientSecret = sdk.FieldName("Client Secret") - ClientToken = sdk.FieldName("Client Token") - Credential = sdk.FieldName("Credential") - Credentials = sdk.FieldName("Credentials") - Database = sdk.FieldName("Database") - DefaultRegion = sdk.FieldName("Default Region") - DefaultZone = sdk.FieldName("Default Zone") - Deployment = sdk.FieldName("Deployment") - Email = sdk.FieldName("Email") - Endpoint = sdk.FieldName("Endpoint") - Host = sdk.FieldName("Host") - HostAddress = sdk.FieldName("Host Address") - Key = sdk.FieldName("Key") - ManagementKey = sdk.FieldName("Management Key") - MFASerial = sdk.FieldName("MFA Serial") - Mode = sdk.FieldName("Mode") - Namespace = sdk.FieldName("Namespace") - OneTimePassword = sdk.FieldName("One-Time Password") - OrgID = sdk.FieldName("Org ID") - OrgURL = sdk.FieldName("Org URL") - Organization = sdk.FieldName("Organization") - Password = sdk.FieldName("Password") - Port = sdk.FieldName("Port") - PublicKey = sdk.FieldName("Public Key") - PrivateKey = sdk.FieldName("Private Key") - ProjectID = sdk.FieldName("Project ID") - Project = sdk.FieldName("Project") - Region = sdk.FieldName("Region") - Secret = sdk.FieldName("Secret") - SecretAccessKey = sdk.FieldName("Secret Access Key") - Space = sdk.FieldName("Space") - Subdomain = sdk.FieldName("Subdomain") - Token = sdk.FieldName("Token") - URL = sdk.FieldName("URL") - User = sdk.FieldName("User") - UserAccessToken = sdk.FieldName("User Access Token") - UserKey = sdk.FieldName("User Key") - Username = sdk.FieldName("Username") - Website = sdk.FieldName("Website") + AccountKey = sdk.FieldName("Account Key") + APIHost = sdk.FieldName("API Host") + APIUrl = sdk.FieldName("API URL") + APIKey = sdk.FieldName("API Key") + APIKeyID = sdk.FieldName("API Key ID") + APISecret = sdk.FieldName("API Secret") + AccessKeyID = sdk.FieldName("Access Key ID") + AccessToken = sdk.FieldName("Access Token") + Account = sdk.FieldName("Account") + AccountID = sdk.FieldName("Account ID") + AccountSID = sdk.FieldName("Account SID") + Address = sdk.FieldName("Address") + AppKey = sdk.FieldName("App Key") + AppSecret = sdk.FieldName("App Secret") + AppToken = sdk.FieldName("App Token") + AuthToken = sdk.FieldName("Auth Token") + Authtoken = sdk.FieldName("Authtoken") + Cert = sdk.FieldName("Cert") + Certificate = sdk.FieldName("Certificate") + CertificateAuthority = sdk.FieldName("Certificate Authority") + ClientSecret = sdk.FieldName("Client Secret") + ClientToken = sdk.FieldName("Client Token") + Credential = sdk.FieldName("Credential") + Credentials = sdk.FieldName("Credentials") + Database = sdk.FieldName("Database") + DefaultRegion = sdk.FieldName("Default Region") + DefaultZone = sdk.FieldName("Default Zone") + Deployment = sdk.FieldName("Deployment") + Email = sdk.FieldName("Email") + Endpoint = sdk.FieldName("Endpoint") + Host = sdk.FieldName("Host") + HostAddress = sdk.FieldName("Host Address") + Key = sdk.FieldName("Key") + ManagementKey = sdk.FieldName("Management Key") + MFASerial = sdk.FieldName("MFA Serial") + Mode = sdk.FieldName("Mode") + Namespace = sdk.FieldName("Namespace") + OneTimePassword = sdk.FieldName("One-Time Password") + OrgID = sdk.FieldName("Org ID") + OrgURL = sdk.FieldName("Org URL") + Organization = sdk.FieldName("Organization") + Password = sdk.FieldName("Password") + Port = sdk.FieldName("Port") + PublicKey = sdk.FieldName("Public Key") + PrivateKey = sdk.FieldName("Private Key") + ProjectID = sdk.FieldName("Project ID") + Project = sdk.FieldName("Project") + Region = sdk.FieldName("Region") + Secret = sdk.FieldName("Secret") + SecretAccessKey = sdk.FieldName("Secret Access Key") + Space = sdk.FieldName("Space") + Subdomain = sdk.FieldName("Subdomain") + Token = sdk.FieldName("Token") + URL = sdk.FieldName("URL") + User = sdk.FieldName("User") + UserAccessToken = sdk.FieldName("User Access Token") + UserKey = sdk.FieldName("User Key") + Username = sdk.FieldName("Username") + Website = sdk.FieldName("Website") ) func ListAll() []sdk.FieldName { @@ -84,6 +85,7 @@ func ListAll() []sdk.FieldName { Authtoken, Cert, Certificate, + CertificateAuthority, ClientSecret, ClientToken, Credential,