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