mirror of https://github.com/tailscale/tailscale/
cmd/tailscale/cli: add "configure kubeconfig" subcommand
It takes in a node hostname and configures the local kubeconfig file to point to that. Updates #7220 Signed-off-by: Maisem Ali <maisem@tailscale.com>pull/7280/head
parent
181a3da513
commit
c01c84ea8e
@ -0,0 +1,152 @@
|
|||||||
|
// Copyright (c) Tailscale Inc & AUTHORS
|
||||||
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
//go:build !ts_omit_kube
|
||||||
|
|
||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/peterbourgon/ff/v3/ffcli"
|
||||||
|
"golang.org/x/exp/slices"
|
||||||
|
"k8s.io/client-go/util/homedir"
|
||||||
|
"sigs.k8s.io/yaml"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
configureCmd.Subcommands = append(configureCmd.Subcommands, configureKubeconfigCmd)
|
||||||
|
}
|
||||||
|
|
||||||
|
var configureKubeconfigCmd = &ffcli.Command{
|
||||||
|
Name: "kubeconfig",
|
||||||
|
ShortHelp: "Configure kubeconfig to use Tailscale",
|
||||||
|
ShortUsage: "kubeconfig <hostname-or-fqdn>",
|
||||||
|
LongHelp: strings.TrimSpace(`
|
||||||
|
Run this command to configure your kubeconfig to use Tailscale for authentication to a Kubernetes cluster.
|
||||||
|
|
||||||
|
The hostname argument should be set to the Tailscale hostname of the peer running as an auth proxy in the cluster.
|
||||||
|
`),
|
||||||
|
FlagSet: (func() *flag.FlagSet {
|
||||||
|
fs := newFlagSet("kubeconfig")
|
||||||
|
return fs
|
||||||
|
})(),
|
||||||
|
Exec: runConfigureKubeconfig,
|
||||||
|
}
|
||||||
|
|
||||||
|
func runConfigureKubeconfig(ctx context.Context, args []string) error {
|
||||||
|
if len(args) != 1 {
|
||||||
|
return errors.New("unknown arguments")
|
||||||
|
}
|
||||||
|
hostOrFQDN := args[0]
|
||||||
|
|
||||||
|
st, err := localClient.Status(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if st.BackendState != "Running" {
|
||||||
|
return errors.New("Tailscale is not running")
|
||||||
|
}
|
||||||
|
targetFQDN, ok := nodeDNSNameFromArg(st, hostOrFQDN)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("no peer found with hostname %q", hostOrFQDN)
|
||||||
|
}
|
||||||
|
targetFQDN = strings.TrimSuffix(targetFQDN, ".")
|
||||||
|
confPath := filepath.Join(homedir.HomeDir(), ".kube", "config")
|
||||||
|
if err := setKubeconfigForPeer(targetFQDN, confPath); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
printf("kubeconfig configured for %q\n", hostOrFQDN)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// appendOrSetNamed finds a map with a "name" key matching name in dst, and
|
||||||
|
// replaces it with val. If no such map is found, val is appended to dst.
|
||||||
|
func appendOrSetNamed(dst []any, name string, val map[string]any) []any {
|
||||||
|
if got := slices.IndexFunc(dst, func(m any) bool {
|
||||||
|
if m, ok := m.(map[string]any); ok {
|
||||||
|
return m["name"] == name
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}); got != -1 {
|
||||||
|
dst[got] = val
|
||||||
|
} else {
|
||||||
|
dst = append(dst, val)
|
||||||
|
}
|
||||||
|
return dst
|
||||||
|
}
|
||||||
|
|
||||||
|
var errInvalidKubeconfig = errors.New("invalid kubeconfig")
|
||||||
|
|
||||||
|
func updateKubeconfig(cfgYaml []byte, fqdn string) ([]byte, error) {
|
||||||
|
var cfg map[string]any
|
||||||
|
if len(cfgYaml) > 0 {
|
||||||
|
if err := yaml.Unmarshal(cfgYaml, &cfg); err != nil {
|
||||||
|
return nil, errInvalidKubeconfig
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if cfg == nil {
|
||||||
|
cfg = map[string]any{
|
||||||
|
"apiVersion": "v1",
|
||||||
|
"kind": "Config",
|
||||||
|
}
|
||||||
|
} else if cfg["apiVersion"] != "v1" || cfg["kind"] != "Config" {
|
||||||
|
return nil, errInvalidKubeconfig
|
||||||
|
}
|
||||||
|
|
||||||
|
var clusters []any
|
||||||
|
if cm, ok := cfg["clusters"]; ok {
|
||||||
|
clusters = cm.([]any)
|
||||||
|
}
|
||||||
|
cfg["clusters"] = appendOrSetNamed(clusters, fqdn, map[string]any{
|
||||||
|
"name": fqdn,
|
||||||
|
"cluster": map[string]string{
|
||||||
|
"server": "https://" + fqdn,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
var users []any
|
||||||
|
if um, ok := cfg["users"]; ok {
|
||||||
|
users = um.([]any)
|
||||||
|
}
|
||||||
|
cfg["users"] = appendOrSetNamed(users, "tailscale-auth", map[string]any{
|
||||||
|
// We just need one of these, and can reuse it for all clusters.
|
||||||
|
"name": "tailscale-auth",
|
||||||
|
"user": map[string]string{
|
||||||
|
// We do not use the token, but if we do not set anything here
|
||||||
|
// kubectl will prompt for a username and password.
|
||||||
|
"token": "unused",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
var contexts []any
|
||||||
|
if cm, ok := cfg["contexts"]; ok {
|
||||||
|
contexts = cm.([]any)
|
||||||
|
}
|
||||||
|
cfg["contexts"] = appendOrSetNamed(contexts, fqdn, map[string]any{
|
||||||
|
"name": fqdn,
|
||||||
|
"context": map[string]string{
|
||||||
|
"cluster": fqdn,
|
||||||
|
"user": "tailscale-auth",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
cfg["current-context"] = fqdn
|
||||||
|
return yaml.Marshal(cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
func setKubeconfigForPeer(fqdn, filePath string) error {
|
||||||
|
b, err := os.ReadFile(filePath)
|
||||||
|
if err != nil && !os.IsNotExist(err) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
b, err = updateKubeconfig(b, fqdn)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.WriteFile(filePath, b, 0600)
|
||||||
|
}
|
@ -0,0 +1,196 @@
|
|||||||
|
// Copyright (c) Tailscale Inc & AUTHORS
|
||||||
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
//go:build !ts_omit_kube
|
||||||
|
|
||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/google/go-cmp/cmp"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestKubeconfig(t *testing.T) {
|
||||||
|
const fqdn = "foo.tail-scale.ts.net"
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
in string
|
||||||
|
want string
|
||||||
|
wantErr error
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "invalid-yaml",
|
||||||
|
in: `apiVersion: v1
|
||||||
|
kind: ,asdf`,
|
||||||
|
wantErr: errInvalidKubeconfig,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid-cfg",
|
||||||
|
in: `apiVersion: v1
|
||||||
|
kind: Pod`,
|
||||||
|
wantErr: errInvalidKubeconfig,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty",
|
||||||
|
in: "",
|
||||||
|
want: `apiVersion: v1
|
||||||
|
clusters:
|
||||||
|
- cluster:
|
||||||
|
server: https://foo.tail-scale.ts.net
|
||||||
|
name: foo.tail-scale.ts.net
|
||||||
|
contexts:
|
||||||
|
- context:
|
||||||
|
cluster: foo.tail-scale.ts.net
|
||||||
|
user: tailscale-auth
|
||||||
|
name: foo.tail-scale.ts.net
|
||||||
|
current-context: foo.tail-scale.ts.net
|
||||||
|
kind: Config
|
||||||
|
users:
|
||||||
|
- name: tailscale-auth
|
||||||
|
user:
|
||||||
|
token: unused`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "already-configured",
|
||||||
|
in: `apiVersion: v1
|
||||||
|
clusters:
|
||||||
|
- cluster:
|
||||||
|
server: https://foo.tail-scale.ts.net
|
||||||
|
name: foo.tail-scale.ts.net
|
||||||
|
contexts:
|
||||||
|
- context:
|
||||||
|
cluster: foo.tail-scale.ts.net
|
||||||
|
user: tailscale-auth
|
||||||
|
name: foo.tail-scale.ts.net
|
||||||
|
kind: Config
|
||||||
|
current-context: foo.tail-scale.ts.net
|
||||||
|
users:
|
||||||
|
- name: tailscale-auth
|
||||||
|
user:
|
||||||
|
token: unused`,
|
||||||
|
want: `apiVersion: v1
|
||||||
|
clusters:
|
||||||
|
- cluster:
|
||||||
|
server: https://foo.tail-scale.ts.net
|
||||||
|
name: foo.tail-scale.ts.net
|
||||||
|
contexts:
|
||||||
|
- context:
|
||||||
|
cluster: foo.tail-scale.ts.net
|
||||||
|
user: tailscale-auth
|
||||||
|
name: foo.tail-scale.ts.net
|
||||||
|
current-context: foo.tail-scale.ts.net
|
||||||
|
kind: Config
|
||||||
|
users:
|
||||||
|
- name: tailscale-auth
|
||||||
|
user:
|
||||||
|
token: unused`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "other-cluster",
|
||||||
|
in: `apiVersion: v1
|
||||||
|
clusters:
|
||||||
|
- cluster:
|
||||||
|
server: https://192.168.1.1:8443
|
||||||
|
name: some-cluster
|
||||||
|
contexts:
|
||||||
|
- context:
|
||||||
|
cluster: some-cluster
|
||||||
|
user: some-auth
|
||||||
|
name: some-cluster
|
||||||
|
kind: Config
|
||||||
|
current-context: some-cluster
|
||||||
|
users:
|
||||||
|
- name: some-auth
|
||||||
|
user:
|
||||||
|
token: asdfasdf`,
|
||||||
|
want: `apiVersion: v1
|
||||||
|
clusters:
|
||||||
|
- cluster:
|
||||||
|
server: https://192.168.1.1:8443
|
||||||
|
name: some-cluster
|
||||||
|
- cluster:
|
||||||
|
server: https://foo.tail-scale.ts.net
|
||||||
|
name: foo.tail-scale.ts.net
|
||||||
|
contexts:
|
||||||
|
- context:
|
||||||
|
cluster: some-cluster
|
||||||
|
user: some-auth
|
||||||
|
name: some-cluster
|
||||||
|
- context:
|
||||||
|
cluster: foo.tail-scale.ts.net
|
||||||
|
user: tailscale-auth
|
||||||
|
name: foo.tail-scale.ts.net
|
||||||
|
current-context: foo.tail-scale.ts.net
|
||||||
|
kind: Config
|
||||||
|
users:
|
||||||
|
- name: some-auth
|
||||||
|
user:
|
||||||
|
token: asdfasdf
|
||||||
|
- name: tailscale-auth
|
||||||
|
user:
|
||||||
|
token: unused`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "already-using-tailscale",
|
||||||
|
in: `apiVersion: v1
|
||||||
|
clusters:
|
||||||
|
- cluster:
|
||||||
|
server: https://bar.tail-scale.ts.net
|
||||||
|
name: bar.tail-scale.ts.net
|
||||||
|
contexts:
|
||||||
|
- context:
|
||||||
|
cluster: bar.tail-scale.ts.net
|
||||||
|
user: tailscale-auth
|
||||||
|
name: bar.tail-scale.ts.net
|
||||||
|
kind: Config
|
||||||
|
current-context: bar.tail-scale.ts.net
|
||||||
|
users:
|
||||||
|
- name: tailscale-auth
|
||||||
|
user:
|
||||||
|
token: unused`,
|
||||||
|
want: `apiVersion: v1
|
||||||
|
clusters:
|
||||||
|
- cluster:
|
||||||
|
server: https://bar.tail-scale.ts.net
|
||||||
|
name: bar.tail-scale.ts.net
|
||||||
|
- cluster:
|
||||||
|
server: https://foo.tail-scale.ts.net
|
||||||
|
name: foo.tail-scale.ts.net
|
||||||
|
contexts:
|
||||||
|
- context:
|
||||||
|
cluster: bar.tail-scale.ts.net
|
||||||
|
user: tailscale-auth
|
||||||
|
name: bar.tail-scale.ts.net
|
||||||
|
- context:
|
||||||
|
cluster: foo.tail-scale.ts.net
|
||||||
|
user: tailscale-auth
|
||||||
|
name: foo.tail-scale.ts.net
|
||||||
|
current-context: foo.tail-scale.ts.net
|
||||||
|
kind: Config
|
||||||
|
users:
|
||||||
|
- name: tailscale-auth
|
||||||
|
user:
|
||||||
|
token: unused`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got, err := updateKubeconfig([]byte(tt.in), fqdn)
|
||||||
|
if err != nil {
|
||||||
|
if err != tt.wantErr {
|
||||||
|
t.Fatalf("updateKubeconfig() error = %v, wantErr %v", err, tt.wantErr)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
} else if tt.wantErr != nil {
|
||||||
|
t.Fatalf("updateKubeconfig() error = %v, wantErr %v", err, tt.wantErr)
|
||||||
|
}
|
||||||
|
got = bytes.TrimSpace(got)
|
||||||
|
want := []byte(strings.TrimSpace(tt.want))
|
||||||
|
if d := cmp.Diff(want, got); d != "" {
|
||||||
|
t.Errorf("Kubeconfig() mismatch (-want +got):\n%s", d)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue