Last updatedDec 28, 2017

How to extend Bamboo Specs

How to extend Bamboo Specs for 3rd party plugins

This documentation explains how to extend Bamboo Specs for your plugins. If you're interested in using Bamboo Specs to manage your Bamboo instance instead, refer to Bamboo Specs documentation

Prerequisites

To begin creating Bamboo Specs for your plugin module, make sure you're familiar with Bamboo Specs documentation and proper plugin module.  

Components of Bamboo Specs

Bamboo Specs consists of 3 complementary components to be implemented: 

  • properties class → model holding your module configuration, 
  • builder class → api which is going to be used by a user
  • exporter class → Bamboo Server side component 

The main part of this tutorial gives general definition and rules which should applied to the mentioned classes. If you want to write a dedicated Bamboo Specs support for a specific plugin module type, refer to the right section of this document:

Properties classes

Properties represent a final form of the module configuration, ready to be used and sent to Bamboo. Properties are immutable objects that hold the state of the module after it had been configured using the correct builder. Here is some key information about task properties:

  • they are usually hidden from users; user-facing documentation doesn't refer to a properties classes, users should operate on a builder classes
  • class must implement the EntityProperties interface
  • class needs to extend the properties base class, e.g. com.atlassian.bamboo.specs.api.model.task.TaskProperties
  • class should be effectively immutable
  • class should implement hashCode and equals methods
  • class names should have Properties suffix
  • there's a couple of rules which needs to be applied to class constructors:

    • class must expose one public constructor which is going to be used by builder class. This constructor may accept parameters and initialise fields. It must call validate method in order to validate current configuration
    • class must expose one parameterless constructor which is going to be used for serialisation/deserialisation, the access may be private, it should initialise default values of the fields and should skip validate method, e.g.

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      @Immutable
      public class MyProperties extends TaskProperties {
          private final String configurationField;
          
          private MyProperties() { //private access
              configurationField = "default value";
              //no validate()
          }
      
          public MyProperties(String configurationField) { //public access 
              this.configurationField = configurationField;
          
              validate();
          }
      }
  • all validation code should be enclosed in validate() method

Properties should be validated. There are couple of validation rules which should be applied:

  • specific properties classes should perform validation after creation, e.g by calling validate method in the constructor, except from parameterless constructor, those are allowed to not pass the validation, as long as it's not a public constructor
  • validation should be consistent with the UI, properly imported entity should be completely valid when accessed by UI
  • property classes can perform only client side validation, which means they can e.g validate if user provided an executable label, however, verification if such label is configured in Bamboo Server should be done in exporter class
  • validation messages should contain reference to a common com.atlassian.bamboo.specs.api.validators.common.ValidationContext  

Builder classes

Specs builders are main classes which will be used by end users to configure Bamboo plans or deployment projects. Builders are mutable entities which should offer seamless configuration of the module, e.g. task or notification.

By design, specs builders provided by Bamboo do not contain the "Builder" suffix in their names, contrary to the general rule of the builder pattern in Java. We recommend you to follow the same pattern. The rationale behind it is to provide the best user experience to users. It's more natural to read/write new Job than new JobBuilder.

Key information about task builders:

  • builders are end-user-facing, therefore, it's advised that they are properly documented with using JavaDocs
  • builder should aim to provide the best user experience; if parts of the UI can't be moved directly to builder code, it's better to provide convenient API methods instead of forcibly mimicking UI screens 
  • class needs to extend base builder class, e.g com.atlassian.bamboo.specs.api.builders.task.Task

  • class must implement EntityPropertiesBuilder interface

  • class should implement a build method which produces properties, it's not forced by the class contract, however, Bamboo Specs export base on existence of this method, you may also consider implementing EntityPropertiesBuilder interface
  • class doesn't need to perform validation as it should be handled by the property classes, however it's recommended to validate methods arguments to achieve 'fail-fast' behaviour
  • default configuration of newly created builder must much the default values which UI screen provides 

Additionally, Bamboo team followed the following conventions, which you may consider applying to be consistent with the core builders:

  • setter which sets single property, doesn't have any prefix, e.g. name()
  • builder doesn't expose any getters, except identifiers (like getKey())
  • there are no setters for collection or maps, there are only additive builder methods which mutate underlying collection. There is no method for clearing the configuration provided, user can recreate a builder object, however, limited number of builder expose clearXXX method. 
  • if you have some shortcut method operating on collection (like setting default values) it should be additive. Prefix addXXX can be used for clarity (that it's additive)
  • we don't provide non-additive collection-related method at all for API simplification; if required, we may provide clean method, e.g. there are no single builder which exports both appendPortMapping(PortMapping) and setPortMappings(List<PortMapping>)**

Exporter classes

Exporter is a plugin module residing in Bamboo, which is responsible for importing and exporting the properties. Exporter is also responsible for more precise, contextful validation.

Key information about the exporter:

  • class should implement validation on top of properties' class validation
  • Bamboo needs to be informed about new exporter by a <exporter /> tag in your atlassian-plugin.xml

In case your component holds any secrets like passwords or SSH and you're using encryption service to properly encrypt them, make sure the task exporter is aware of this and properly imports configuration and does not leak unencrypted secret during export. 

How to organise the code

Bamboo Server at the moment doesn't provide any downloadable JAR which would contain a set of Bamboo Specs builders ready to use by end user. It's up to the plugin vendor to properly organise the plugin code and share appropriate modules with end users. We recommend the following structure:

  • exporter classes should be contained by plugin's module, since they're internal part of the plugin module definition
  • builder and properties class should be in a separate module which depends on bamboo-specs-api. Be aware this module should contain the minimal list of classes and limited dependencies, since it's the module you're going to share with end users. User Bamboo Specs projects shouldn't be cluttered with unnecessary helper classes or 3rd party transitive dependencies.

Export to Bamboo Specs tips

Bamboo provides exportto Bamboo Specs. The feature basically dumps the plan/deployment configuration and uses Bamboo Spec exporter to generate properties classes. Basing on Java class definition and using Java reflection Bamboo generates Bamboo Specs code which can be used to recreate a plan/deployment definition by a user. Here are few rules you should take into consideration when exporting to Bamboo specs:

  • code generator looks for a builder class basing on the properties → builder naming convention, which means CommandTask will be used CommandTaskProperties; you can instrument code generator by com.atlassian.bamboo.specs.api.codegen.annotations.Builder annotation and point it to a proper builder name.

    1
    2
    @Builder(MyTaskBuilder.class)
    class MyTaskProperties extends TaskProperties {}
1
2
class MyTaskBuilder extends Task<MyTaskBuilder, MyTaskProperties> {}
```
  • code generator matches setter in builder class with properties field by name if the name doesn't match; you can tweak this behaviour via: com.atlassian.bamboo.specs.api.codegen.annotations.Setter

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    class MyTaskProperties extends TaskProperties {
        @Setter("setProperty")
        String property;
    }
    
    class MyTask extends Task<MyTask, MyTaskProperties> {
       ...
       public MyTask setProperty(String property) {
            this.property = property;
    
            return this;
       }
    }
  • if you want to skip code generation for a single field, just use com.atlassian.bamboo.specs.api.codegen.annotations.SkipCodeGen

    1
    2
    3
    4
    class MyTaskProperties extends TaskProperties {
        @SkipCodeGen
        String property;
    }
  • com.atlassian.bamboo.specs.api.codegen.annotations.ConstructFrom annotation is useful if a builder class doesn't expose public constructor without any parameters

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    @ConstructFrom({"config1", "config2"})
    class MyTaskProperties extends TaskProperties {
        String config1;
        String config2;
    }
    
    class MyTask extends Task<MyTask, MyTaskProperties> {
       public MyTask(String config1, String config2) {
          this.config1 = config1;
          this.config2 = config2;
       }
    }
  • if any of the default rules are not applicable to a specific use case, com.atlassian.bamboo.specs.api.codegen.annotations.CodeGenerator annotation can be used to introduce a dedicated code emitter which will take an top level instance of a properties class or any of its field and generate Bamboo Specs builders code

    1
    2
    3
    4
    class MavenTaskProperties extends TaskProperties {
        @CodeGenerator(MavenVersionEmitter.class)
        protected int version = MavenTask.MAVEN_V3;
    }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
class MavenTask extends Task<MavenTask, MavenTaskProperties> {
    protected int version = MAVEN_V3;


    public MavenTask version2() {
        this.version = MAVEN_V2;
        return this;
    }

    public MavenTask version3() {
        this.version = MAVEN_V3;
        return this;
    }
}

class MavenVersionEmitter implements CodeEmitter<Integer> {
    public String emitCode(@NotNull final CodeGenerationContext context, @NotNull final Integer value) throws CodeGenerationException {
        return ".version" + value + "()";
    }
}


@CodeGenerator(MyCodeEmitter.class)
class MyTaskProperties extends TaskProperties {}

class MyTask extends Task<MyTask, MyTaskProperties> {}


class MyCodeEmitter implements CodeEmitter<MyTaskProperties> {
    public String emitCode(@NotNull final CodeGenerationContext context, @NotNull final MyTaskProperties value) 
        throws CodeGenerationException {
        
        //Very specific code emitter for MyTask
    }
}
```

If Bamboo Server can't generate code for a particular plugin, it'll export appropriate Java code comment .

How Bamboo Specs exporter deals with default values

Bamboo Specs exporter minimizes the size of its output but omitting fields that are set to their default values. In most cases that simply means skipping the fields that are set to null, empty string or empty collection. For instance, if a Job has no tasks the call to .tasks() method of the Job class will not be generated.

The behavior becomes more complex when non-empty default values are involved. Consider this example:

1
2
3
Plan plan = new Plan(new Project()
                .key(new BambooKey("PROJECT"), "Plan name", PLANKEY)
                .enabled(true);

Since the default value of enabled is true, the code above is equivalent of the following, shorter:

1
2
Plan plan = new Plan(new Project()
                .key(new BambooKey("PROJECT"), "Plan name", PLANKEY);

Bamboo Specs exporter produces the latter code thanks to the following procedure:

  • when exporting a properties class, exporter creates another instance of the class by calling its default value provider.
  • if a field of the instance exported is equal to the value of the same field inside 'default' instance`, the code corresponding to that field is not produced

Default value provider is (typically), properties class'es parameterless constructor.

If a method annotated with  @DefaultFieldValues annotation is present, it is used instead of the constructor. Such method:

  • must be static
  • must not accept any parameters
  • must return the appropriate property class

By following these rules you can ensure that Bamboo Spec exporter produces the shortest possible, yet valid code:

  • if a field of a properties class has a default value, you need to ensure that both its builder and default value provider set that field to the same value
  • properties classes must implement equals() and hashCode(). These methods should take into account all relevant fields of the properties class.
  • non-primitive fields of properties classes must provide correct equals() method

Rule of thumb: it's better if parameterless constructor initialises fields to null/empty than if it uses incorrect default value. In the first case exporter produces some superflous but correct code, in the latter the resulting code might not accurately represent the exported entity.

How to write Bamboo Specs for tasks

This section describes adding Bamboo Specs support for your custom task module. Before reading this section, please make sure you're familiar writing custom tasks and you've read the first part of this document. In this tutorial we're assuming that a custom task has already been implemented. 

How to start? 

First of all you will need a properties class which will be used as a data model which holds configuration for your task. Follow the common rules to make sure it's compatible with Bamboo Specs. Properties class must implement EntityProperties interface, however, it's going to be more convenient extend com.atlassian.bamboo.specs.api.model.task.TaskProperties.

Your task supports one of the following:

  • build plans
  • deployments
  • build plans and deployments (default)

If you want to provide support to builds plan or deployment only, override the applicableTo with a correct value. 

CommandTaskProperties.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
@Immutable
public final class CommandTaskProperties extends TaskProperties {
    private static final AtlassianModuleProperties ATLASSIAN_PLUGIN =
            new AtlassianModuleProperties("com.atlassian.bamboo.plugins.scripttask:task.builder.command");
    private static final ValidationContext VALIDATION_CONTEXT = ValidationContext.of("Command task");

    @NotNull private final String executable;
    @Nullable private final String argument;
    @Nullable private final String environmentVariables;
    @Nullable private final String workingSubdirectory;

    // for importing
    private CommandTaskProperties() {
        this.executable = null;
        this.argument = null;
        this.environmentVariables = null;
        this.workingSubdirectory = null;
    }

    public CommandTaskProperties(@Nullable final String description,
                                 final boolean enabled,
                                 @NotNull final String executable,
                                 @Nullable final String argument,
                                 @Nullable final String environmentVariables,
                                 @Nullable final String workingSubdirectory) throws PropertiesValidationException {
        super(description, enabled);

        this.executable = executable;
        this.argument = argument;
        this.environmentVariables = environmentVariables;
        this.workingSubdirectory = workingSubdirectory;

        validate();
    }

    @Override
    public void validate() throws PropertiesValidationException {
        super.validate();

        checkThat(VALIDATION_CONTEXT, StringUtils.isNotBlank(executable), "Executable is not defined");
    }

    // Equals and hash code
    // ...

    // Getters
    // ...
}

Once you have a properties class, you will need a builder class which is used to build it. Make sure your builder class fulfils common builder rules. Consider extending abstract com.atlassian.bamboo.specs.api.builders.task.Task which should help in implementing your builder.

CommandTask.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
/**
 * Represents a task that executes a command.
 */
public class CommandTask extends Task<CommandTask, CommandTaskProperties> { 
    @NotNull private String executable;
    @Nullable private String argument;
    @Nullable private String environmentVariables;
    @Nullable private String workingSubdirectory;

    /**
     * Sets label (<em>not a path</em>) of command to be executed. This label must be first
     * defined in the GUI on the Administration/Executables page.
     */
    public CommandTask executable(@NotNull final String executable) {
        checkNotEmpty("executable", executable);
        this.executable = executable;
        return this;
    }

    /**
     * Sets command line argument to be passed when command is executed.
     */
    public CommandTask argument(@Nullable final String argument) {
        this.argument = argument;
        return this;
    }

    /**
     * Sets environment variables to be set when command is executed.
     */
    public CommandTask environmentVariables(@Nullable final String environmentVariables) {
        this.environmentVariables = environmentVariables;
        return this;
    }

    /**
     * Sets a directory the command should be executed in.
     */
    public CommandTask workingSubdirectory(@Nullable final String workingSubdirectory) {
        this.workingSubdirectory = workingSubdirectory;
        return this;
    }

    @NotNull
    @Override
    protected CommandTaskProperties build() {
        return new CommandTaskProperties(
                description,
                taskEnabled,
                executable,
                argument,
                environmentVariables,
                workingSubdirectory);
    }
}

The last step is to write exporter class to implement logic responsible for importing and exporting your task's configuration. Note that the previous components were a part of Bamboo Specs and they are directly (builders) or indirectly (properties) used by users. An exporter is a part of Bamboo Server and it's required to have it bundled in your plugin JAR. An exporter consists of two elements: 

  • class which implements com.atlassian.bamboo.task.export.TaskDefinitionExporter interface
  • <exporter/> entry in your task's plugin module definition (atlassian-plugin.xml

Note that an exporter works in the Bamboo Server context, which means it has access to all OSGI services Bamboo exports. You can use it to implement more in depth validation e.g. while there's no way to validate if an executable label provided by user is valid on the properties class level, it is possible to do it in the exporter. 

CommandTaskExporter.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
public class CommandTaskExporter implements TaskDefinitionExporter {
    private static final ValidationContext COMMAND_CONTEXT = ValidationContext.of("Command task");

    @Autowired
    private UIConfigSupport uiConfigSupport;


    @NotNull
    @Override
    public CommandTask toSpecsEntity(@NotNull final TaskDefinition taskDefinition) {
        final Map<String, String> configuration = taskDefinition.getConfiguration();
        return new CommandTask()
                .executable(configuration.get(CommandConfig.CFG_SCRIPT))
                .argument(configuration.getOrDefault(CommandConfig.CFG_ARGUMENT, null))
                .environmentVariables(configuration.getOrDefault(TaskConfigConstants.CFG_ENVIRONMENT_VARIABLES, null))
                .workingSubdirectory(configuration.getOrDefault(TaskConfigConstants.CFG_WORKING_SUBDIRECTORY, null));
    }

    @Override
    public Map<String, String> toTaskConfiguration(@NotNull TaskContainer taskContainer, final TaskProperties taskProperties) {
        final CommandTaskProperties commandTaskProperties = Narrow.downTo(taskProperties, CommandTaskProperties.class);
        Preconditions.checkState(commandTaskProperties != null, "Don't know how to import task properties of type: " + taskProperties.getClass().getName());

        final Map<String, String> cfg = new HashMap<>();
        cfg.put(CommandConfig.CFG_SCRIPT, commandTaskProperties.getExecutable());
        if (commandTaskProperties.getArgument() != null)
                cfg.put(CommandConfig.CFG_ARGUMENT, commandTaskProperties.getArgument());
        if (commandTaskProperties.getEnvironmentVariables() != null)
                cfg.put(CommandConfig.CFG_ENVIRONMENT_VARIABLES, commandTaskProperties.getEnvironmentVariables());
        if (commandTaskProperties.getWorkingSubdirectory() != null) 
                cfg.put(TaskConfigConstants.CFG_WORKING_SUBDIRECTORY, commandTaskProperties.getWorkingSubdirectory());
        return cfg;
    }

    @Override
    public List<ValidationProblem> validate(@NotNull TaskValidationContext taskValidationContext, @NotNull TaskProperties taskProperties) {
        final List<ValidationProblem> validationProblems = new ArrayList<>();
        final CommandTaskProperties commandTaskProperties = Narrow.downTo(taskProperties, CommandTaskProperties.class);
        if (commandTaskProperties != null) {
            final List<String> labels = uiConfigSupport.getExecutableLabels(CommandConfig.CAPABILITY_SHORT_KEY);
            final String label = commandTaskProperties.getExecutable();
            if (labels == null || !labels.contains(label)) {
                validationProblems.add(new ValidationProblem(
                        COMMAND_CONTEXT, "Can't find executable by label: '" + label + "'. Available values: " + labels));
            }
        }
        return result;
    }
}

atlassian-plugin.xml

1
2
3
4
5
6
<atlassian-plugin ...>
  <taskType key="task.builder.command" name="Command" class="com.atlassian.bamboo.plugins.command.task.CommandBuildTask">
    ...
    <exporter class="com.atlassian.bamboo.plugins.command.task.export.CommandTaskExporter"/>
  </taskType>
</atlassian-plugin>

Using the AnyTask as an alternative

Even without a proper builder, properties and exporter classes, it's possible to use Bamboo Specs to import custom tasks. The com.atlassian.bamboo.specs.api.builders.task.AnyTask can be used instead of a dedicated com.atlassian.bamboo.specs.api.builders.task.Task implementation, for example when one is not yet implemented.

The  AnyTask is a generic specs builder which accepts two main configuration options: Atlassian plugin module key of the task, and tasks configuration map.

  • The plugin module key is the key of the task that's to be imported. It should match your custom task module key - it's build from Atlassian plugin key and the plugin module key, e.g. the Ant task type is recognised by the com.atlassian.bamboo.plugins.ant:task.builder.ant

    1
    2
    3
    4
    5
    6
    7
    8
    <atlassian-plugin key="com.atlassian.bamboo.plugins.ant" name="${pom.name}" pluginsVersion="1">
    
      ...
      <taskType key="task.builder.ant" name="Ant" class="com.atlassian.bamboo.plugins.ant.task.AntBuildTask">
          ...
      </taskType>
      ...
    </atlassian-plugin>
  • The configuration map is simple task configuration

Generally speaking AnyTask should be used as a last resort and dedicated builders should be favoured, because:

  • it's harder to use and more error prone, user needs to know all configuration keys of the tasks and the task module key itself, which of both are not easily discoverable by the end users
  • it can't perform any kind of validation, which means implementation of the task itself must be more more defensive to a wrong configuration.

How to write Bamboo Specs for triggers

This section describes adding Bamboo Specs support for your custom trigger reason module. Before reading this section, make sure you're familiar writing trigger reason plugin module and you've read the first part of this document. In this tutorial we're assuming that a trigger has already been implemented. 

How to start? 

First of all you will need a properties class which will be used as a data model which holds configuration for your task. Follow the common rules to make sure it's compatible for Bamboo Specs. Properties class must implement EntityProperties interface, however, it's going to be more convenient extend com.atlassian.bamboo.specs.api.model.trigger.TriggerProperties. However, if your tigger cooperates with any repository via triggering repositories it's better if you use com.atlassian.bamboo.specs.api.model.trigger.RepositoryBasedTriggerProperties as a base class. Otherwise the triggering repositories might not be imported properly.

Your trigger supports one of the following:

  • build plans
  • deployments
  • build plans and deployments (default) If you want to provide support to builds plan or deployment only, override the applicableTo with a correct value.

RepositoryPollingTriggerProperties.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
@Immutable
public final class RepositoryPollingTriggerProperties extends RepositoryBasedTriggerProperties {
    public static final String MODULE_KEY = "com.atlassian.bamboo.triggers.atlassian-bamboo-triggers:poll";
    private static final String NAME = "Repository polling";
    private static final AtlassianModuleProperties MODULE = EntityPropertiesBuilders.build(new AtlassianModule(MODULE_KEY));

    private static final int DEFAULT_POLLING_PERIOD = preventInlining(180);

    private final Duration pollingPeriod;
    private final String cronExpression;
    private final PollType pollType;

    private RepositoryPollingTriggerProperties() {
        super();

        pollingPeriod = Duration.ofSeconds(DEFAULT_POLLING_PERIOD);
        cronExpression = null;
        pollType = PollType.PERIOD;
    }

    public RepositoryPollingTriggerProperties(final String description,
                                              final boolean isEnabled,
                                              final TriggeringRepositoriesType triggeringRepositoriesType,
                                              final List<VcsRepositoryIdentifierProperties> triggeringRepositories,
                                              final String cronExpression) {
        super(NAME, description, isEnabled, triggeringRepositoriesType, triggeringRepositories);
        this.cronExpression = cronExpression;
        this.pollingPeriod = null;
        pollType = PollType.CRON;
        validate();
    }

    public RepositoryPollingTriggerProperties(final String description,
                                              final boolean isEnabled,
                                              final TriggeringRepositoriesType triggeringRepositoriesType,
                                              final List<VcsRepositoryIdentifierProperties> selectedTriggeringRepositories,
                                              final Duration pollingPeriod) {
        super(NAME, description, isEnabled, triggeringRepositoriesType, selectedTriggeringRepositories);
        this.cronExpression = null;
        this.pollingPeriod = pollingPeriod;
        pollType = PollType.PERIOD;
        validate();
    }


    @Override
    public void validate() {
        super.validate();

        if (pollType == null) {
            throw new PropertiesValidationException("Can't create repository polling trigger without any polling type");
        }

        switch (pollType) {
            case PERIOD:
                checkNotNull("pollingPeriod", pollingPeriod);
                checkPositive("pollingPeriod", (int) pollingPeriod.getSeconds());
                break;
            case CRON:
                CronExpressionClientSideValidator.validate(cronExpression);
                break;
            default:
                throw new PropertiesValidationException("Can't create repository polling trigger - unknown polling type: " + pollType);
        }
    }

    public enum PollType {
        PERIOD,
        CRON
    }

    // Equals and hash code
    // ...

    // Getters
    // ...
}

Once you have a properties class you will need a builder class which is used to build it. Make sure your builder class fulfils common builder rules. Consider extending abstract com.atlassian.bamboo.specs.api.builders.trigger.Trigger which should help in implementing your builder. However,com.atlassian.bamboo.specs.api.builders.trigger.RepositoryBasedTrigger might be better suited if your trigger cooperates with triggering repositories. 

RepositoryPollingTrigger.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
/**
 * Represents repository polling trigger.
 */
public class RepositoryPollingTrigger extends RepositoryBasedTrigger<RepositoryPollingTrigger, RepositoryPollingTriggerProperties> {
    private static final EnumSet<TimeUnit> APPLICABLE_TIME_UNITS = EnumSet.of(TimeUnit.SECONDS,
            TimeUnit.MINUTES,
            TimeUnit.HOURS,
            TimeUnit.DAYS);

    private RepositoryPollingTriggerProperties.PollType pollType;
    private Duration pollingPeriod;
    private String cronExpression;

    /**
     * Creates repository polling trigger.
     */
    public RepositoryPollingTrigger() {
        triggeringRepositoriesType = TriggeringRepositoriesType.ALL;
        pollingPeriod = Duration.ofSeconds(180);
        cronExpression = null;
        pollType = RepositoryPollingTriggerProperties.PollType.PERIOD;
    }

    /**
     * Specifies how often (in {@link TimeUnit}) Bamboo should check the repository for changes.
     * Time units smaller than {@link TimeUnit#SECONDS} won't be accepted.
     * Default value is 180 seconds.
     *
     * @see #withPollingPeriod(Duration)
     */
    public RepositoryPollingTrigger pollEvery(int every, @NotNull TimeUnit timeUnit) {
        checkNotNull("timeUnit", timeUnit);
        checkPositive("pollingPeriod", every);
        checkArgument(ValidationContext.empty(), APPLICABLE_TIME_UNITS.contains(timeUnit),
                "Polling is available only with seconds, minutes and hours based period");

        pollingPeriod = Duration.ofSeconds(timeUnit.toSeconds(every));

        pollType = PERIOD;
        cronExpression = null;
        return this;
    }

    /**
     * Specifies time interval between checks for changes in the repositories.
     * Duration smaller than a second won't be accepted.
     * Default value is 180 seconds.
     *
     * @see #pollEvery(int, TimeUnit)
     */
    public RepositoryPollingTrigger withPollingPeriod(@NotNull Duration duration) {
        checkNotNull("duration", duration);
        checkArgument(ValidationContext.empty(), duration.getSeconds() > 0, "Polling interval cannot be shorted than one second");

        pollingPeriod = duration;
        pollType = PERIOD;
        cronExpression = null;
        return this;
    }

    /**
     * Selects polling type for this trigger. Possible values:
     * <dl>
     * <dt>PERIOD</dt>
     * <dd>Poll in defined intervals.</dd>
     * <dt>CRON</dt>
     * <dd>Poll according to cron expression.</dd>
     * </dl>
     */
    public RepositoryPollingTrigger withPollType(@NotNull RepositoryPollingTriggerProperties.PollType pollType) {
        checkNotNull("pollType", pollType);
        this.pollType = pollType;
        return this;
    }

    /**
     * Orders Bamboo to check repository for changes once daily at specified time.
     */
    public RepositoryPollingTrigger pollOnceDaily(@NotNull LocalTime at) {
        return pollWithCronExpression(CronExpressionCreationHelper.scheduleOnceDaily(at));
    }

    /**
     * Orders Bamboo to check repository for changes weekly at specified days of week and time.
     */
    public RepositoryPollingTrigger pollWeekly(@NotNull LocalTime at, DayOfWeek... onDays) {
        return pollWithCronExpression(CronExpressionCreationHelper.scheduleWeekly(at, onDays));
    }

    /**
     * Orders Bamboo to check repository for changes weekly at specified days of week and time.
     */
    public RepositoryPollingTrigger pollWeekly(@NotNull LocalTime at, @NotNull Collection<DayOfWeek> days) {
        return pollWithCronExpression(CronExpressionCreationHelper.scheduleWeekly(at, days));
    }

    /**
     * Orders Bamboo to check repository for changes once monthly at specified day of month and time.
     */
    public RepositoryPollingTrigger pollMonthly(@NotNull LocalTime at, int dayOfMonth) {
        return pollWithCronExpression(CronExpressionCreationHelper.scheduleMonthly(at, dayOfMonth));
    }

    /**
     * Orders Bamboo to check repository for changes based on given cron expression.
     */
    public RepositoryPollingTrigger pollWithCronExpression(@NotNull String cronExpression) {
        checkNotBlank("cronExpression", cronExpression);
        this.cronExpression = cronExpression;
        pollType = CRON;
        this.pollingPeriod = null;
        return this;
    }

    @Override
    protected RepositoryPollingTriggerProperties build() {
        switch (pollType) {
            case PERIOD:
                return new RepositoryPollingTriggerProperties(description, triggerEnabled, triggeringRepositoriesType, selectedTriggeringRepositories, pollingPeriod);
            case CRON:
                return new RepositoryPollingTriggerProperties(description, triggerEnabled, triggeringRepositoriesType, selectedTriggeringRepositories, cronExpression);
            default:
                throw new AssertionError(String.format("Don't know what %s is.", pollType));
        }
    }
}

The last step is to write an exporter class to implement logic responsible for importing and exporting your trigger's configuration. Note that previous components were part of Bamboo Specs and they are directly (builders) or indirectly (properties) used by users. An exporter is a part of Bamboo Server and it's required to have it bundled in your plugin JAR. Exporters consist of two elements: 

  • class which implements com.atlassian.bamboo.trigger.export.TriggerDefinitionExporter interface
  • <exporter/> entry in your trigger's plugin module definition (atlassian-plugin.xml

Please note, exporter works in the Bamboo Server context, which means it has access to all OSGI services Bamboo exports. You can use it to implement more in depth validation. Please note, the exporter must import only the configuration of your trigger. If you're using triggering repositories you should use RepositoryBasedTriggerProperties as a base class. Bamboo will detect such situation and properly import the repositories ids.

CommandTaskExporter.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
public class RepositoryPollingTriggerDefinitionExporter implements TriggerDefinitionExporter {
    static final String TRIGGER_CRON_EXPRESSION_KEY = "repository.change.poll.cronExpression";
    static final String TRIGGER_PERIOD_KEY = "repository.change.poll.pollingPeriod";
    static final String TRIGGER_POLL_TYPE_KEY = "repository.change.poll.type";

    private final ValidationContext validationContext = ValidationContext.of("Repository polling trigger");

    @NotNull
    @Override
    public Map<String, String> toTriggerConfiguration(@NotNull TriggerProperties triggerProperties, @NotNull Triggerable triggerable) {
        final RepositoryPollingTriggerProperties trigger = Narrow.downTo(triggerProperties, RepositoryPollingTriggerProperties.class);
        checkArgument(validationContext, trigger != null, "Don't know how to handle: " + triggerProperties.getClass());

        final Map<String, String> configuration = Maps.newHashMap();
        configuration.put(TRIGGER_POLL_TYPE_KEY, trigger.getPollType().toString());
        switch (trigger.getPollType()) {
            case PERIOD:
                configuration.put(TRIGGER_PERIOD_KEY, String.valueOf(trigger.getPollingPeriod().getSeconds()));
                break;
            case CRON:
                configuration.put(TRIGGER_CRON_EXPRESSION_KEY, String.valueOf(trigger.getCronExpression()));
                break;
        }

        return configuration;
    }

    @NotNull
    @Override
    public Trigger toSpecsEntity(@NotNull TriggerDefinition triggerDefinition) {
        RepositoryPollingTrigger repositoryPollingTrigger = new RepositoryPollingTrigger();

        RepositoryPollingTriggerProperties.PollType pollType =
                RepositoryPollingTriggerProperties.PollType
                        .valueOf(triggerDefinition.getConfiguration().get(TRIGGER_POLL_TYPE_KEY));

        switch (pollType) {
            case CRON:
                repositoryPollingTrigger.pollWithCronExpression(triggerDefinition.getConfiguration().get(TRIGGER_CRON_EXPRESSION_KEY));
                break;
            case PERIOD:
                repositoryPollingTrigger.pollEvery(Integer.parseInt(triggerDefinition.getConfiguration().get(TRIGGER_PERIOD_KEY)), TimeUnit.SECONDS);
                break;
        }
        return repositoryPollingTrigger;
    }

    @Override
    public List<ValidationProblem> validate(@NotNull TriggerValidationContext triggerValidationContext, @NotNull TriggerProperties triggerProperties) {
        List<ValidationProblem> problems = Lists.newArrayList();
        RepositoryPollingTriggerProperties trigger = Narrow.downTo(triggerProperties, RepositoryPollingTriggerProperties.class);

        if (trigger != null) {
            switch (trigger.getPollType()) {

                case CRON:
                    if (!CronExpression.isValidExpression(trigger.getCronExpression())) {
                        problems.add(new ValidationProblem(String.format("The cron expresion: %s is not valid", trigger.getCronExpression())));
                    }
                    break;
                //no specific server side validation for PERIOD poll type
            }
            RepositoryTriggersValidator.validateRepositoryTriggers(triggerValidationContext, trigger, problems);
        } else {
            problems.add(new ValidationProblem("Don't know how to validate " + triggerProperties.getClass()));

        }

        return problems;
    }
}

atlassian-plugin.xml

1
2
3
4
5
6
7
<atlassian-plugin ...>
  ...
  <triggerType key="poll" name="Repository polling" class="com.atlassian.bamboo.trigger.polling.PollingTriggerActivator">
    ...
     <exporter class="com.atlassian.bamboo.trigger.exporters.RepositoryPollingTriggerDefinitionExporter"/>
  </triggerType>
</atlassian-plugin>

Using the AnyTrigger as an alternative

Even without a proper builder, properties and exporter classes, it's possible to use Bamboo Specs to import custom triggers. The com.atlassian.bamboo.specs.api.builders.trigger.AnyTrigger can be used instead of a dedicated com.atlassian.bamboo.specs.api.builders.trigger.Trigger implementation, for example when one is not yet implemented.

The  AnyTrigger is a generic specs builder which accepts two main configuration options: Atlassian plugin module key of the trigger, and the trigger's configuration map.

  • The plugin module key is the key of the task that's to be imported. It should match your custom task module key, it's build from Atlassian plugin key and the plugin module key, e.g. the polling trigger type is recognised by the com.atlassian.bamboo.triggers.atlassian-bamboo-triggers:poll

    1
    2
    3
    4
    5
    6
    7
    8
    9
    <atlassian-plugin key="com.atlassian.bamboo.triggers.atlassian-bamboo-triggers" ...>
    
      ...
      <triggerType key="poll" name="Repository polling" class="com.atlassian.bamboo.trigger.polling.PollingTriggerActivator">
        ...
      </triggerType>
    
      ...
    </atlassian-plugin>
  • The configuration map simply represents a trigger's configuration

Generally speaking AnyTrigger should be used as a last resort and dedicated builders should be favoured, because:

  • it's harder to use and more error prone, user needs to know all configuration keys of the triggers and the trigger module key itself, which of both are not easily discoverable by the end users
  • it can't perform any kind of validation, which means implementation of the trigger activator itself must be more more defensive against a wrong configuration.