Skip to content

Commit dbf30a5

Browse files
committed
fix: prevent dry-run from creating resources on consumer cluster
Signed-off-by: olalekan odukoya <odukoyaonline@gmail.com>
1 parent 53d8621 commit dbf30a5

8 files changed

Lines changed: 737 additions & 41 deletions

File tree

cli/pkg/kubectl/base/dryrun.go

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
/*
2+
Copyright 2025 The Kube Bind Authors.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package base
18+
19+
import "time"
20+
21+
// DryRunAssets represents the saved assets from a dry-run
22+
type DryRunAssets struct {
23+
SessionID string `json:"sessionID"`
24+
UserID string `json:"userID"`
25+
ServerURL string `json:"serverURL"`
26+
ClusterID string `json:"clusterID,omitempty"`
27+
CreatedAt time.Time `json:"createdAt"`
28+
Kubeconfig string `json:"kubeconfig"`
29+
RequestFiles []string `json:"requestFiles"`
30+
}

cli/pkg/kubectl/bind-apiservice/plugin/bind.go

Lines changed: 201 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -18,14 +18,20 @@ package plugin
1818

1919
import (
2020
"context"
21+
"encoding/json"
2122
"errors"
2223
"fmt"
24+
"io"
25+
"os"
26+
"path/filepath"
2327
"strings"
28+
"time"
2429

2530
"github.com/spf13/cobra"
2631
corev1 "k8s.io/api/core/v1"
2732
apierrors "k8s.io/apimachinery/pkg/api/errors"
2833
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
34+
"k8s.io/apimachinery/pkg/runtime"
2935
"k8s.io/apimachinery/pkg/util/sets"
3036
"k8s.io/cli-runtime/pkg/genericclioptions"
3137
"k8s.io/cli-runtime/pkg/printers"
@@ -35,6 +41,7 @@ import (
3541
clientcmdapi "k8s.io/client-go/tools/clientcmd/api"
3642
"k8s.io/component-base/logs"
3743
logsv1 "k8s.io/component-base/logs/api/v1"
44+
"sigs.k8s.io/yaml"
3845

3946
"github.com/kube-bind/kube-bind/cli/pkg/kubectl/base"
4047
kubebindv1alpha2 "github.com/kube-bind/kube-bind/sdk/apis/kubebind/v1alpha2"
@@ -55,6 +62,7 @@ type BindAPIServiceOptions struct {
5562
remoteKubeconfigName string
5663
remoteNamespace string
5764
file string
65+
FromDryRun string
5866

5967
// skipKonnector skips the deployment of the konnector.
6068
SkipKonnector bool
@@ -96,7 +104,8 @@ func (b *BindAPIServiceOptions) AddCmdFlags(cmd *cobra.Command) {
96104
cmd.Flags().BoolVar(&b.SkipKonnector, "skip-konnector", b.SkipKonnector, "Skip the deployment of the konnector")
97105
cmd.Flags().BoolVar(&b.DowngradeKonnector, "downgrade-konnector", b.DowngradeKonnector, "Downgrade the konnector to the version of the kubectl-bind-apiservice binary")
98106
cmd.Flags().StringVar(&b.KonnectorImageOverride, "konnector-image", b.KonnectorImageOverride, "The konnector image to use")
99-
cmd.Flags().BoolVarP(&b.DryRun, "dry-run", "d", b.DryRun, "If true, only print the requests that would be sent to the service provider after authentication, without actually binding.")
107+
cmd.Flags().BoolVarP(&b.DryRun, "dry-run", "d", b.DryRun, "If true, only print the requests that would be sent to the service provider after authentication, without creating any resources on the consumer cluster.")
108+
cmd.Flags().StringVar(&b.FromDryRun, "from-dry-run", b.FromDryRun, "Apply bindings from a previous dry-run session using the session ID)")
100109
cmd.Flags().MarkHidden("konnector-image") //nolint:errcheck
101110
cmd.Flags().BoolVar(&b.NoBanner, "no-banner", b.NoBanner, "Do not show the red banner")
102111
cmd.Flags().MarkHidden("no-banner") //nolint:errcheck
@@ -134,15 +143,15 @@ func (b *BindAPIServiceOptions) Complete(args []string) error {
134143

135144
// Validate validates the BindAPIServiceOptions are complete and usable.
136145
func (b *BindAPIServiceOptions) Validate() error {
137-
if b.file == "" && b.Template == "" {
138-
return errors.New("file, template-name or --file are required")
146+
if b.file == "" && b.Template == "" && b.FromDryRun == "" {
147+
return errors.New("file, template-name or --from-dry-run are required")
139148
}
140149

141150
if allowed := sets.NewString(b.Print.AllowedFormats()...); *b.Print.OutputFormat != "" && !allowed.Has(*b.Print.OutputFormat) {
142151
return fmt.Errorf("invalid output format %q (allowed: %s)", *b.Print.OutputFormat, strings.Join(allowed.List(), ", "))
143152
}
144153

145-
if b.Template == "" {
154+
if b.FromDryRun == "" && b.Template == "" {
146155
if (b.remoteKubeconfigNamespace == "" && b.remoteKubeconfigName != "") ||
147156
(b.remoteKubeconfigNamespace != "" && b.remoteKubeconfigName == "") {
148157
return errors.New("remote-kubeconfig-namespace and remote-kubeconfig-name must be specified together")
@@ -151,8 +160,7 @@ func (b *BindAPIServiceOptions) Validate() error {
151160
return errors.New("remote-kubeconfig or remote-kubeconfig-namespace and remote-kubeconfig-name are required")
152161
}
153162
}
154-
// Name is required unless reading from file, where name will be read from the file.
155-
if b.Name == "" && b.file == "" {
163+
if b.Name == "" && b.file == "" && b.FromDryRun == "" && b.Template == "" {
156164
return errors.New("name is required")
157165
}
158166

@@ -195,20 +203,37 @@ func (b *BindAPIServiceOptions) Run(ctx context.Context) error {
195203
RemoteKubeconfigName: b.remoteKubeconfigName,
196204
RemoteNamespace: b.remoteNamespace,
197205
File: b.file,
206+
DryRun: b.DryRun,
198207
}
199208
binder := NewBinder(config, binderOpts)
200209

201210
var bindings []*kubebindv1alpha2.APIServiceBinding
202-
if b.Template != "" {
211+
212+
switch {
213+
case b.FromDryRun != "":
214+
bindings, err = binder.BindFromDryRun(ctx, b.FromDryRun)
215+
if err != nil {
216+
return fmt.Errorf("failed to apply dry-run assets: %w", err)
217+
}
218+
case b.Template != "":
203219
r, err := b.bindTemplate(ctx)
204220
if err != nil {
205221
return err
206222
}
223+
224+
if b.DryRun {
225+
return b.handleTemplateDryRun(r.response)
226+
}
227+
207228
bindings, err = binder.BindFromResponse(ctx, r.response)
208229
if err != nil {
209230
return fmt.Errorf("failed to create bindings: %w", err)
210231
}
211-
} else if b.file != "" {
232+
case b.file != "":
233+
if b.DryRun {
234+
return b.handleFileDryRun()
235+
}
236+
212237
bindings, err = binder.BindFromFile(ctx)
213238
if err != nil {
214239
return fmt.Errorf("failed to create bindings: %w", err)
@@ -320,9 +345,14 @@ func (b *BindAPIServiceOptions) bindTemplate(ctx context.Context) (*bindTemplate
320345
return nil, fmt.Errorf("failed to create authenticated client: %w", err)
321346
}
322347

348+
name := b.Name
349+
if name == "" {
350+
name = b.Template
351+
}
352+
323353
bindRequest := &kubebindv1alpha2.BindableResourcesRequest{
324354
ObjectMeta: metav1.ObjectMeta{
325-
Name: b.Name,
355+
Name: name,
326356
},
327357
TemplateRef: kubebindv1alpha2.APIServiceExportTemplateRef{
328358
Name: b.Template,
@@ -338,6 +368,14 @@ func (b *BindAPIServiceOptions) bindTemplate(ctx context.Context) (*bindTemplate
338368
return nil, fmt.Errorf("unexpected response: authentication.oauth2CodeGrant is nil")
339369
}
340370

371+
if b.DryRun {
372+
return &bindTemplateResult{
373+
response: bindResponse,
374+
namespace: "",
375+
name: "",
376+
}, nil
377+
}
378+
341379
err = b.ensureClientSideNamespaceExists(ctx, config)
342380
if err != nil {
343381
return nil, fmt.Errorf("failed to ensure kube-bind namespace exists: %w", err)
@@ -367,3 +405,157 @@ func (b *BindAPIServiceOptions) bindTemplate(ctx context.Context) (*bindTemplate
367405
name: secret.Name,
368406
}, nil
369407
}
408+
409+
// handleTemplateDryRun prints the requests and saves assets locally without touching the consumer cluster
410+
func (b *BindAPIServiceOptions) handleTemplateDryRun(response *kubebindv1alpha2.BindingResourceResponse) error {
411+
fmt.Fprintf(b.Options.IOStreams.ErrOut, "Dry-run mode: outputting APIServiceExport requests\n\n")
412+
413+
if response.Authentication.OAuth2CodeGrant == nil {
414+
return fmt.Errorf("authentication data missing in response")
415+
}
416+
sessionID := response.Authentication.OAuth2CodeGrant.SessionID
417+
418+
if err := b.printRequests(response.Requests); err != nil {
419+
return err
420+
}
421+
422+
if err := b.saveBindingAssets(response, sessionID); err != nil {
423+
return fmt.Errorf("failed to save binding assets: %w", err)
424+
}
425+
426+
fmt.Fprintf(b.Options.IOStreams.ErrOut, "\n✅ Dry-run complete. No changes made to consumer cluster.\n")
427+
return nil
428+
}
429+
430+
// saveBindingAssets saves the binding response to local storage for later use
431+
func (b *BindAPIServiceOptions) saveBindingAssets(response *kubebindv1alpha2.BindingResourceResponse, sessionID string) error {
432+
if response.Authentication.OAuth2CodeGrant == nil {
433+
return fmt.Errorf("authentication data missing")
434+
}
435+
436+
userID := response.Authentication.OAuth2CodeGrant.ID
437+
438+
homeDir, err := os.UserHomeDir()
439+
if err != nil {
440+
return fmt.Errorf("failed to get home directory: %w", err)
441+
}
442+
443+
dryRunDir := filepath.Join(homeDir, ".kube-bind", "dry-run", sessionID)
444+
if err := os.MkdirAll(dryRunDir, 0700); err != nil {
445+
return fmt.Errorf("failed to create dry-run directory: %w", err)
446+
}
447+
448+
kubeconfigPath := filepath.Join(dryRunDir, "kubeconfig.yaml")
449+
if err := os.WriteFile(kubeconfigPath, response.Kubeconfig, 0600); err != nil {
450+
return fmt.Errorf("failed to save kubeconfig: %w", err)
451+
}
452+
453+
requestFiles := make([]string, 0, len(response.Requests))
454+
for i, request := range response.Requests {
455+
requestPath := filepath.Join(dryRunDir, fmt.Sprintf("request-%d.yaml", i))
456+
457+
var obj map[string]interface{}
458+
if err := json.Unmarshal(request.Raw, &obj); err != nil {
459+
return fmt.Errorf("failed to unmarshal request %d: %w", i, err)
460+
}
461+
462+
yamlData, err := yaml.Marshal(obj)
463+
if err != nil {
464+
return fmt.Errorf("failed to marshal request %d to YAML: %w", i, err)
465+
}
466+
467+
if err := os.WriteFile(requestPath, yamlData, 0600); err != nil {
468+
return fmt.Errorf("failed to save request %d: %w", i, err)
469+
}
470+
471+
requestFiles = append(requestFiles, requestPath)
472+
}
473+
474+
assets := &base.DryRunAssets{
475+
SessionID: sessionID,
476+
UserID: userID,
477+
ServerURL: b.ServerName,
478+
ClusterID: b.ClusterName,
479+
CreatedAt: time.Now(),
480+
Kubeconfig: kubeconfigPath,
481+
RequestFiles: requestFiles,
482+
}
483+
484+
metadataPath := filepath.Join(dryRunDir, "metadata.json")
485+
metadataData, err := json.MarshalIndent(assets, "", " ")
486+
if err != nil {
487+
return fmt.Errorf("failed to marshal metadata: %w", err)
488+
}
489+
490+
if err := os.WriteFile(metadataPath, metadataData, 0600); err != nil {
491+
return fmt.Errorf("failed to save metadata: %w", err)
492+
}
493+
494+
fmt.Fprintf(b.Options.IOStreams.ErrOut, "\n💾 Dry-run assets saved to: %s\n", dryRunDir)
495+
fmt.Fprintf(b.Options.IOStreams.ErrOut, " Session ID: %s\n", sessionID)
496+
fmt.Fprintf(b.Options.IOStreams.ErrOut, " To apply later: kubectl bind apiservice --from-dry-run %s\n", sessionID)
497+
498+
return nil
499+
}
500+
501+
// handleFileDryRun prints the request from file without touching the consumer cluster
502+
func (b *BindAPIServiceOptions) handleFileDryRun() error {
503+
fmt.Fprintf(b.Options.IOStreams.ErrOut, "Dry-run mode: outputting APIServiceExport request from file\n\n")
504+
505+
var data []byte
506+
var err error
507+
if b.file == "-" {
508+
data, err = io.ReadAll(b.Options.IOStreams.In)
509+
if err != nil {
510+
return fmt.Errorf("failed to read from stdin: %w", err)
511+
}
512+
} else {
513+
data, err = os.ReadFile(b.file)
514+
if err != nil {
515+
return fmt.Errorf("failed to read file %s: %w", b.file, err)
516+
}
517+
}
518+
519+
var request kubebindv1alpha2.APIServiceExportRequest
520+
if err := yaml.Unmarshal(data, &request); err != nil {
521+
return fmt.Errorf("failed to unmarshal request: %w", err)
522+
}
523+
524+
if err := b.printRequest(&request, data); err != nil {
525+
return err
526+
}
527+
528+
fmt.Fprintf(b.Options.IOStreams.ErrOut, "\n✅ Dry-run complete. No changes made to consumer cluster.\n")
529+
return nil
530+
}
531+
532+
// printRequest prints a single APIServiceExportRequest using the configured printer
533+
func (b *BindAPIServiceOptions) printRequest(request *kubebindv1alpha2.APIServiceExportRequest, rawData []byte) error {
534+
if b.printer != nil {
535+
if err := b.printer.PrintObj(request, b.Options.IOStreams.Out); err != nil {
536+
return fmt.Errorf("failed to print request: %w", err)
537+
}
538+
} else {
539+
fmt.Fprintf(b.Options.IOStreams.Out, "%s", rawData)
540+
}
541+
return nil
542+
}
543+
544+
// printRequests prints multiple requests from a response, handling YAML separators
545+
func (b *BindAPIServiceOptions) printRequests(requests []runtime.RawExtension) error {
546+
for i, request := range requests {
547+
if i > 0 && b.Print.OutputFormat != nil && *b.Print.OutputFormat == "yaml" {
548+
fmt.Fprintf(b.Options.IOStreams.Out, "---\n")
549+
}
550+
551+
var apiRequest kubebindv1alpha2.APIServiceExportRequest
552+
if err := json.Unmarshal(request.Raw, &apiRequest); err != nil {
553+
return fmt.Errorf("failed to unmarshal request %d: %w", i, err)
554+
}
555+
556+
if err := b.printRequest(&apiRequest, request.Raw); err != nil {
557+
return fmt.Errorf("failed to print request %d: %w", i, err)
558+
}
559+
}
560+
return nil
561+
}

0 commit comments

Comments
 (0)