Idiomatic way to make a Gradle task dependent conditionally on another task in plugin development

Imagine I have a convention plugin that defines a generic buildImage task based on a Dockerfile that is assumed to be in the main project directory (in my use case, I’m using the upstream gradle-docker-plugin).

Now imagine I want to improve my convention plugin, and define a standard way to generate a Dockerfile by defining another task generateDockerfile. But I still want to allow client projects to use their own in-project Dockerfile if they want to (as an opt-in feature).

So I create an extension so that users of this plugin can use a custom DSL to configure whether they want the plugin to automatically generate their Dockerfile (by passing some configuration) or rather use their own in-project Dockerfile. The latter would look like something similar to:

// Final project's build script
docker {
  dockerfile(file('Dockerfile')) // I want to use my own Dockerfile
}

Therefore, here’s the plugin code (simplified):

// Docker convention plugin

// Extension for configuring
interface DockerExtension {
  RegularFileProperty getDockerfile()
  // other properties to configure the generated dockerfile
}
extensions.create("docker", DockerExtension)

// Build image task
tasks.register("buildImage", DockerBuildImage) {
  ...
}

// Generate Dockerfile task
tasks.register("generateDockerfile", Dockerfile) {
  onlyIf { !docker.dockerfile.isPresent() }
  inputs.property('dockerfile', docker.dockerfile).optional(true)

  // Instructions to generate Dockerfile
  // ...
}

So the question is: what is the best/most idiomatic way I can configure the buildImage task so that its inputs depend CONDITIONALLY on the outputs of generateDockerfile task, if dockerfile property is not present (no dependency otherwise).

Here’s the options I’m considering. I’d like to know if I’m missing something and if there is a best-practice way to solve this.

Option 1: dependsOn

tasks.named('buildImage') {
  inputs.property('dockerfile', docker.dockerfile).optional(true)
  if (!docker.dockerfile.isPresent()) {
    dependsOn 'generateDockerfile'
  }
}

Option 2: inputs/outputs wire-up

tasks.named('buildImage') {
  inputs.property('dockerfile', docker.dockerfile).optional(true)
  if (!docker.dockerfile.isPresent()) {
    inputs.files(tasks.getByName('generateDockerfile').outputs.files)
  }
}

Option 3: separate the plugins

This option consists of breaking down the plugin into two plugins: a ‘base’ one with the buildImage logic, and a specialized one for those clients that want to opt-in to the unified Dockerfile facility. In this case, I don’t need conditional logic in the latter because I can assume that the final project does want the standardised Dockerfile so I can wire-up the two tasks unconditionally. I would not need the extension’s property either.

When picking a solution, think about the possibility of scaling it too. E.g. different ways of generating the unified Dockerfile. With the fist two options, representing the property gets a bit more cumbersome, maybe I’d need a nested object in my DSL, then I’d need a task for each case (e.g. generateFooDockerfile, generateBarDockerfile, etc). In the last option, I would need a “special” plugin for each of these cases, each having their own simple extension to configure the various arguments.

Concerns

I have tried all the above solutions and they seem to work, but I am concerned about whether eager-vs-lazy configuration matters here. I.e. I understand the condition if (!docker.dockerfile.isPresent()) checks the property eagerly. Is this a problem or a bad-practice? If so, why, and what’s its lazy counterpart?

Or should I not be concerned at all because tasks.register() and tasks.named() both act lazily anyway?

Please notice this is a Gradle DSL question, the docker use case is purely incidental.

Option 1 definitely not, any manual dependsOn is a code-smell unless a lifecycle task is on the left-hand side.
Besides that, it also will not work reliably, as it requires that the dockerFile property is already set when the buildImage task is configured, which is against the intended laziness of Propertys and can easily happen if something breaks task-configuration avoidance for the buildImage task.

Option 2 would hopefully throw an exception just like when you use @Input on a file-based input property. If it does not, a bug-report should be opened to add this.
And besides that, it has the same problem mentioned for Option 1 regarding unreliability.

Additionally, you break laziness of generateDockerFile task in Option 2 as you use getByName and thus force realisation whether it would be necessary or not.

Why don’t you simply declare both as inputs?
Like this:

val baz = objects.fileProperty().value(layout.projectDirectory.file("gradlew"))
val boo = objects.fileProperty()

val bar by tasks.registering {
    outputs.file(layout.buildDirectory.file("bar.txt"))
    onlyIf { false }
    doLast {}
}

val foo by tasks.registering {
    inputs.file(baz).optional()
    inputs.files(bar)
    doLast {
        inputs.files.forEach { println(it) }
    }
}

val bam by tasks.registering {
    inputs.file(boo).optional()
    inputs.files(bar)
    doLast {
        inputs.files.forEach { println(it) }
    }
}

A split of the plugins is also an options of course.

Or alternatively add a function to your extension that adds the generation task, so the user can simply opt-in to the generation using the extension.
And by specifying arguments or providing multiple such functions you can also provide the option to add different tasks.

Thanks for your reply,

there’s something I don’t understand about your example.

If I run either task foo or bam, the file bar.txt will be among inputs in both cases.

In my scenario, it should be the other way around. I don’t want the generated file to affect the task tracking if the property specifying to use a static file is set.

So your example doesn’t seem to fit my use case.

The file will be absent and thus not change the up-to-dateness, so why are you concerned?

You can also use bar.map { it.outputs.files.asFileTree } to only consider it an input if it exists if that feels better.

Thanks, it makes sense.

Now I’m only slightly concerned about the onlyIf line on the file generator task.

onlyIf { !docker.dockerfile.isPresent() }

But the documentation says the closure is evaluated at execution time, so it shouldn’t matter that it uses isPresent() which is an eager call instead, am I correct?

That would be the key factor that would prevent the task from running.

Yes, onlyIf is done at execution time right before the task would or would not run, so there checking isPresent() is perfectly fine.

1 Like