istio.io/istio@v0.0.0-20240520182934-d79c90f27776/istioctl/pkg/tag/tag.go (about) 1 // Copyright Istio 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 tag 16 17 import ( 18 "cmp" 19 "context" 20 "encoding/json" 21 "fmt" 22 "io" 23 "strings" 24 "text/tabwriter" 25 26 "github.com/spf13/cobra" 27 "k8s.io/client-go/kubernetes" 28 "k8s.io/client-go/rest" 29 30 "istio.io/istio/istioctl/pkg/cli" 31 "istio.io/istio/istioctl/pkg/util" 32 "istio.io/istio/istioctl/pkg/util/formatting" 33 "istio.io/istio/pkg/config/analysis" 34 "istio.io/istio/pkg/config/analysis/analyzers/webhook" 35 "istio.io/istio/pkg/config/analysis/diag" 36 "istio.io/istio/pkg/config/analysis/local" 37 "istio.io/istio/pkg/config/resource" 38 "istio.io/istio/pkg/kube" 39 "istio.io/istio/pkg/maps" 40 "istio.io/istio/pkg/slices" 41 ) 42 43 const ( 44 45 // help strings and long formatted user outputs 46 skipConfirmationFlagHelpStr = `The skipConfirmation determines whether the user is prompted for confirmation. 47 If set to true, the user is not prompted and a Yes response is assumed in all cases.` 48 overrideHelpStr = `If true, allow revision tags to be overwritten, otherwise reject revision tag updates that 49 overwrite existing revision tags.` 50 revisionHelpStr = "Control plane revision to reference from a given revision tag" 51 tagCreatedStr = `Revision tag %q created, referencing control plane revision %q. To enable injection using this 52 revision tag, use 'kubectl label namespace <NAMESPACE> istio.io/rev=%s' 53 ` 54 webhookNameHelpStr = "Name to use for a revision tag's mutating webhook configuration." 55 autoInjectNamespacesHelpStr = "If set to true, the sidecars should be automatically injected into all namespaces by default" 56 ) 57 58 // options for CLI 59 var ( 60 // revision to point tag webhook at 61 revision = "" 62 manifestsPath = "" 63 overwrite = false 64 skipConfirmation = false 65 webhookName = "" 66 autoInjectNamespaces = false 67 outputFormat = util.TableFormat 68 ) 69 70 type tagDescription struct { 71 Tag string `json:"tag"` 72 Revision string `json:"revision"` 73 Namespaces []string `json:"namespaces"` 74 } 75 76 func TagCommand(ctx cli.Context) *cobra.Command { 77 cmd := &cobra.Command{ 78 Use: "tag", 79 Short: "Command group used to interact with revision tags", 80 Long: `Command group used to interact with revision tags. Revision tags allow for the creation of mutable aliases 81 referring to control plane revisions for sidecar injection. 82 83 With revision tags, rather than relabeling a namespace from "istio.io/rev=revision-a" to "istio.io/rev=revision-b" to 84 change which control plane revision handles injection, it's possible to create a revision tag "prod" and label our 85 namespace "istio.io/rev=prod". The "prod" revision tag could point to "1-7-6" initially and then be changed to point to "1-8-1" 86 at some later point. 87 88 This allows operators to change which Istio control plane revision should handle injection for a namespace or set of namespaces 89 without manual relabeling of the "istio.io/rev" tag. 90 `, 91 Args: func(cmd *cobra.Command, args []string) error { 92 if len(args) != 0 { 93 return fmt.Errorf("unknown subcommand %q", args[0]) 94 } 95 return nil 96 }, 97 RunE: func(cmd *cobra.Command, args []string) error { 98 cmd.HelpFunc()(cmd, args) 99 return nil 100 }, 101 } 102 103 cmd.AddCommand(tagSetCommand(ctx)) 104 cmd.AddCommand(tagGenerateCommand(ctx)) 105 cmd.AddCommand(tagListCommand(ctx)) 106 cmd.AddCommand(tagRemoveCommand(ctx)) 107 108 return cmd 109 } 110 111 func tagSetCommand(ctx cli.Context) *cobra.Command { 112 cmd := &cobra.Command{ 113 Use: "set <revision-tag>", 114 Short: "Create or modify revision tags", 115 Long: `Create or modify revision tags. Tag an Istio control plane revision for use with namespace istio.io/rev 116 injection labels.`, 117 Example: ` # Create a revision tag from the "1-8-0" revision 118 istioctl tag set prod --revision 1-8-0 119 120 # Point namespace "test-ns" at the revision pointed to by the "prod" revision tag 121 kubectl label ns test-ns istio.io/rev=prod 122 123 # Change the revision tag to reference the "1-8-1" revision 124 istioctl tag set prod --revision 1-8-1 --overwrite 125 126 # Make revision "1-8-1" the default revision, both resulting in that revision handling injection for "istio-injection=enabled" 127 # and validating resources cluster-wide 128 istioctl tag set default --revision 1-8-1 129 130 # Rollout namespace "test-ns" to update workloads to the "1-8-1" revision 131 kubectl rollout restart deployments -n test-ns 132 `, 133 SuggestFor: []string{"create"}, 134 Args: func(cmd *cobra.Command, args []string) error { 135 if len(args) == 0 { 136 return fmt.Errorf("must provide a tag for modification") 137 } 138 if len(args) > 1 { 139 return fmt.Errorf("must provide a single tag for creation") 140 } 141 return nil 142 }, 143 RunE: func(cmd *cobra.Command, args []string) error { 144 kubeClient, err := ctx.CLIClient() 145 if err != nil { 146 return fmt.Errorf("failed to create Kubernetes client: %v", err) 147 } 148 149 return setTag(context.Background(), kubeClient, args[0], revision, ctx.IstioNamespace(), false, cmd.OutOrStdout(), cmd.OutOrStderr()) 150 }, 151 } 152 153 cmd.PersistentFlags().BoolVar(&overwrite, "overwrite", false, overrideHelpStr) 154 cmd.PersistentFlags().StringVarP(&manifestsPath, "manifests", "d", "", util.ManifestsFlagHelpStr) 155 cmd.PersistentFlags().BoolVarP(&skipConfirmation, "skip-confirmation", "y", false, skipConfirmationFlagHelpStr) 156 cmd.PersistentFlags().StringVarP(&revision, "revision", "r", "", revisionHelpStr) 157 cmd.PersistentFlags().StringVarP(&webhookName, "webhook-name", "", "", webhookNameHelpStr) 158 cmd.PersistentFlags().BoolVar(&autoInjectNamespaces, "auto-inject-namespaces", false, autoInjectNamespacesHelpStr) 159 _ = cmd.MarkPersistentFlagRequired("revision") 160 161 return cmd 162 } 163 164 func tagGenerateCommand(ctx cli.Context) *cobra.Command { 165 cmd := &cobra.Command{ 166 Use: "generate <revision-tag>", 167 Short: "Generate configuration for a revision tag to stdout", 168 Long: `Create a revision tag and output to the command's stdout. Tag an Istio control plane revision for use with namespace istio.io/rev 169 injection labels.`, 170 Example: ` # Create a revision tag from the "1-8-0" revision 171 istioctl tag generate prod --revision 1-8-0 > tag.yaml 172 173 # Apply the tag to cluster 174 kubectl apply -f tag.yaml 175 176 # Point namespace "test-ns" at the revision pointed to by the "prod" revision tag 177 kubectl label ns test-ns istio.io/rev=prod 178 179 # Rollout namespace "test-ns" to update workloads to the "1-8-0" revision 180 kubectl rollout restart deployments -n test-ns 181 `, 182 Args: func(cmd *cobra.Command, args []string) error { 183 if len(args) == 0 { 184 return fmt.Errorf("must provide a tag for modification") 185 } 186 if len(args) > 1 { 187 return fmt.Errorf("must provide a single tag for creation") 188 } 189 return nil 190 }, 191 RunE: func(cmd *cobra.Command, args []string) error { 192 kubeClient, err := ctx.CLIClient() 193 if err != nil { 194 return fmt.Errorf("failed to create Kubernetes client: %v", err) 195 } 196 197 return setTag(context.Background(), kubeClient, args[0], revision, ctx.IstioNamespace(), true, cmd.OutOrStdout(), cmd.OutOrStderr()) 198 }, 199 } 200 201 cmd.PersistentFlags().BoolVar(&overwrite, "overwrite", false, overrideHelpStr) 202 cmd.PersistentFlags().StringVarP(&manifestsPath, "manifests", "d", "", util.ManifestsFlagHelpStr) 203 cmd.PersistentFlags().BoolVarP(&skipConfirmation, "skip-confirmation", "y", false, skipConfirmationFlagHelpStr) 204 cmd.PersistentFlags().StringVarP(&revision, "revision", "r", "", revisionHelpStr) 205 cmd.PersistentFlags().StringVarP(&webhookName, "webhook-name", "", "", webhookNameHelpStr) 206 cmd.PersistentFlags().BoolVar(&autoInjectNamespaces, "auto-inject-namespaces", false, autoInjectNamespacesHelpStr) 207 _ = cmd.MarkPersistentFlagRequired("revision") 208 209 return cmd 210 } 211 212 func tagListCommand(ctx cli.Context) *cobra.Command { 213 cmd := &cobra.Command{ 214 Use: "list", 215 Short: "List existing revision tags", 216 Example: "istioctl tag list", 217 Aliases: []string{"show"}, 218 Args: func(cmd *cobra.Command, args []string) error { 219 if len(args) != 0 { 220 return fmt.Errorf("tag list command does not accept arguments") 221 } 222 return nil 223 }, 224 RunE: func(cmd *cobra.Command, args []string) error { 225 kubeClient, err := ctx.CLIClient() 226 if err != nil { 227 return fmt.Errorf("failed to create Kubernetes client: %v", err) 228 } 229 return listTags(context.Background(), kubeClient.Kube(), cmd.OutOrStdout()) 230 }, 231 } 232 233 cmd.PersistentFlags().StringVarP(&outputFormat, "output", "o", util.TableFormat, "Output format for tag description "+ 234 "(available formats: table,json)") 235 return cmd 236 } 237 238 func tagRemoveCommand(ctx cli.Context) *cobra.Command { 239 cmd := &cobra.Command{ 240 Use: "remove <revision-tag>", 241 Short: "Remove Istio control plane revision tag", 242 Long: `Remove Istio control plane revision tag. 243 244 Removing a revision tag should be done with care. Removing a revision tag will disrupt sidecar injection in namespaces 245 that reference the tag in an "istio.io/rev" label. Verify that there are no remaining namespaces referencing a 246 revision tag before removing using the "istioctl tag list" command. 247 `, 248 Example: ` # Remove the revision tag "prod" 249 istioctl tag remove prod 250 `, 251 Aliases: []string{"delete"}, 252 Args: func(cmd *cobra.Command, args []string) error { 253 if len(args) == 0 { 254 return fmt.Errorf("must provide a tag for removal") 255 } 256 if len(args) > 1 { 257 return fmt.Errorf("must provide a single tag for removal") 258 } 259 return nil 260 }, 261 RunE: func(cmd *cobra.Command, args []string) error { 262 kubeClient, err := ctx.CLIClient() 263 if err != nil { 264 return fmt.Errorf("failed to create Kubernetes client: %v", err) 265 } 266 267 return removeTag(context.Background(), kubeClient.Kube(), args[0], skipConfirmation, cmd.OutOrStdout()) 268 }, 269 } 270 271 cmd.PersistentFlags().BoolVarP(&skipConfirmation, "skip-confirmation", "y", false, skipConfirmationFlagHelpStr) 272 return cmd 273 } 274 275 // setTag creates or modifies a revision tag. 276 func setTag(ctx context.Context, kubeClient kube.CLIClient, tagName, revision, istioNS string, generate bool, w, stderr io.Writer) error { 277 opts := &GenerateOptions{ 278 Tag: tagName, 279 Revision: revision, 280 WebhookName: webhookName, 281 ManifestsPath: manifestsPath, 282 Generate: generate, 283 Overwrite: overwrite, 284 AutoInjectNamespaces: autoInjectNamespaces, 285 UserManaged: true, 286 } 287 tagWhYAML, err := Generate(ctx, kubeClient, opts, istioNS) 288 if err != nil { 289 return err 290 } 291 // Check the newly generated webhook does not conflict with existing ones. 292 resName := webhookName 293 if resName == "" { 294 resName = fmt.Sprintf("%s-%s", "istio-revision-tag", tagName) 295 } 296 if err := analyzeWebhook(resName, istioNS, tagWhYAML, revision, kubeClient.RESTConfig()); err != nil { 297 // if we have a conflict, we will fail. If --skip-confirmation is set, we will continue with a 298 // warning; when actually applying we will also confirm to ensure the user does not see the 299 // warning *after* it has applied 300 if !skipConfirmation { 301 _, _ = stderr.Write([]byte(err.Error())) 302 if !generate { 303 if !util.Confirm("Apply anyways? [y/N]", w) { 304 return nil 305 } 306 } 307 } 308 } 309 310 if generate { 311 _, err := w.Write([]byte(tagWhYAML)) 312 if err != nil { 313 return err 314 } 315 return nil 316 } 317 318 if err := Create(kubeClient, tagWhYAML, istioNS); err != nil { 319 return fmt.Errorf("failed to apply tag webhook MutatingWebhookConfiguration to cluster: %v", err) 320 } 321 fmt.Fprintf(w, tagCreatedStr, tagName, revision, tagName) 322 return nil 323 } 324 325 func analyzeWebhook(name, istioNamespace, wh, revision string, config *rest.Config) error { 326 sa := local.NewSourceAnalyzer(analysis.Combine("webhook", &webhook.Analyzer{}), "", resource.Namespace(istioNamespace), nil) 327 if err := sa.AddReaderKubeSource([]local.ReaderSource{{Name: "", Reader: strings.NewReader(wh)}}); err != nil { 328 return err 329 } 330 k, err := kube.NewClient(kube.NewClientConfigForRestConfig(config), "") 331 if err != nil { 332 return err 333 } 334 sa.AddRunningKubeSourceWithRevision(k, revision, false) 335 res, err := sa.Analyze(make(chan struct{})) 336 if err != nil { 337 return err 338 } 339 relevantMessages := diag.Messages{} 340 for _, msg := range res.Messages.FilterOutLowerThan(diag.Error) { 341 if msg.Resource.Metadata.FullName.Name == resource.LocalName(name) { 342 relevantMessages = append(relevantMessages, msg) 343 } 344 } 345 if len(relevantMessages) > 0 { 346 o, err := formatting.Print(relevantMessages, formatting.LogFormat, false) 347 if err != nil { 348 return err 349 } 350 // nolint 351 return fmt.Errorf("creating tag would conflict, pass --skip-confirmation to proceed:\n%v\n", o) 352 } 353 return nil 354 } 355 356 // removeTag removes an existing revision tag. 357 func removeTag(ctx context.Context, kubeClient kubernetes.Interface, tagName string, skipConfirmation bool, w io.Writer) error { 358 webhooks, err := GetWebhooksWithTag(ctx, kubeClient, tagName) 359 if err != nil { 360 return fmt.Errorf("failed to retrieve tag with name %s: %v", tagName, err) 361 } 362 if len(webhooks) == 0 { 363 return fmt.Errorf("cannot remove tag %q: cannot find MutatingWebhookConfiguration for tag", tagName) 364 } 365 366 taggedNamespaces, err := GetNamespacesWithTag(ctx, kubeClient, tagName) 367 if err != nil { 368 return fmt.Errorf("failed to retrieve namespaces dependent on tag %q", tagName) 369 } 370 // warn user if deleting a tag that still has namespaces pointed to it 371 if len(taggedNamespaces) > 0 && !skipConfirmation { 372 if !util.Confirm(buildDeleteTagConfirmation(tagName, taggedNamespaces), w) { 373 fmt.Fprintf(w, "Aborting operation.\n") 374 return nil 375 } 376 } 377 378 // proceed with webhook deletion 379 err = DeleteTagWebhooks(ctx, kubeClient, tagName) 380 if err != nil { 381 return fmt.Errorf("failed to delete Istio revision tag MutatingConfigurationWebhook: %v", err) 382 } 383 384 fmt.Fprintf(w, "Revision tag %s removed\n", tagName) 385 return nil 386 } 387 388 type uniqTag struct { 389 revision, tag string 390 } 391 392 // listTags lists existing revision. 393 func listTags(ctx context.Context, kubeClient kubernetes.Interface, writer io.Writer) error { 394 tagWebhooks, err := GetRevisionWebhooks(ctx, kubeClient) 395 if err != nil { 396 return fmt.Errorf("failed to retrieve revision tags: %v", err) 397 } 398 if len(tagWebhooks) == 0 { 399 fmt.Fprintf(writer, "No Istio revision tag MutatingWebhookConfigurations to list\n") 400 return nil 401 } 402 rawTags := map[uniqTag]tagDescription{} 403 for _, wh := range tagWebhooks { 404 tagName := GetWebhookTagName(wh) 405 tagRevision, err := GetWebhookRevision(wh) 406 if err != nil { 407 return fmt.Errorf("error parsing revision from webhook %q: %v", wh.Name, err) 408 } 409 tagNamespaces, err := GetNamespacesWithTag(ctx, kubeClient, tagName) 410 if err != nil { 411 return fmt.Errorf("error retrieving namespaces for tag %q: %v", tagName, err) 412 } 413 tagDesc := tagDescription{ 414 Tag: tagName, 415 Revision: tagRevision, 416 Namespaces: tagNamespaces, 417 } 418 key := uniqTag{ 419 revision: tagRevision, 420 tag: tagName, 421 } 422 rawTags[key] = tagDesc 423 } 424 for k := range rawTags { 425 if k.tag != "" { 426 delete(rawTags, uniqTag{revision: k.revision}) 427 } 428 } 429 tags := slices.SortFunc(maps.Values(rawTags), func(a, b tagDescription) int { 430 if r := cmp.Compare(a.Revision, b.Revision); r != 0 { 431 return r 432 } 433 return cmp.Compare(a.Tag, b.Tag) 434 }) 435 436 switch outputFormat { 437 case util.JSONFormat: 438 return PrintJSON(writer, tags) 439 case util.TableFormat: 440 default: 441 return fmt.Errorf("unknown format: %s", outputFormat) 442 } 443 w := new(tabwriter.Writer).Init(writer, 0, 8, 1, ' ', 0) 444 fmt.Fprintln(w, "TAG\tREVISION\tNAMESPACES") 445 for _, t := range tags { 446 fmt.Fprintf(w, "%s\t%s\t%s\n", t.Tag, t.Revision, strings.Join(t.Namespaces, ",")) 447 } 448 449 return w.Flush() 450 } 451 452 func PrintJSON(w io.Writer, res any) error { 453 out, err := json.MarshalIndent(res, "", "\t") 454 if err != nil { 455 return fmt.Errorf("error while marshaling to JSON: %v", err) 456 } 457 fmt.Fprintln(w, string(out)) 458 return nil 459 } 460 461 // buildDeleteTagConfirmation takes a list of webhooks and creates a message prompting confirmation for their deletion. 462 func buildDeleteTagConfirmation(tag string, taggedNamespaces []string) string { 463 var sb strings.Builder 464 base := fmt.Sprintf("Caution, found %d namespace(s) still injected by tag %q:", len(taggedNamespaces), tag) 465 sb.WriteString(base) 466 for _, ns := range taggedNamespaces { 467 sb.WriteString(" " + ns) 468 } 469 sb.WriteString("\nProceed with operation? [y/N]") 470 471 return sb.String() 472 }