CKADを駆け抜けろ！kubectl短縮コマンドを体に叩き込むツールを作った

# TL; DR

```bash
# リソース名が長い！
$ k get replicasets
ERROR: too long! should be `kubectl get rs`

# オプション名が長い！
$ k get rs --namespace foo
ERROR: too long! should be `kubectl get rs -n foo`

# ヨシ！
$ k get rs -n foo
NAME              DESIRED   CURRENT   READY   AGE
nginx-76d6c9b8c   1         1         1       23h
```

https://github.com/Syuparn/kuberta

# はじめに

CKADは、k8sを利用するアプリケーション開発者向けの試験です。

https://training.linuxfoundation.org/ja/certification/certified-kubernetes-application-developer-ckad/

最大の特徴として、実際にコマンドでクラスターのリソースを操作する**実技試験**であることが挙げられます。
さらに、他の方の受験記を見るとどうやら「設問が多く試験時間がギリギリ」なようです。

私も目下勉強中ですが、教材の練習問題の時点で間に合わずヒーヒー言っています。

（時間が足りない...せめて `kubectl` のタイピングを1文字でも減らせれば...）
（そういえば、短縮コマンドがあったっけ...でもたくさんあって覚えられないよ...）

# 作ったもの

短縮コマンドが覚えられないなら、**体に叩き込みましょう**。短縮名を使わない限りエラーが出れば自然と身につくはずです。

https://github.com/Syuparn/kuberta

このコマンドは `kubectl` のラッパーとして動作し、長い引数を指定されるとエラーを吐きます。

## インストール

インストールは `go install` だけでokです。

```
$ go install github.com/syuparn/kuberta@latest
go: downloading github.com/syuparn/kuberta v0.1.0
```

続いて、エイリアスを登録します (`kubectl` は `k` で実行するのが貴族の嗜みです)。

```bash:.bashrc
alias k=kuberta
```

**だらだらコマンドを打っていると叱ってくれます**。

```bash
# リソース名が長い！😠
$ k get replicasets
ERROR: too long! should be `kubectl get rs`
# オプションが長い！😠
$ k get rs --namespace foo
ERROR: too long! should be `kubectl get rs -n foo`
# 😄
$ k get rs -n foo
NAME              DESIRED   CURRENT   READY   AGE
nginx-76d6c9b8c   1         1         1       23h
```

# 実装
## コマンドをラップ
まずは、`kubectl` をラップするCLIを作成します。今回はGoで開発しました。
仕組みは単純で、受け取ったコマンドライン引数を `exec.Command` で `kubectl` へ横流しします。

```go:exec.go
func Exec(args []string, w io.Writer) error {
	if len(args) == 0 {
		return Help(w)
	}
	// ...
	// コマンドライン引数をそのままkubectlへ渡す
	delegateToKubectl(args, w)

	return nil
}

func delegateToKubectl(args []string, w io.Writer) error {
	cmd := exec.Command("kubectl", args...)
	cmd.Stdout = w

	return cmd.Run()
}
```

## 引数バリデーションの仕組み
これだけではただの `kubectl` なので、コマンド実行前にバリデーションチェックを追加します。

### リソースの短縮名が使われているか

まずは、リソースの短縮名が使われているか確認します。

#### 短縮名の取得
リソースの正式名称と短縮名の対応は `kubectl api-resources` で確認できるので、この結果を愚直にパースします。

```bash
$ kubectl api-resources
NAME                              SHORTNAMES   APIVERSION                             NAMESPACED   KIND
bindings                                       v1                                     true         Binding
componentstatuses                 cs           v1                                     false        ComponentStatus
configmaps                        cm           v1                                     true         ConfigMap
endpoints                         ep           v1                                     true         Endpoints
events                            ev           v1                                     true         Event
limitranges                       limits       v1                                     true         LimitRange
namespaces                        ns           v1                                     false        Namespace
nodes                             no           v1                                     false        Node
...
```

コードが死ぬほど汚いです ~~が、ネタコマンドなのでご愛敬~~

```go:resource_alias.go
func GetResourceAliasMap() (map[string]string, error) {
	cmd := exec.Command("kubectl", "api-resources", "--no-headers")
	b, err := cmd.Output()
	if err != nil {
		return nil, fmt.Errorf("failed to get resource aliases: %w", err)
	}

	// 各行、各フィールドでsplitし[][]stringに整形
	records := splitOutput(string(b))

	// {名称: 短縮名} 形式のマップを生成
	return parseResourceAliasMap(records)
}

func splitOutput(b string) [][]string {
	spaces := regexp.MustCompile(`\s+`)

	lines := strings.Split(b, "\n")
	return lo.Map(lines, func(line string, _ int) []string {
		return spaces.Split(line, -1)
	})
}

func parseResourceAliasMap(records [][]string) (map[string]string, error) {
	m := map[string]string{}

	for _, record := range records {

		if len(record) < 5 {
			// 短縮名が定義されていないのでスキップ
			continue
		}

		shortNames := strings.Split(record[1], ",")
		name := record[0]
		kind := record[4]

		for _, shortName := range shortNames {
			// 複数形
			m[name] = shortName

			// 単数形
			m[strings.ToLower(kind)] = shortName
		}
	}

	return m, nil
}
```

#### バリデーションチェック

次に、渡された引数が短縮名に置換可能かどうかを調べます。

```go:validate.go
func ValidateResourceNames(args []string, aliases map[string]string) error {
	expectedArgs := make([]string, len(args))
	copy(expectedArgs, args)

	for i, arg := range args {
		// 引数を短縮名に置換可能であれば置換
		if alias, ok := aliases[arg]; ok {
			expectedArgs[i] = alias
		}

		// 完全一致ではなくスラッシュの手前にリソース名を指定する場合（例 `service/foo`）もチェック
		prefix := strings.Split(arg, "/")[0]
		if alias, ok := aliases[prefix]; ok {
			expectedArgs[i] = strings.Replace(arg, prefix, alias, 1)
		}
	}

	// 1つでも置換されていれば、短縮名を使い忘れているのでエラー
	if !reflect.DeepEqual(args, expectedArgs) {
		return fmt.Errorf("too long! should be `kubectl %s`", strings.Join(expectedArgs, " "))
	}

	return nil
}
```

### オプションの別名が使われているか

続いて、オプション `--hoge` の短縮形が使われているか確認します。

#### 短縮オプションの取得

指定されたコマンドの後ろに `-h` を付けて、ヘルプを出力します。
`Options:` 以下にオプションの説明が表示されます。

```bash
$ k get po -h
Display one or many resources.

 Prints a table of the most important information about the specified resources. You can filter the list using a label
selector and the --selector flag. If the desired resource type is namespaced you will only see results in your current
namespace unless you pass --all-namespaces.
...

Options:
    -A, --all-namespaces=false:
        If present, list the requested object(s) across all namespaces. Namespace in current context is ignored even
        if specified with --namespace.

    --allow-missing-template-keys=true:
        If true, ignore any errors in templates when a field or map key is missing in the template. Only applies to
        golang and jsonpath output formats.

    --chunk-size=500:
        Return large lists in chunks rather than all at once. Pass 0 to disable. This flag is beta and may change in
        the future.

    --field-selector='':
        Selector (field query) to filter on, supports '=', '==', and '!='.(e.g. --field-selector
        key1=value1,key2=value2). The server only supports a limited number of field queries per type.

    -f, --filename=[]:
        Filename, directory, or URL to files identifying the resource to get from a server.
...
```

また、全コマンドで共通して使えるコマンドは `kubectl options` で確認できます。

```bash
$ kubectl options
The following options can be passed to any command:

    --add-dir-header=false:
        If true, adds the file directory to the header of the log messages (DEPRECATED: will be removed in a future
        release, see
        https://github.com/kubernetes/enhancements/tree/master/keps/sig-instrumentation/2845-deprecate-klog-specific-flags-in-k8s-components)

    --alsologtostderr=false:
        log to standard error as well as files (no effect when -logtostderr=true) (DEPRECATED: will be removed in a
        future release, see
        https://github.com/kubernetes/enhancements/tree/master/keps/sig-instrumentation/2845-deprecate-klog-specific-flags-in-k8s-components)

    --as='':
        Username to impersonate for the operation. User could be a regular user or a service account in a namespace.
```

オプションのパースはリソース名よりもごり押しです。`-` で始まる行をパースし、{正式なオプション: 短縮オプション} の対応マップを作成します。

```:抜き出してパースしたい行
    -f, --filename=[]:
```

（汚いのでコード略。詳細はこちら）

https://github.com/Syuparn/kuberta/blob/main/option_alias.go#L28

#### バリデーションチェック

リソース名のときと同じように、引数を短縮オプションに置換可能であればエラーを発生させます。

最後に、`kubectl` を呼び出す直前にこれらのバリデーションチェックを入れれば完成です。

```go:exec.go
func Exec(args []string, w io.Writer) error {
	if len(args) == 0 {
		return Help(w)
	}

	// リソース名チェック
	resourceAliases, err := GetResourceAliasMap()
	if err != nil {
		return fmt.Errorf("FATAL: failed to create resource alias map: %w", err)
	}
	err = ValidateResourceNames(args, resourceAliases)
	if err != nil {
		fmt.Fprintf(w, "ERROR: %s\n", err)
		return nil
	}

	// オプションチェック
	optionAliases, err := GetOptionAliasMap(args)
	if err != nil {
		return fmt.Errorf("FATAL: failed to create option alias map: %w", err)
	}
	err = ValidateOptions(args, optionAliases)
	if err != nil {
		fmt.Fprintf(w, "ERROR: %s\n", err)
		return nil
	}

	delegateToKubectl(args, w)

	return nil
}
```

# おわりに

**こんなの作ってる暇があったらCKADの勉強しろ**