diff --git a/contrib/completions/bash/oc b/contrib/completions/bash/oc index 3fa65e24ec19..f3bf34194e2c 100644 --- a/contrib/completions/bash/oc +++ b/contrib/completions/bash/oc @@ -1412,6 +1412,74 @@ _oc_set_probe() must_have_one_noun=() } +_oc_set_triggers() +{ + last_command="oc_set_triggers" + commands=() + + flags=() + two_word_flags=() + flags_with_completion=() + flags_completion=() + + flags+=("--all") + flags+=("--auto") + flags+=("--containers=") + two_word_flags+=("-c") + flags+=("--filename=") + flags_with_completion+=("--filename") + flags_completion+=("__handle_filename_extension_flag yaml|yml|json") + two_word_flags+=("-f") + flags_with_completion+=("-f") + flags_completion+=("__handle_filename_extension_flag yaml|yml|json") + flags+=("--from-config") + flags+=("--from-github") + flags+=("--from-image=") + flags+=("--from-webhook") + flags+=("--manual") + flags+=("--no-headers") + flags+=("--output=") + two_word_flags+=("-o") + flags+=("--output-version=") + flags+=("--remove") + flags+=("--remove-all") + flags+=("--selector=") + two_word_flags+=("-l") + flags+=("--show-all") + flags+=("-a") + flags+=("--show-labels") + flags+=("--sort-by=") + flags+=("--template=") + two_word_flags+=("-t") + flags+=("--api-version=") + flags+=("--certificate-authority=") + flags_with_completion+=("--certificate-authority") + flags_completion+=("_filedir") + flags+=("--client-certificate=") + flags_with_completion+=("--client-certificate") + flags_completion+=("_filedir") + flags+=("--client-key=") + flags_with_completion+=("--client-key") + flags_completion+=("_filedir") + flags+=("--cluster=") + flags+=("--config=") + flags_with_completion+=("--config") + flags_completion+=("_filedir") + flags+=("--context=") + flags+=("--google-json-key=") + flags+=("--insecure-skip-tls-verify") + flags+=("--log-flush-frequency=") + flags+=("--match-server-version") + flags+=("--namespace=") + two_word_flags+=("-n") + flags+=("--server=") + flags+=("--token=") + flags+=("--user=") + + must_have_one_flag=() + must_have_one_noun=() +} + _oc_set() { last_command="oc_set" @@ -1419,6 +1487,7 @@ _oc_set() commands+=("env") commands+=("volumes") commands+=("probe") + commands+=("triggers") flags=() two_word_flags=() diff --git a/contrib/completions/bash/openshift b/contrib/completions/bash/openshift index 1fc06a6a6c98..3b7793f78586 100644 --- a/contrib/completions/bash/openshift +++ b/contrib/completions/bash/openshift @@ -4749,6 +4749,74 @@ _openshift_cli_set_probe() must_have_one_noun=() } +_openshift_cli_set_triggers() +{ + last_command="openshift_cli_set_triggers" + commands=() + + flags=() + two_word_flags=() + flags_with_completion=() + flags_completion=() + + flags+=("--all") + flags+=("--auto") + flags+=("--containers=") + two_word_flags+=("-c") + flags+=("--filename=") + flags_with_completion+=("--filename") + flags_completion+=("__handle_filename_extension_flag yaml|yml|json") + two_word_flags+=("-f") + flags_with_completion+=("-f") + flags_completion+=("__handle_filename_extension_flag yaml|yml|json") + flags+=("--from-config") + flags+=("--from-github") + flags+=("--from-image=") + flags+=("--from-webhook") + flags+=("--manual") + flags+=("--no-headers") + flags+=("--output=") + two_word_flags+=("-o") + flags+=("--output-version=") + flags+=("--remove") + flags+=("--remove-all") + flags+=("--selector=") + two_word_flags+=("-l") + flags+=("--show-all") + flags+=("-a") + flags+=("--show-labels") + flags+=("--sort-by=") + flags+=("--template=") + two_word_flags+=("-t") + flags+=("--api-version=") + flags+=("--certificate-authority=") + flags_with_completion+=("--certificate-authority") + flags_completion+=("_filedir") + flags+=("--client-certificate=") + flags_with_completion+=("--client-certificate") + flags_completion+=("_filedir") + flags+=("--client-key=") + flags_with_completion+=("--client-key") + flags_completion+=("_filedir") + flags+=("--cluster=") + flags+=("--config=") + flags_with_completion+=("--config") + flags_completion+=("_filedir") + flags+=("--context=") + flags+=("--google-json-key=") + flags+=("--insecure-skip-tls-verify") + flags+=("--log-flush-frequency=") + flags+=("--match-server-version") + flags+=("--namespace=") + two_word_flags+=("-n") + flags+=("--server=") + flags+=("--token=") + flags+=("--user=") + + must_have_one_flag=() + must_have_one_noun=() +} + _openshift_cli_set() { last_command="openshift_cli_set" @@ -4756,6 +4824,7 @@ _openshift_cli_set() commands+=("env") commands+=("volumes") commands+=("probe") + commands+=("triggers") flags=() two_word_flags=() diff --git a/docs/generated/oc_by_example_content.adoc b/docs/generated/oc_by_example_content.adoc index f4d880e202d6..69d599b3fab9 100644 --- a/docs/generated/oc_by_example_content.adoc +++ b/docs/generated/oc_by_example_content.adoc @@ -1688,6 +1688,38 @@ Update a probe on a pod template ==== +== oc set triggers +Update the triggers on a build or deployment config + +==== + +[options="nowrap"] +---- + # Print the triggers on the registry + $ oc set triggers dc/registry + + # Set all triggers to manual + $ oc set triggers dc/registry --manual + + # Enable all automatic triggers + $ oc set triggers dc/registry --auto + + # Reset the GitHub webhook on a build to a new, generated secret + $ oc set triggers bc/webapp --from-github= + $ oc set triggers bc/webapp --from-webhook= + + # Remove all triggers + $ oc set triggers bc/webapp --remove-all + + # Stop triggering on config change + $ oc set triggers dc/registry --from-config --remove + + # Add an image trigger to a build config + $ oc set triggers bc/webapp --from-image=namespace1/image:latest +---- +==== + + == oc set volumes Update volumes on a pod template diff --git a/pkg/cmd/cli/cmd/set/set.go b/pkg/cmd/cli/cmd/set/set.go index 565fa6fbe9f5..f7fb71df9ed5 100644 --- a/pkg/cmd/cli/cmd/set/set.go +++ b/pkg/cmd/cli/cmd/set/set.go @@ -38,6 +38,12 @@ func NewCmdSet(fullName string, f *clientcmd.Factory, in io.Reader, out, errout NewCmdProbe(name, f, out, errout), }, }, + { + Message: "Manage application flows:", + Commands: []*cobra.Command{ + NewCmdTriggers(name, f, out, errout), + }, + }, } groups.Add(set) templates.ActsAsRootCommand(set, []string{"options"}, groups...) diff --git a/pkg/cmd/cli/cmd/set/triggers.go b/pkg/cmd/cli/cmd/set/triggers.go new file mode 100644 index 000000000000..2cf74882010d --- /dev/null +++ b/pkg/cmd/cli/cmd/set/triggers.go @@ -0,0 +1,770 @@ +package set + +import ( + "fmt" + "io" + "os" + "reflect" + "strings" + "text/tabwriter" + + "github.com/golang/glog" + "github.com/spf13/cobra" + + kapi "k8s.io/kubernetes/pkg/api" + "k8s.io/kubernetes/pkg/api/meta" + kcmdutil "k8s.io/kubernetes/pkg/kubectl/cmd/util" + "k8s.io/kubernetes/pkg/kubectl/resource" + "k8s.io/kubernetes/pkg/runtime" + + buildapi "github.com/openshift/origin/pkg/build/api" + buildutil "github.com/openshift/origin/pkg/build/util" + cmdutil "github.com/openshift/origin/pkg/cmd/util" + "github.com/openshift/origin/pkg/cmd/util/clientcmd" + deployapi "github.com/openshift/origin/pkg/deploy/api" + "github.com/openshift/origin/pkg/generate/app" + imageapi "github.com/openshift/origin/pkg/image/api" + "k8s.io/kubernetes/pkg/util/sets" +) + +const ( + triggersLong = ` +Set or remove triggers for build configs and deployment configs + +All build configs and deployment configs may have a set of triggers that result in a new deployment +or build being created. This command enables you to alter those triggers - making them automatic or +manual, adding new entries, or changing existing entries. + +Deployments support triggering off of image changes and on config changes. Config changes are any +alterations to the pod template, while image changes will result in the container image value being +updated whenever an image stream tag is updated. + +Build configs support triggering off of image changes, config changes, and webhooks (both GitHub-specific +and generic). The config change trigger for a build config will only trigger the first build.` + + triggersExample = ` # Print the triggers on the registry + $ %[1]s triggers dc/registry + + # Set all triggers to manual + $ %[1]s triggers dc/registry --manual + + # Enable all automatic triggers + $ %[1]s triggers dc/registry --auto + + # Reset the GitHub webhook on a build to a new, generated secret + $ %[1]s triggers bc/webapp --from-github= + $ %[1]s triggers bc/webapp --from-webhook= + + # Remove all triggers + $ %[1]s triggers bc/webapp --remove-all + + # Stop triggering on config change + $ %[1]s triggers dc/registry --from-config --remove + + # Add an image trigger to a build config + $ %[1]s triggers bc/webapp --from-image=namespace1/image:latest` +) + +type TriggersOptions struct { + Out io.Writer + Err io.Writer + + Filenames []string + Selector string + All bool + + Builder *resource.Builder + Infos []*resource.Info + + Encoder runtime.Encoder + + ShortOutput bool + Mapper meta.RESTMapper + + PrintTable bool + PrintObject func(runtime.Object) error + + Remove bool + RemoveAll bool + Auto bool + Manual bool + Reset bool + + ContainerNames string + FromConfig bool + FromGitHub *bool + FromWebHook *bool + FromImage string + // FromImageNamespace is the namespace for the FromImage + FromImageNamespace string +} + +// NewCmdTriggers implements the set triggers command +func NewCmdTriggers(fullName string, f *clientcmd.Factory, out, errOut io.Writer) *cobra.Command { + options := &TriggersOptions{ + Out: out, + Err: errOut, + } + cmd := &cobra.Command{ + Use: "triggers RESOURCE/NAME [--from-config|--from-image|--from-github|--from-webhook] [--auto|--manual]", + Short: "Update the triggers on a build or deployment config", + Long: triggersLong, + Example: fmt.Sprintf(triggersExample, fullName), + Run: func(cmd *cobra.Command, args []string) { + kcmdutil.CheckErr(options.Complete(f, cmd, args)) + kcmdutil.CheckErr(options.Validate()) + if err := options.Run(); err != nil { + // TODO: move met to kcmdutil + if err == cmdutil.ErrExit { + os.Exit(1) + } + kcmdutil.CheckErr(err) + } + }, + } + + kcmdutil.AddPrinterFlags(cmd) + cmd.Flags().StringVarP(&options.Selector, "selector", "l", options.Selector, "Selector (label query) to filter on") + cmd.Flags().BoolVar(&options.All, "all", options.All, "Select all resources in the namespace of the specified resource types") + cmd.Flags().StringSliceVarP(&options.Filenames, "filename", "f", options.Filenames, "Filename, directory, or URL to file to use to edit the resource.") + + cmd.Flags().BoolVar(&options.Remove, "remove", options.Remove, "If true, remove the specified trigger(s).") + cmd.Flags().BoolVar(&options.RemoveAll, "remove-all", options.RemoveAll, "If true, remove all triggers.") + cmd.Flags().BoolVar(&options.Auto, "auto", options.Auto, "Enable all triggers, or just the specified trigger") + cmd.Flags().BoolVar(&options.Manual, "manual", options.Manual, "Set all triggers to manual, or just the specified trigger") + + cmd.Flags().BoolVar(&options.FromConfig, "from-config", options.FromConfig, "If set, configuration changes will result in a change") + cmd.Flags().StringVarP(&options.ContainerNames, "containers", "c", options.ContainerNames, "Comma delimited list of container names this trigger applies to on deployments; defaults to the name of the only container") + cmd.Flags().StringVar(&options.FromImage, "from-image", options.FromImage, "An image stream tag to trigger off of") + options.FromGitHub = cmd.Flags().Bool("from-github", false, "A GitHub webhook - a secret value will be generated automatically") + options.FromWebHook = cmd.Flags().Bool("from-webhook", false, "A generic webhook - a secret value will be generated automatically") + + cmd.MarkFlagFilename("filename", "yaml", "yml", "json") + + return cmd +} + +func (o *TriggersOptions) Complete(f *clientcmd.Factory, cmd *cobra.Command, args []string) error { + cmdNamespace, explicit, err := f.DefaultNamespace() + if err != nil { + return err + } + + if !cmd.Flags().Lookup("from-github").Changed { + o.FromGitHub = nil + } + if !cmd.Flags().Lookup("from-webhook").Changed { + o.FromWebHook = nil + } + + if len(o.FromImage) > 0 { + ref, err := imageapi.ParseDockerImageReference(o.FromImage) + if err != nil { + return fmt.Errorf("the value of --from-image does not appear to be a valid reference to an image: %v", err) + } + if len(ref.Registry) > 0 || len(ref.ID) > 0 { + return fmt.Errorf("the value of --from-image must point to an image stream tag on this server") + } + if len(ref.Tag) == 0 { + return fmt.Errorf("the value of --from-image must include the tag you wish to pull from") + } + o.FromImage = ref.NameString() + o.FromImageNamespace = defaultNamespace(ref.Namespace, cmdNamespace) + } + + count := o.count() + o.Reset = count == 0 && (o.Auto || o.Manual) + switch { + case count == 0 && !o.Remove && !o.RemoveAll && !o.Auto && !o.Manual: + o.PrintTable = true + case !o.RemoveAll && !o.Auto && !o.Manual: + o.Auto = true + } + + mapper, typer := f.Object() + o.Builder = resource.NewBuilder(mapper, typer, resource.ClientMapperFunc(f.ClientForMapping), kapi.Codecs.UniversalDecoder()). + ContinueOnError(). + NamespaceParam(cmdNamespace).DefaultNamespace(). + FilenameParam(explicit, o.Filenames...). + SelectorParam(o.Selector). + ResourceTypeOrNameArgs(o.All, args...). + Flatten() + + output := kcmdutil.GetFlagString(cmd, "output") + if len(output) != 0 { + o.PrintObject = func(obj runtime.Object) error { return f.PrintObject(cmd, obj, o.Out) } + } + + o.Encoder = f.JSONEncoder() + o.ShortOutput = kcmdutil.GetFlagString(cmd, "output") == "name" + o.Mapper = mapper + + return nil +} + +func (o *TriggersOptions) count() int { + count := 0 + if o.FromConfig { + count++ + } + if o.FromGitHub != nil { + count++ + } + if o.FromWebHook != nil { + count++ + } + if len(o.FromImage) > 0 { + count++ + } + return count +} + +func (o *TriggersOptions) Validate() error { + count := o.count() + switch { + case o.Auto && o.Manual: + return fmt.Errorf("you must specify at most one of --auto or --manual") + case o.Remove && o.RemoveAll: + return fmt.Errorf("you must specify either --remove or --remove-all") + case o.RemoveAll && (count != 0 || o.Auto || o.Manual): + return fmt.Errorf("--remove-all may not be used with any other flag") + case o.Remove && count < 1: + return fmt.Errorf("--remove requires a flag defining a trigger type to be specified") + case count > 1: + return fmt.Errorf("you may only set one trigger type at a time") + case count == 0 && !o.Remove && !o.RemoveAll && !o.Auto && !o.Manual && !o.PrintTable: + return fmt.Errorf("specify one of the --from-* flags to add a trigger, --remove to remove, or --auto|--manual to control existing triggers") + } + return nil +} + +func (o *TriggersOptions) Run() error { + infos := o.Infos + singular := len(o.Infos) <= 1 + if o.Builder != nil { + loaded, err := o.Builder.Do().IntoSingular(&singular).Infos() + if err != nil { + return err + } + infos = loaded + } + + if o.PrintTable && o.PrintObject == nil { + return o.printTriggers(infos) + } + + updateTriggerFn := func(triggers *TriggerDefinition) error { + o.updateTriggers(triggers) + return nil + } + patches := CalculatePatches(infos, o.Encoder, func(info *resource.Info) (bool, error) { + return UpdateTriggersForObject(info.Object, updateTriggerFn) + }) + if singular && len(patches) == 0 { + return fmt.Errorf("%s/%s is not a deployment config or build config", infos[0].Mapping.Resource, infos[0].Name) + } + if len(patches) == 0 { + return nil + } + + if o.PrintObject != nil { + var infos []*resource.Info + for _, patch := range patches { + info := patch.Info + if patch.Err != nil { + fmt.Fprintf(o.Err, "error: %s/%s %v\n", info.Mapping.Resource, info.Name, patch.Err) + continue + } + infos = append(infos, info) + } + if len(infos) == 0 { + return cmdutil.ErrExit + } + object, err := resource.AsVersionedObject(infos, !singular, "", nil) + if err != nil { + return err + } + return o.PrintObject(object) + } + + failed := false + for _, patch := range patches { + info := patch.Info + if patch.Err != nil { + failed = true + fmt.Fprintf(o.Err, "error: %s/%s %v\n", info.Mapping.Resource, info.Name, patch.Err) + continue + } + + if string(patch.Patch) == "{}" || len(patch.Patch) == 0 { + fmt.Fprintf(o.Err, "info: %s %q was not changed\n", info.Mapping.Resource, info.Name) + continue + } + + glog.V(4).Infof("Calculated patch %s", patch.Patch) + + obj, err := resource.NewHelper(info.Client, info.Mapping).Patch(info.Namespace, info.Name, kapi.StrategicMergePatchType, patch.Patch) + if err != nil { + handlePodUpdateError(o.Err, err, "triggered") + failed = true + continue + } + + info.Refresh(obj, true) + kcmdutil.PrintSuccess(o.Mapper, o.ShortOutput, o.Out, info.Mapping.Resource, info.Name, "updated") + } + if failed { + return cmdutil.ErrExit + } + return nil +} + +// printTriggers displays a tabular output of the triggers for each object. +func (o *TriggersOptions) printTriggers(infos []*resource.Info) error { + w := tabwriter.NewWriter(o.Out, 0, 2, 2, ' ', 0) + defer w.Flush() + fmt.Fprintf(w, "NAME\tTYPE\tVALUE\tAUTO\n") + for _, info := range infos { + _, err := UpdateTriggersForObject(info.Object, func(triggers *TriggerDefinition) error { + fmt.Fprintf(w, "%s/%s\t%s\t%s\t%t\n", info.Mapping.Resource, info.Name, "config", "", triggers.ConfigChange) + for _, image := range triggers.ImageChange { + var details string + switch { + case len(image.Names) > 0: + if len(image.Namespace) > 0 { + details = fmt.Sprintf("%s/%s (%s)", image.Namespace, image.From, strings.Join(image.Names, ", ")) + } else { + details = fmt.Sprintf("%s (%s)", image.From, strings.Join(image.Names, ", ")) + } + case len(image.Namespace) > 0: + details = fmt.Sprintf("%s/%s", image.Namespace, image.From) + default: + details = image.From + } + fmt.Fprintf(w, "%s/%s\t%s\t%s\t%t\n", info.Mapping.Resource, info.Name, "image", details, image.Auto) + } + for _, s := range triggers.WebHooks { + fmt.Fprintf(w, "%s/%s\t%s\t%s\t%s\n", info.Mapping.Resource, info.Name, "webhook", s, "") + } + for _, s := range triggers.GitHubWebHooks { + fmt.Fprintf(w, "%s/%s\t%s\t%s\t%s\n", info.Mapping.Resource, info.Name, "github", s, "") + } + return nil + }) + if err != nil { + fmt.Fprintf(w, "%s/%s\t%s\t%s\t%t\n", info.Mapping.Resource, info.Name, "", "", false) + } + } + return nil +} + +// updateTriggers updates only those fields with flags set by the user +func (o *TriggersOptions) updateTriggers(triggers *TriggerDefinition) { + // clear everything + if o.RemoveAll { + *triggers = TriggerDefinition{} + return + } + + // clear a specific field + if o.Remove { + if o.FromConfig { + triggers.ConfigChange = false + } + if len(o.FromImage) > 0 { + var newTriggers []ImageChangeTrigger + for _, trigger := range triggers.ImageChange { + if trigger.From != o.FromImage { + newTriggers = append(newTriggers, trigger) + } + } + triggers.ImageChange = newTriggers + } + if o.FromWebHook != nil && *o.FromWebHook { + triggers.WebHooks = nil + } + if o.FromGitHub != nil && *o.FromGitHub { + triggers.GitHubWebHooks = nil + } + return + } + + // change the automated status + if o.Reset { + triggers.ConfigChange = o.Auto + for i := range triggers.ImageChange { + triggers.ImageChange[i].Auto = o.Auto + } + return + } + + // change individual elements + if o.FromConfig { + triggers.ConfigChange = true + } + if len(o.FromImage) > 0 { + names := strings.Split(o.ContainerNames, ",") + if len(o.ContainerNames) == 0 { + names = nil + } + found := false + for i, trigger := range triggers.ImageChange { + if trigger.From == o.FromImage && trigger.Namespace == o.FromImageNamespace { + found = true + triggers.ImageChange[i].Auto = !o.Manual + triggers.ImageChange[i].Names = names + break + } + } + if !found { + triggers.ImageChange = append(triggers.ImageChange, ImageChangeTrigger{ + From: o.FromImage, + Namespace: o.FromImageNamespace, + Auto: !o.Manual, + Names: names, + }) + } + } + if o.FromWebHook != nil && *o.FromWebHook { + triggers.WebHooks = []string{app.GenerateSecret(20)} + } + if o.FromGitHub != nil && *o.FromGitHub { + triggers.GitHubWebHooks = []string{app.GenerateSecret(20)} + } +} + +// ImageChangeTrigger represents the capabilities present in deployment config and build +// config objects in a consistent way. +type ImageChangeTrigger struct { + // If this trigger is automatically applied + Auto bool + // An ImageStreamTag name to target + From string + // The target namespace, normalized if set + Namespace string + // A list of names this trigger targets + Names []string +} + +// TriggerDefinition is the abstract representation of triggers for builds and deploymnet configs. +type TriggerDefinition struct { + ConfigChange bool + ImageChange []ImageChangeTrigger + WebHooks []string + GitHubWebHooks []string +} + +// defaultNamespace returns an empty string if the provided namespace matches the default namespace, or +// returns the namespace. +func defaultNamespace(namespace, defaultNamespace string) string { + if namespace == defaultNamespace { + return "" + } + return namespace +} + +// NewDeploymentConfigTriggers creates a trigger definition from a deployment config. +func NewDeploymentConfigTriggers(config *deployapi.DeploymentConfig) *TriggerDefinition { + t := &TriggerDefinition{} + for _, trigger := range config.Spec.Triggers { + switch trigger.Type { + case deployapi.DeploymentTriggerOnConfigChange: + t.ConfigChange = true + case deployapi.DeploymentTriggerOnImageChange: + t.ImageChange = append(t.ImageChange, ImageChangeTrigger{ + Auto: trigger.ImageChangeParams.Automatic, + Names: trigger.ImageChangeParams.ContainerNames, + From: trigger.ImageChangeParams.From.Name, + Namespace: defaultNamespace(trigger.ImageChangeParams.From.Namespace, config.Namespace), + }) + } + } + return t +} + +// NewBuildConfigTriggers creates a trigger definition from a build config. +func NewBuildConfigTriggers(config *buildapi.BuildConfig) *TriggerDefinition { + t := &TriggerDefinition{} + setStrategy := false + for _, trigger := range config.Spec.Triggers { + switch trigger.Type { + case buildapi.ConfigChangeBuildTriggerType: + t.ConfigChange = true + case buildapi.GenericWebHookBuildTriggerType: + t.WebHooks = append(t.WebHooks, trigger.GenericWebHook.Secret) + case buildapi.GitHubWebHookBuildTriggerType: + t.GitHubWebHooks = append(t.GitHubWebHooks, trigger.GitHubWebHook.Secret) + case buildapi.ImageChangeBuildTriggerType: + if trigger.ImageChange.From == nil { + if strategyTrigger := strategyTrigger(config); strategyTrigger != nil { + setStrategy = true + strategyTrigger.Auto = true + t.ImageChange = append(t.ImageChange, *strategyTrigger) + } + continue + } + // normalize the trigger + trigger.ImageChange.From.Namespace = defaultNamespace(trigger.ImageChange.From.Namespace, config.Namespace) + t.ImageChange = append(t.ImageChange, ImageChangeTrigger{ + Auto: true, + From: trigger.ImageChange.From.Name, + Namespace: trigger.ImageChange.From.Namespace, + }) + } + } + if !setStrategy { + if strategyTrigger := strategyTrigger(config); strategyTrigger != nil { + t.ImageChange = append(t.ImageChange, *strategyTrigger) + } + } + return t +} + +// Apply writes a trigger definition back to a build or deployment config. +func (t *TriggerDefinition) Apply(obj runtime.Object) error { + switch c := obj.(type) { + case *deployapi.DeploymentConfig: + if len(t.GitHubWebHooks) > 0 { + return fmt.Errorf("deployment configs do not support GitHub web hooks") + } + if len(t.WebHooks) > 0 { + return fmt.Errorf("deployment configs do not support web hooks") + } + + existingTriggers := filterDeploymentTriggers(c.Spec.Triggers, deployapi.DeploymentTriggerOnConfigChange) + var triggers []deployapi.DeploymentTriggerPolicy + if t.ConfigChange { + triggers = append(triggers, deployapi.DeploymentTriggerPolicy{Type: deployapi.DeploymentTriggerOnConfigChange}) + } + allNames := sets.NewString() + for _, container := range c.Spec.Template.Spec.Containers { + allNames.Insert(container.Name) + } + for _, trigger := range t.ImageChange { + if len(trigger.Names) == 0 { + return fmt.Errorf("you must specify --containers when setting --from-image") + } + if !allNames.HasAll(trigger.Names...) { + return fmt.Errorf( + "not all container names exist: %s (accepts: %s)", + strings.Join(sets.NewString(trigger.Names...).Difference(allNames).List(), ", "), + strings.Join(allNames.List(), ", "), + ) + } + triggers = append(triggers, deployapi.DeploymentTriggerPolicy{ + Type: deployapi.DeploymentTriggerOnImageChange, + ImageChangeParams: &deployapi.DeploymentTriggerImageChangeParams{ + Automatic: trigger.Auto, + From: kapi.ObjectReference{ + Kind: "ImageStreamTag", + Name: trigger.From, + }, + ContainerNames: trigger.Names, + }, + }) + } + c.Spec.Triggers = mergeDeployTriggers(existingTriggers, triggers) + return nil + + case *buildapi.BuildConfig: + var triggers []buildapi.BuildTriggerPolicy + if t.ConfigChange { + triggers = append(triggers, buildapi.BuildTriggerPolicy{Type: buildapi.ConfigChangeBuildTriggerType}) + } + for _, trigger := range t.WebHooks { + triggers = append(triggers, buildapi.BuildTriggerPolicy{ + Type: buildapi.GenericWebHookBuildTriggerType, + GenericWebHook: &buildapi.WebHookTrigger{ + Secret: trigger, + }, + }) + } + for _, trigger := range t.GitHubWebHooks { + triggers = append(triggers, buildapi.BuildTriggerPolicy{ + Type: buildapi.GitHubWebHookBuildTriggerType, + GitHubWebHook: &buildapi.WebHookTrigger{ + Secret: trigger, + }, + }) + } + + // add new triggers, filter out any old triggers that match (if moving from automatic to manual), + // and then merge the old triggers and the new triggers to preserve fields like lastTriggeredImageID + existingTriggers := c.Spec.Triggers + strategyTrigger := strategyTrigger(c) + for _, trigger := range t.ImageChange { + change := &buildapi.ImageChangeTrigger{ + From: &kapi.ObjectReference{ + Kind: "ImageStreamTag", + Name: trigger.From, + Namespace: trigger.Namespace, + }, + } + + // use the canonical ImageChangeTrigger with nil From + strategyTrigger.Auto = trigger.Auto + if reflect.DeepEqual(strategyTrigger, &trigger) { + change.From = nil + } + + // if this trigger is not automatic, then we need to remove it from the list of triggers + if !trigger.Auto { + existingTriggers = filterBuildImageTriggers(existingTriggers, trigger, strategyTrigger) + continue + } + + triggers = append(triggers, buildapi.BuildTriggerPolicy{ + Type: buildapi.ImageChangeBuildTriggerType, + ImageChange: change, + }) + } + c.Spec.Triggers = mergeBuildTriggers(existingTriggers, triggers) + return nil + + default: + return fmt.Errorf("the object is not a deployment config or build config") + } +} + +// triggerMatchesBuildImageChange identifies whether the image change is equivalent to the trigger +func triggerMatchesBuildImageChange(trigger ImageChangeTrigger, strategyTrigger *ImageChangeTrigger, imageChange *buildapi.ImageChangeTrigger) bool { + if imageChange == nil { + return false + } + if imageChange.From == nil { + return strategyTrigger != nil && strategyTrigger.From == trigger.From && strategyTrigger.Namespace == trigger.Namespace + } + namespace := imageChange.From.Namespace + if strategyTrigger != nil { + namespace = defaultNamespace(namespace, strategyTrigger.Namespace) + } + return imageChange.From.Name == trigger.From && namespace == trigger.Namespace +} + +// filterBuildImageTriggers return only triggers that do not match the provided ImageChangeTrigger. strategyTrigger may be provided +// if set to remove a BuildTriggerPolicy without a From (which points to the strategy) +func filterBuildImageTriggers(src []buildapi.BuildTriggerPolicy, trigger ImageChangeTrigger, strategyTrigger *ImageChangeTrigger) []buildapi.BuildTriggerPolicy { + var dst []buildapi.BuildTriggerPolicy + for i := range src { + if triggerMatchesBuildImageChange(trigger, strategyTrigger, src[i].ImageChange) { + continue + } + dst = append(dst, src[i]) + } + return dst +} + +// filterDeploymentTriggers returns only triggers that do not have one of the provided types. +func filterDeploymentTriggers(src []deployapi.DeploymentTriggerPolicy, types ...deployapi.DeploymentTriggerType) []deployapi.DeploymentTriggerPolicy { + var dst []deployapi.DeploymentTriggerPolicy +Outer: + for i := range src { + for _, t := range types { + if t == src[i].Type { + continue Outer + } + } + dst = append(dst, src[i]) + } + return dst +} + +// strategyTrigger returns a synthetic ImageChangeTrigger that represents the image stream tag the build strategy +// points to, or nil if no such strategy trigger is possible (if the build doesn't point to an ImageStreamTag). +func strategyTrigger(config *buildapi.BuildConfig) *ImageChangeTrigger { + if from := buildutil.GetImageStreamForStrategy(config.Spec.Strategy); from != nil { + if from.Kind == "ImageStreamTag" { + // normalize the strategy object reference + from.Namespace = defaultNamespace(from.Namespace, config.Namespace) + return &ImageChangeTrigger{From: from.Name, Namespace: from.Namespace} + } + } + return nil +} + +// mergeDeployTriggers returns an array of DeploymentTriggerPolicies that have no duplicates. +func mergeDeployTriggers(dst, src []deployapi.DeploymentTriggerPolicy) []deployapi.DeploymentTriggerPolicy { + // never return an empty map, because the triggers on a deployment config default when the map is empty + result := []deployapi.DeploymentTriggerPolicy{} + for _, current := range dst { + if findDeployTrigger(src, current) != -1 { + result = append(result, current) + } + } + for _, current := range src { + if findDeployTrigger(result, current) == -1 { + result = append(result, current) + } + } + return result +} + +// findDeployTrigger finds the position of a deployment trigger in the provided array, or -1 if no such +// matching trigger is found. +func findDeployTrigger(dst []deployapi.DeploymentTriggerPolicy, trigger deployapi.DeploymentTriggerPolicy) int { + for i := range dst { + if reflect.DeepEqual(dst[i], trigger) { + return i + } + } + return -1 +} + +// mergeBuildTriggers returns an array of BuildTriggerPolicies that have no duplicates, in the same order +// as they exist in their original arrays (a zip-merge). +func mergeBuildTriggers(dst, src []buildapi.BuildTriggerPolicy) []buildapi.BuildTriggerPolicy { + var result []buildapi.BuildTriggerPolicy + for _, current := range dst { + if findBuildTrigger(src, current) != -1 { + result = append(result, current) + } + } + for _, current := range src { + if findBuildTrigger(result, current) == -1 { + result = append(result, current) + } + } + return result +} + +// findBuildTrigger finds the equivalent build trigger position in the provided array, or -1 if +// no such build trigger exists. Equality only cares about the value of the From field. +func findBuildTrigger(dst []buildapi.BuildTriggerPolicy, trigger buildapi.BuildTriggerPolicy) int { + // make a copy for semantic equality + if trigger.ImageChange != nil { + trigger.ImageChange = &buildapi.ImageChangeTrigger{From: trigger.ImageChange.From} + } + for i, copied := range dst { + // make a copy for semantic equality + if copied.ImageChange != nil { + copied.ImageChange = &buildapi.ImageChangeTrigger{From: copied.ImageChange.From} + } + if reflect.DeepEqual(copied, trigger) { + return i + } + } + return -1 +} + +// UpdateTriggersForObject extracts a trigger definition from the provided object, passes it to fn, and +// then applies the trigger definition back on the object. It returns true if the object was mutated +// and an optional error if the any part of the flow returns error. +func UpdateTriggersForObject(obj runtime.Object, fn func(*TriggerDefinition) error) (bool, error) { + // TODO: replace with a swagger schema based approach (identify pod template via schema introspection) + switch t := obj.(type) { + case *deployapi.DeploymentConfig: + triggers := NewDeploymentConfigTriggers(t) + if err := fn(triggers); err != nil { + return true, err + } + return true, triggers.Apply(t) + case *buildapi.BuildConfig: + triggers := NewBuildConfigTriggers(t) + if err := fn(triggers); err != nil { + return true, err + } + return true, triggers.Apply(t) + default: + return false, fmt.Errorf("the object is not a deployment config or build config") + } +} diff --git a/pkg/generate/app/app.go b/pkg/generate/app/app.go index c146d8ce952e..2c1b9ef01fe0 100644 --- a/pkg/generate/app/app.go +++ b/pkg/generate/app/app.go @@ -132,13 +132,13 @@ func (r *SourceRef) BuildSource() (*buildapi.BuildSource, []buildapi.BuildTrigge { Type: buildapi.GitHubWebHookBuildTriggerType, GitHubWebHook: &buildapi.WebHookTrigger{ - Secret: generateSecret(20), + Secret: GenerateSecret(20), }, }, { Type: buildapi.GenericWebHookBuildTriggerType, GenericWebHook: &buildapi.WebHookTrigger{ - Secret: generateSecret(20), + Secret: GenerateSecret(20), }, }, } @@ -358,8 +358,8 @@ func (r *DeploymentConfigRef) DeploymentConfig() (*deployapi.DeploymentConfig, e }, nil } -// generateSecret generates a random secret string -func generateSecret(n int) string { +// GenerateSecret generates a random secret string +func GenerateSecret(n int) string { n = n * 3 / 4 b := make([]byte, n) read, _ := rand.Read(b) diff --git a/test/cmd/triggers.sh b/test/cmd/triggers.sh new file mode 100755 index 000000000000..4d3b00fcdcb5 --- /dev/null +++ b/test/cmd/triggers.sh @@ -0,0 +1,88 @@ +#!/bin/bash + +set -o errexit +set -o nounset +set -o pipefail + +OS_ROOT=$(dirname "${BASH_SOURCE}")/../.. +source "${OS_ROOT}/hack/util.sh" +source "${OS_ROOT}/hack/cmd_util.sh" +os::log::install_errexit + +# Cleanup cluster resources created by this test +( + set +e + oc delete all --all + exit 0 +) &>/dev/null + + +url=":${API_PORT:-8443}" +project="$(oc project -q)" + +# This test validates builds and build related commands + +os::cmd::expect_success 'oc new-app centos/ruby-22-centos7~https://github.com/openshift/ruby-hello-world.git' +os::cmd::expect_success 'oc get bc/ruby-hello-world' +os::cmd::expect_success 'oc get dc/ruby-hello-world' + +## Build configs + +# error conditions +os::cmd::expect_failure_and_text 'oc set triggers bc/ruby-hello-world --remove --remove-all' 'specify either --remove or --remove-all' +os::cmd::expect_failure_and_text 'oc set triggers bc/ruby-hello-world --auto --manual' 'at most one of --auto or --manual' +# print +os::cmd::expect_success_and_text 'oc set triggers bc/ruby-hello-world' 'config.*true' +os::cmd::expect_success_and_text 'oc set triggers bc/ruby-hello-world' 'image.*ruby-22-centos7:latest.*true' +os::cmd::expect_success_and_text 'oc set triggers bc/ruby-hello-world' 'webhook' +os::cmd::expect_success_and_text 'oc set triggers bc/ruby-hello-world' 'github' +# remove all +os::cmd::expect_success_and_text 'oc set triggers bc/ruby-hello-world --remove-all' 'updated' +os::cmd::expect_success_and_not_text 'oc set triggers bc/ruby-hello-world' 'webhook|github' +os::cmd::expect_success_and_text 'oc set triggers bc/ruby-hello-world' 'config.*false' +os::cmd::expect_success_and_text 'oc set triggers bc/ruby-hello-world' 'image.*ruby-22-centos7:latest.*false' +# set github hook +os::cmd::expect_success_and_text 'oc set triggers bc/ruby-hello-world --from-github' 'updated' +os::cmd::expect_success_and_text 'oc set triggers bc/ruby-hello-world' 'github' +os::cmd::expect_success_and_text 'oc set triggers bc/ruby-hello-world --remove --from-github' 'updated' +os::cmd::expect_success_and_not_text 'oc set triggers bc/ruby-hello-world' 'github' +# set webhook +os::cmd::expect_success_and_text 'oc set triggers bc/ruby-hello-world --from-webhook' 'updated' +os::cmd::expect_success_and_text 'oc set triggers bc/ruby-hello-world' 'webhook' +os::cmd::expect_success_and_text 'oc set triggers bc/ruby-hello-world --remove --from-webhook' 'updated' +os::cmd::expect_success_and_not_text 'oc set triggers bc/ruby-hello-world' 'webhook' +# set from-image +os::cmd::expect_success_and_text 'oc set triggers bc/ruby-hello-world --from-image=ruby-22-centos7:other' 'updated' +os::cmd::expect_success_and_text 'oc set triggers bc/ruby-hello-world' 'image.*ruby-22-centos7:other.*true' +# manual and remove both clear build configs +os::cmd::expect_success_and_text 'oc set triggers bc/ruby-hello-world --from-image=ruby-22-centos7:other --manual' 'updated' +os::cmd::expect_success_and_not_text 'oc set triggers bc/ruby-hello-world' 'image.*ruby-22-centos7:other.*false' +os::cmd::expect_success_and_text 'oc set triggers bc/ruby-hello-world --from-image=ruby-22-centos7:other' 'updated' +os::cmd::expect_success_and_text 'oc set triggers bc/ruby-hello-world --from-image=ruby-22-centos7:other --remove' 'updated' +os::cmd::expect_success_and_not_text 'oc set triggers bc/ruby-hello-world' 'image.*ruby-22-centos7:other' +# test --all +os::cmd::expect_success_and_text 'oc set triggers bc --all' 'buildconfigs/ruby-hello-world.*image.*ruby-22-centos7:latest.*false' +os::cmd::expect_success_and_text 'oc set triggers bc --all --auto' 'updated' +os::cmd::expect_success_and_text 'oc set triggers bc --all' 'buildconfigs/ruby-hello-world.*image.*ruby-22-centos7:latest.*true' + +## Deployment configs + +# error conditions +os::cmd::expect_failure_and_text 'oc set triggers dc/ruby-hello-world --from-github' 'deployment configs do not support GitHub web hooks' +os::cmd::expect_failure_and_text 'oc set triggers dc/ruby-hello-world --from-webhook' 'deployment configs do not support web hooks' +os::cmd::expect_failure_and_text 'oc set triggers dc/ruby-hello-world --from-image=test:latest' 'you must specify --containers when setting --from-image' +os::cmd::expect_failure_and_text 'oc set triggers dc/ruby-hello-world --from-image=test:latest --containers=other' 'not all container names exist: other \(accepts: ruby-hello-world\)' +# print +os::cmd::expect_success_and_text 'oc set triggers dc/ruby-hello-world' 'config.*true' +os::cmd::expect_success_and_text 'oc set triggers dc/ruby-hello-world' 'image.*ruby-hello-world:latest \(ruby-hello-world\).*true' +os::cmd::expect_success_and_not_text 'oc set triggers dc/ruby-hello-world' 'webhook' +os::cmd::expect_success_and_not_text 'oc set triggers dc/ruby-hello-world' 'github' +# remove all +os::cmd::expect_success_and_text 'oc set triggers dc/ruby-hello-world --remove-all' 'updated' +os::cmd::expect_success_and_not_text 'oc set triggers dc/ruby-hello-world' 'webhook|github|image' +os::cmd::expect_success_and_text 'oc set triggers dc/ruby-hello-world' 'config.*false' +# auto +os::cmd::expect_success_and_text 'oc set triggers dc/ruby-hello-world --auto' 'updated' +os::cmd::expect_success_and_text 'oc set triggers dc/ruby-hello-world' 'config.*true' +os::cmd::expect_success_and_text 'oc set triggers dc/ruby-hello-world --from-image=ruby-hello-world:latest -c ruby-hello-world' 'updated' +os::cmd::expect_success_and_text 'oc set triggers dc/ruby-hello-world' 'image.*ruby-hello-world:latest \(ruby-hello-world\).*true'