Skip to content

Commit 1f8ef3a

Browse files
committed
Simple kubectl plugin for plan functionality
We would like to modularize kpt into composable functionality. Start with creating a kubectl plugin that can hold "plan" functionality. Signed-off-by: justinsb <justinsb@google.com>
1 parent 4c4cf98 commit 1f8ef3a

File tree

20 files changed

+1043
-0
lines changed

20 files changed

+1043
-0
lines changed

plugins/cmd/kubectl-plan/README.md

+7
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
# kubectl-plan
2+
3+
kubectl-plan is an experimental kubectl plugin that dry-runs
4+
apply operations and shows the changes in an easy to read format.
5+
6+
It is still under development and highly experimental, it should
7+
not be treated as stable.

plugins/cmd/kubectl-plan/main.go

+35
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
// Copyright 2023 The kpt Authors
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package main
16+
17+
import (
18+
"context"
19+
"fmt"
20+
"os"
21+
22+
"github.com/GoogleContainerTools/kpt/plugins/pkg/cmd/plan"
23+
)
24+
25+
func main() {
26+
if err := Run(context.Background()); err != nil {
27+
fmt.Fprintf(os.Stderr, "%v\n", err)
28+
os.Exit(1)
29+
}
30+
}
31+
32+
func Run(ctx context.Context) error {
33+
root := plan.NewCommand()
34+
return root.ExecuteContext(ctx)
35+
}

plugins/go.mod

+60
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
module github.com/GoogleContainerTools/kpt/plugins
2+
3+
go 1.21.3
4+
5+
require (
6+
github.com/google/go-cmp v0.5.9
7+
github.com/spf13/cobra v1.8.0
8+
k8s.io/apimachinery v0.28.4
9+
k8s.io/client-go v0.28.4
10+
k8s.io/klog/v2 v2.100.1
11+
sigs.k8s.io/controller-runtime v0.13.0
12+
sigs.k8s.io/kubebuilder-declarative-pattern v0.13.0
13+
sigs.k8s.io/kubebuilder-declarative-pattern/mockkubeapiserver v0.0.0-20231030230424-f6a5c89244f2
14+
sigs.k8s.io/kustomize/kyaml v0.15.0
15+
)
16+
17+
require (
18+
github.com/davecgh/go-spew v1.1.1 // indirect
19+
github.com/emicklei/go-restful/v3 v3.10.1 // indirect
20+
github.com/fsnotify/fsnotify v1.6.0 // indirect
21+
github.com/go-errors/errors v1.4.2 // indirect
22+
github.com/go-logr/logr v1.2.4 // indirect
23+
github.com/go-openapi/jsonpointer v0.19.6 // indirect
24+
github.com/go-openapi/jsonreference v0.20.2 // indirect
25+
github.com/go-openapi/swag v0.22.3 // indirect
26+
github.com/gogo/protobuf v1.3.2 // indirect
27+
github.com/golang/protobuf v1.5.3 // indirect
28+
github.com/google/gnostic-models v0.6.8 // indirect
29+
github.com/google/gofuzz v1.2.0 // indirect
30+
github.com/google/uuid v1.3.0 // indirect
31+
github.com/imdario/mergo v0.3.13 // indirect
32+
github.com/inconshreveable/mousetrap v1.1.0 // indirect
33+
github.com/josharian/intern v1.0.0 // indirect
34+
github.com/json-iterator/go v1.1.12 // indirect
35+
github.com/mailru/easyjson v0.7.7 // indirect
36+
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
37+
github.com/modern-go/reflect2 v1.0.2 // indirect
38+
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
39+
github.com/spf13/pflag v1.0.5 // indirect
40+
github.com/stretchr/testify v1.8.4 // indirect
41+
go.uber.org/atomic v1.10.0 // indirect
42+
go.uber.org/multierr v1.11.0 // indirect
43+
golang.org/x/net v0.17.0 // indirect
44+
golang.org/x/oauth2 v0.8.0 // indirect
45+
golang.org/x/sys v0.13.0 // indirect
46+
golang.org/x/term v0.13.0 // indirect
47+
golang.org/x/text v0.13.0 // indirect
48+
golang.org/x/time v0.3.0 // indirect
49+
google.golang.org/appengine v1.6.7 // indirect
50+
google.golang.org/protobuf v1.31.0 // indirect
51+
gopkg.in/inf.v0 v0.9.1 // indirect
52+
gopkg.in/yaml.v2 v2.4.0 // indirect
53+
gopkg.in/yaml.v3 v3.0.1 // indirect
54+
k8s.io/api v0.28.4 // indirect
55+
k8s.io/kube-openapi v0.0.0-20230717233707-2695361300d9 // indirect
56+
k8s.io/utils v0.0.0-20230505201702-9f6742963106 // indirect
57+
sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd // indirect
58+
sigs.k8s.io/structured-merge-diff/v4 v4.2.3 // indirect
59+
sigs.k8s.io/yaml v1.3.0 // indirect
60+
)

plugins/go.sum

+190
Large diffs are not rendered by default.
+121
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
// Copyright 2023 The kpt Authors
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package plan
16+
17+
import (
18+
"context"
19+
"encoding/json"
20+
"fmt"
21+
22+
"k8s.io/apimachinery/pkg/api/meta"
23+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
24+
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
25+
"k8s.io/apimachinery/pkg/runtime/schema"
26+
"k8s.io/apimachinery/pkg/types"
27+
"k8s.io/client-go/dynamic"
28+
"k8s.io/client-go/rest"
29+
"sigs.k8s.io/kubebuilder-declarative-pattern/pkg/restmapper"
30+
)
31+
32+
// ClusterTarget supports actions against a running kubernetes cluster.
33+
type ClusterTarget struct {
34+
client dynamic.Interface
35+
restMapper resourceFinder
36+
}
37+
38+
func NewClusterTarget(restConfig *rest.Config) (*ClusterTarget, error) {
39+
client, err := dynamic.NewForConfig(restConfig)
40+
if err != nil {
41+
return nil, fmt.Errorf("creating kubernetes client: %w", err)
42+
}
43+
44+
restMapper, err := restmapper.NewControllerRESTMapper(restConfig)
45+
if err != nil {
46+
return nil, fmt.Errorf("building REST mapper: %w", err)
47+
}
48+
49+
return &ClusterTarget{
50+
client: client,
51+
restMapper: restMapper,
52+
}, nil
53+
}
54+
55+
type resourceFinder interface {
56+
RESTMapping(gk schema.GroupKind, versions ...string) (*meta.RESTMapping, error)
57+
}
58+
59+
// ResourceForGVK gets the GVR / Scope for the specified object.
60+
func (c *ClusterTarget) ResourceForGVK(ctx context.Context, gvk schema.GroupVersionKind) (*clusterResourceTarget, error) {
61+
mapping, err := c.restMapper.RESTMapping(gvk.GroupKind(), gvk.Version)
62+
if err != nil {
63+
return nil, fmt.Errorf("cannot get RESTMapping for %v: %w", gvk, err)
64+
}
65+
return &clusterResourceTarget{info: mapping, client: c.client}, nil
66+
}
67+
68+
// Apply is a wrapper around applying changes to a live cluster.
69+
func (c *clusterResourceTarget) Apply(ctx context.Context, obj *unstructured.Unstructured, options metav1.PatchOptions) (*unstructured.Unstructured, error) {
70+
target, err := c.buildResource(ctx, obj)
71+
if err != nil {
72+
return nil, err
73+
}
74+
75+
j, err := json.Marshal(obj)
76+
if err != nil {
77+
return nil, fmt.Errorf("error marshalling object to JSON: %w", err)
78+
}
79+
80+
// Apply with server-side apply (specified with ApplyPatchType)
81+
patched, err := target.Patch(ctx, obj.GetName(), types.ApplyPatchType, j, options)
82+
if err != nil {
83+
return nil, fmt.Errorf("server-side-apply failed: %w", err)
84+
}
85+
86+
return patched, nil
87+
}
88+
89+
// buildResource creates the dynamic ResourceInterface for the object
90+
func (c *clusterResourceTarget) buildResource(ctx context.Context, obj *unstructured.Unstructured) (dynamic.ResourceInterface, error) {
91+
if c.info.Scope == meta.RESTScopeRoot {
92+
return c.client.Resource(c.info.Resource), nil
93+
} else {
94+
namespace := obj.GetNamespace()
95+
if namespace == "" {
96+
return nil, fmt.Errorf("namespace was not set, but is required for namespace-scoped objects")
97+
}
98+
return c.client.Resource(c.info.Resource).Namespace(namespace), nil
99+
}
100+
}
101+
102+
// Get reads the current version of an object.
103+
func (c *clusterResourceTarget) Get(ctx context.Context, obj *unstructured.Unstructured, options metav1.GetOptions) (*unstructured.Unstructured, error) {
104+
target, err := c.buildResource(ctx, obj)
105+
if err != nil {
106+
return nil, err
107+
}
108+
109+
existing, err := target.Get(ctx, obj.GetName(), options)
110+
if err != nil {
111+
return nil, fmt.Errorf("get failed: %w", err)
112+
}
113+
114+
return existing, nil
115+
}
116+
117+
type clusterResourceTarget struct {
118+
info *meta.RESTMapping
119+
120+
client dynamic.Interface
121+
}

plugins/pkg/cmd/plan/golden_test.go

+115
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
// Copyright 2023 The kpt Authors
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package plan
16+
17+
import (
18+
"bytes"
19+
"context"
20+
"os"
21+
"path/filepath"
22+
"testing"
23+
24+
"github.com/google/go-cmp/cmp"
25+
"k8s.io/client-go/rest"
26+
"k8s.io/klog/v2"
27+
"sigs.k8s.io/kubebuilder-declarative-pattern/mockkubeapiserver"
28+
"sigs.k8s.io/yaml"
29+
)
30+
31+
func TestPlanner(t *testing.T) {
32+
k8s, err := mockkubeapiserver.NewMockKubeAPIServer(":0")
33+
if err != nil {
34+
t.Fatalf("error building mock kube-apiserver: %v", err)
35+
}
36+
defer func() {
37+
if err := k8s.Stop(); err != nil {
38+
t.Fatalf("error closing mock kube-apiserver: %v", err)
39+
}
40+
}()
41+
addr, err := k8s.StartServing()
42+
if err != nil {
43+
t.Errorf("error starting mock kube-apiserver: %v", err)
44+
}
45+
klog.Infof("mock kubeapiserver will listen on %v", addr)
46+
47+
restConfig := &rest.Config{
48+
Host: addr.String(),
49+
}
50+
51+
dir := "testdata"
52+
files, err := os.ReadDir(dir)
53+
if err != nil {
54+
t.Fatalf("failed to read directory %q: %v", dir, err)
55+
}
56+
for _, file := range files {
57+
p := filepath.Join(dir, file.Name())
58+
if !file.IsDir() {
59+
t.Errorf("found non-directory %q", p)
60+
continue
61+
}
62+
63+
t.Run(file.Name(), func(t *testing.T) {
64+
p := filepath.Join(dir, file.Name())
65+
66+
ctx := context.Background()
67+
68+
objects, err := loadObjectsFromFilesystem(filepath.Join(p, "apply.yaml"))
69+
if err != nil {
70+
t.Fatalf("error loading objects: %v", err)
71+
}
72+
73+
target, err := NewClusterTarget(restConfig)
74+
if err != nil {
75+
t.Fatalf("error building target: %v", err)
76+
}
77+
78+
planner := &Planner{}
79+
80+
plan, err := planner.BuildPlan(ctx, objects, target)
81+
if err != nil {
82+
t.Fatalf("error from BuildPlan: %v", err)
83+
}
84+
85+
actual, err := yaml.Marshal(plan)
86+
if err != nil {
87+
t.Fatalf("yaml.Marshal failed: %v", err)
88+
}
89+
CompareGoldenFile(t, filepath.Join(p, "plan.yaml"), actual)
90+
})
91+
}
92+
}
93+
94+
func CompareGoldenFile(t *testing.T, p string, got []byte) {
95+
if os.Getenv("WRITE_GOLDEN_OUTPUT") != "" {
96+
// Short-circuit when the output is correct
97+
b, err := os.ReadFile(p)
98+
if err == nil && bytes.Equal(b, got) {
99+
return
100+
}
101+
102+
if err := os.WriteFile(p, got, 0644); err != nil {
103+
t.Fatalf("failed to write golden output %s: %v", p, err)
104+
}
105+
t.Errorf("wrote output to %s", p)
106+
} else {
107+
want, err := os.ReadFile(p)
108+
if err != nil {
109+
t.Fatalf("failed to read file %q: %v", p, err)
110+
}
111+
if diff := cmp.Diff(string(want), string(got)); diff != "" {
112+
t.Errorf("unexpected diff in %s: %s", p, diff)
113+
}
114+
}
115+
}

0 commit comments

Comments
 (0)