@@ -18,14 +18,20 @@ package plugin
1818
1919import (
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.
136145func (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