From c2b25da59de7be6d9c9587761a299e87d04bc0e1 Mon Sep 17 00:00:00 2001 From: Nick Josevski Date: Wed, 12 Aug 2026 18:07:14 +1000 Subject: [PATCH] feat: add Run runbook step Adds an "OctopusDeploy: Run runbook" build step so a TeamCity build can run an Octopus runbook, closing the gap where only releases could be deployed. Closes #178 Co-Authored-By: Claude Opus 5 (1M context) --- .../teamcity/e2e/dsl/OctopusProvisioning.java | 114 +++++++++++++++- .../teamcity/e2e/dsl/TeamCityRest.java | 27 ++++ .../OctopusConnectionFormSmokeUiTest.java | 2 + .../e2e/test/OctopusRunRunbookE2ETest.java | 79 +++++++++++ .../agent/OctopusRunRunbookBuildProcess.java | 121 +++++++++++++++++ .../agent/OctopusRunRunbookRunner.java | 58 ++++++++ .../teamcity/agent/cli/CommandHelper.java | 61 +++++++++ .../teamcity/agent/cli/CommandUtils.java | 4 + .../agent/cli/RunRunbookBuildProcess.java | 68 ++++++++++ .../build-agent-plugin-Octopus.TeamCity.xml | 1 + .../OctopusRunRunbookBuildProcessTest.java | 93 +++++++++++++ .../teamcity/agent/cli/CommandHelperTest.java | 59 ++++++++ .../teamcity/common/OctopusConstants.java | 9 ++ .../server/OctopusRunRunbookRunType.java | 106 +++++++++++++++ .../OctopusConnectionBuildStartProcessor.java | 1 + .../build-server-plugin-Octopus.TeamCity.xml | 1 + .../forms/editOctopusRunRunbookForm.jsp | 126 ++++++++++++++++++ .../viewOctopusRunRunbook.jsp | 53 ++++++++ ...ctopusRunRunbookRunTypeValidationTest.java | 101 ++++++++++++++ 19 files changed, 1078 insertions(+), 6 deletions(-) create mode 100644 e2e/src/test/java/octopus/teamcity/e2e/test/OctopusRunRunbookE2ETest.java create mode 100644 octopus-agent/src/main/java/octopus/teamcity/agent/OctopusRunRunbookBuildProcess.java create mode 100644 octopus-agent/src/main/java/octopus/teamcity/agent/OctopusRunRunbookRunner.java create mode 100644 octopus-agent/src/main/java/octopus/teamcity/agent/cli/RunRunbookBuildProcess.java create mode 100644 octopus-agent/src/test/java/octopus/teamcity/agent/OctopusRunRunbookBuildProcessTest.java create mode 100644 octopus-server/src/main/java/octopus/teamcity/server/OctopusRunRunbookRunType.java create mode 100644 octopus-server/src/main/resources/buildServerResources/forms/editOctopusRunRunbookForm.jsp create mode 100644 octopus-server/src/main/resources/buildServerResources/viewOctopusRunRunbook.jsp create mode 100644 octopus-server/src/test/java/octopus/teamcity/server/OctopusRunRunbookRunTypeValidationTest.java diff --git a/e2e/src/test/java/octopus/teamcity/e2e/dsl/OctopusProvisioning.java b/e2e/src/test/java/octopus/teamcity/e2e/dsl/OctopusProvisioning.java index be608df5..2afb88f7 100644 --- a/e2e/src/test/java/octopus/teamcity/e2e/dsl/OctopusProvisioning.java +++ b/e2e/src/test/java/octopus/teamcity/e2e/dsl/OctopusProvisioning.java @@ -15,6 +15,7 @@ import java.util.Map; import com.google.gson.JsonArray; +import com.google.gson.JsonElement; import com.google.gson.JsonObject; import com.google.gson.JsonParser; @@ -115,15 +116,103 @@ private static String createLifecycleWithPhases( return JsonParser.parseString(resp.body()).getAsJsonObject().get("Id").getAsString(); } + /** + * Adds a runbook with the same single run-on-server script step to an existing project, and + * publishes a snapshot so it can be run without naming one. Returns the runbook's id. + */ + public static String createRunbookWithServerScriptStep( + final OctopusClient client, + final SpaceHome spaceHome, + final String octopusBaseUrl, + final String apiKey, + final String projectName, + final String runbookName) + throws Exception { + final String projectId = + ProjectApi.create(client, spaceHome) + .getByName(projectName) + .orElseThrow(() -> new IllegalStateException("No project named " + projectName)) + .getProperties() + .getId(); + + final JsonObject retention = new JsonObject(); + retention.addProperty("QuantityToKeep", 10); + retention.addProperty("ShouldKeepForever", false); + final JsonObject runbook = new JsonObject(); + runbook.addProperty("ProjectId", projectId); + runbook.addProperty("Name", runbookName); + runbook.addProperty("EnvironmentScope", "All"); + runbook.add("RunRetentionPolicy", retention); + + final JsonObject created = + readJson( + octopusRequest( + "POST", + octopusBaseUrl + stripUriTemplate(spaceHome.getRunbooksLink()), + apiKey, + runbook.toString()), + "create runbook"); + final String runbookId = created.get("Id").getAsString(); + final String processUrl = + octopusBaseUrl + + stripUriTemplate(spaceHome.getRunbookProcessesLink()) + + "/" + + created.get("RunbookProcessId").getAsString(); + + final JsonObject process = + readJson(octopusRequest("GET", processUrl, apiKey, null), "get runbook process"); + process.add("Steps", scriptStep()); + readJson(octopusRequest("PUT", processUrl, apiKey, process.toString()), "put runbook process"); + + final JsonObject template = + readJson( + octopusRequest("GET", processUrl + "/runbookSnapshotTemplate", apiKey, null), + "get runbook snapshot template"); + final JsonObject snapshot = new JsonObject(); + snapshot.addProperty("ProjectId", projectId); + snapshot.addProperty("RunbookId", runbookId); + snapshot.addProperty("Name", template.get("NextNameIncrement").getAsString()); + snapshot.add("SelectedPackages", new JsonArray()); + readJson( + octopusRequest( + "POST", + octopusBaseUrl + + stripUriTemplate(spaceHome.getRunbookSnapshotsLink()) + + "?publish=true", + apiKey, + snapshot.toString()), + "publish runbook snapshot"); + + return runbookId; + } + + /** True if the runbook has been run at least once. */ + public static boolean hasRunbookRun( + final SpaceHome spaceHome, + final String octopusBaseUrl, + final String apiKey, + final String runbookId) + throws Exception { + final JsonObject runs = + readJson( + octopusRequest( + "GET", + octopusBaseUrl + stripUriTemplate(spaceHome.getRunbookRunsLink()) + "?take=100", + apiKey, + null), + "list runbook runs"); + for (final JsonElement run : runs.getAsJsonArray("Items")) { + if (runbookId.equals(run.getAsJsonObject().get("RunbookId").getAsString())) { + return true; + } + } + return false; + } + private static void addInlineScriptStep( final String octopusBaseUrl, final String apiKey, final String deploymentProcessLink) throws Exception { - // Links may carry a URI template suffix (e.g. "{?...}"); strip it. - final String path = - deploymentProcessLink.contains("{") - ? deploymentProcessLink.substring(0, deploymentProcessLink.indexOf('{')) - : deploymentProcessLink; - final String url = octopusBaseUrl + path; + final String url = octopusBaseUrl + stripUriTemplate(deploymentProcessLink); final Http.Response get = octopusRequest("GET", url, apiKey, null); if (get.statusCode() != 200) { @@ -141,6 +230,19 @@ private static void addInlineScriptStep( } } + /** Links may carry a URI template suffix (e.g. "{?...}"); strip it. */ + private static String stripUriTemplate(final String link) { + return link.contains("{") ? link.substring(0, link.indexOf('{')) : link; + } + + private static JsonObject readJson(final Http.Response response, final String what) { + if (response.statusCode() < 200 || response.statusCode() >= 300) { + throw new IllegalStateException( + what + " -> " + response.statusCode() + ": " + response.body()); + } + return JsonParser.parseString(response.body()).getAsJsonObject(); + } + private static Http.Response octopusRequest( final String method, final String url, final String apiKey, final String body) throws IOException { diff --git a/e2e/src/test/java/octopus/teamcity/e2e/dsl/TeamCityRest.java b/e2e/src/test/java/octopus/teamcity/e2e/dsl/TeamCityRest.java index 373ea0bb..5bb836b2 100644 --- a/e2e/src/test/java/octopus/teamcity/e2e/dsl/TeamCityRest.java +++ b/e2e/src/test/java/octopus/teamcity/e2e/dsl/TeamCityRest.java @@ -452,6 +452,33 @@ public void addPromoteReleaseStepUsingConnection( json); } + /** + * Adds a Run runbook (octopus.run.runbook) step referencing a connection. Waits for the runbook + * run so the build fails if the run fails. + */ + public void addRunRunbookStepUsingConnection( + final String buildTypeId, + final String connectionId, + final String projectName, + final String runbookName, + final String runIn) + throws Exception { + final String json = + createStepFeatureJson( + "Run runbook", + "octopus.run.runbook", + createProp("octopus_connection_id", connectionId), + createProp("octopus_project_name", projectName), + createProp("octopus_runbook_name", runbookName), + createProp("octopus_deployto", runIn), + createProp("octopus_waitfordeployments", "true")); + send( + "POST", + "/httpAuth/app/rest/buildTypes/" + buildTypeId + "/steps", + "application/json", + json); + } + /** Lists the names of a finished build's artifacts (top-level children). */ public String listBuildArtifacts(final String buildId) throws Exception { return send( diff --git a/e2e/src/test/java/octopus/teamcity/e2e/test/OctopusConnectionFormSmokeUiTest.java b/e2e/src/test/java/octopus/teamcity/e2e/test/OctopusConnectionFormSmokeUiTest.java index cc3e68d7..013ecb0e 100644 --- a/e2e/src/test/java/octopus/teamcity/e2e/test/OctopusConnectionFormSmokeUiTest.java +++ b/e2e/src/test/java/octopus/teamcity/e2e/test/OctopusConnectionFormSmokeUiTest.java @@ -48,6 +48,8 @@ void connectionDropdownRendersOnEveryConnectionAwareForm() throws Exception { "Push package", tc.addEmptyStep("FormIT_Steps", "octopus.push.package", "Push")); runnerIdsByForm.put( "Build information", tc.addEmptyStep("FormIT_Steps", "octopus.metadata", "Build info")); + runnerIdsByForm.put( + "Run runbook", tc.addEmptyStep("FormIT_Steps", "octopus.run.runbook", "Runbook")); PlaywrightUi.withLoggedInPage( stack, diff --git a/e2e/src/test/java/octopus/teamcity/e2e/test/OctopusRunRunbookE2ETest.java b/e2e/src/test/java/octopus/teamcity/e2e/test/OctopusRunRunbookE2ETest.java new file mode 100644 index 00000000..a2bbac21 --- /dev/null +++ b/e2e/src/test/java/octopus/teamcity/e2e/test/OctopusRunRunbookE2ETest.java @@ -0,0 +1,79 @@ +package octopus.teamcity.e2e.test; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.octopus.sdk.http.OctopusClient; +import com.octopus.sdk.model.space.SpaceHome; + +import java.time.Duration; +import java.util.Collections; + +import octopus.teamcity.e2e.dsl.OctopusProvisioning; +import octopus.teamcity.e2e.dsl.OctopusTeamCityStack; +import octopus.teamcity.e2e.dsl.SharedStack; +import octopus.teamcity.e2e.dsl.TeamCityRest; +import org.junit.jupiter.api.Test; + +/** Runs a published runbook through the Run runbook step and asserts Octopus recorded the run. */ +class OctopusRunRunbookE2ETest { + + private static final String OCTOPUS_PROJECT = "RunRunbookIT"; + private static final String RUNBOOK = "RunRunbookIT-Say hello"; + // Unique to this test — environment names are space-global on the shared Octopus. + private static final String ENVIRONMENT = "RunRunbookIT-Development"; + + @Test + void runRunbookStepUsingConnectionRunsTheRunbook() throws Exception { + try (final OctopusTeamCityStack stack = SharedStack.full()) { + final OctopusClient client = stack.octopusClient(); + final SpaceHome spaceHome = stack.spaceHome(client); + + final String environmentId = + OctopusProvisioning.createEnvironment(client, spaceHome, ENVIRONMENT); + OctopusProvisioning.createProjectWithServerScriptStep( + client, + spaceHome, + stack.octopusUrlForHost(), + stack.octopusApiKey(), + OCTOPUS_PROJECT, + Collections.singletonList(environmentId)); + final String runbookId = + OctopusProvisioning.createRunbookWithServerScriptStep( + client, + spaceHome, + stack.octopusUrlForHost(), + stack.octopusApiKey(), + OCTOPUS_PROJECT, + RUNBOOK); + + final TeamCityRest tc = stack.rest(); + tc.createProject("RunbookIT", "Runbook IT"); + final String connectionId = + tc.createOctopusConnection( + "RunbookIT", + "IT Octopus", + stack.octopusUrlForContainers(), + stack.octopusApiKey(), + ""); + tc.createBuildType("RunbookIT_Run", "Run runbook", "RunbookIT"); + tc.addRunRunbookStepUsingConnection( + "RunbookIT_Run", connectionId, OCTOPUS_PROJECT, RUNBOOK, ENVIRONMENT); + + final String buildId = tc.triggerBuild("RunbookIT_Run"); + final String status = tc.waitForBuildFinished(buildId, Duration.ofMinutes(6)); + final String log = tc.downloadBuildLog(buildId); + + assertThat(status) + .withFailMessage("Build did not succeed. Log:\n%s", log) + .isEqualTo("SUCCESS"); + + assertThat( + OctopusProvisioning.hasRunbookRun( + spaceHome, stack.octopusUrlForHost(), stack.octopusApiKey(), runbookId)) + .withFailMessage("No run of runbook %s found. Log:\n%s", RUNBOOK, log) + .isTrue(); + + assertThat(log).doesNotContain(stack.octopusApiKey()); + } + } +} diff --git a/octopus-agent/src/main/java/octopus/teamcity/agent/OctopusRunRunbookBuildProcess.java b/octopus-agent/src/main/java/octopus/teamcity/agent/OctopusRunRunbookBuildProcess.java new file mode 100644 index 00000000..e537628a --- /dev/null +++ b/octopus-agent/src/main/java/octopus/teamcity/agent/OctopusRunRunbookBuildProcess.java @@ -0,0 +1,121 @@ +/* + * Copyright 2000-2012 Octopus Deploy Pty. Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package octopus.teamcity.agent; + +import java.util.ArrayList; +import java.util.Map; + +import jetbrains.buildServer.agent.AgentRunningBuild; +import jetbrains.buildServer.agent.BuildRunnerContext; +import octopus.teamcity.common.OctopusConstants; +import org.jetbrains.annotations.NotNull; + +public class OctopusRunRunbookBuildProcess extends OctopusBuildProcess { + public OctopusRunRunbookBuildProcess( + @NotNull AgentRunningBuild runningBuild, @NotNull BuildRunnerContext context) { + super(runningBuild, context); + } + + @Override + protected String getLogMessage() { + return "Running Octopus Deploy runbook"; + } + + @Override + protected OctopusCommandBuilder createCommand() { + final Map parameters = getContext().getRunnerParameters(); + final OctopusConstants constants = OctopusConstants.Instance; + + return new OctopusCommandBuilder() { + @Override + protected String[] buildCommand(boolean masked) { + final ArrayList commands = new ArrayList(); + final String serverUrl = parameters.get(constants.getServerKey()); + final String apiKey = parameters.get(constants.getApiKey()); + final String spaceName = parameters.get(constants.getSpaceName()); + final String commandLineArguments = parameters.get(constants.getCommandLineArgumentsKey()); + final String projectName = parameters.get(constants.getProjectNameKey()); + final String runbookName = parameters.get(constants.getRunbookNameKey()); + final String snapshot = parameters.get(constants.getRunbookSnapshotKey()); + final String runIn = parameters.get(constants.getDeployToKey()); + final String tenants = parameters.get(constants.getTenantsKey()); + final String tenanttags = parameters.get(constants.getTenantTagsKey()); + final boolean wait = + Boolean.parseBoolean(parameters.get(constants.getWaitForDeployments())); + final String runTimeout = parameters.get(constants.getDeploymentTimeout()); + final boolean cancelOnTimeout = + Boolean.parseBoolean(parameters.get(constants.getCancelDeploymentOnTimeout())); + + commands.add("run-runbook"); + commands.add("--server"); + commands.add(serverUrl); + commands.add("--apikey"); + commands.add(masked ? "SECRET" : apiKey); + + if (spaceName != null && !spaceName.isEmpty()) { + commands.add("--space"); + commands.add(spaceName); + } + commands.add("--project"); + commands.add(projectName); + commands.add("--runbook"); + commands.add(runbookName); + commands.add("--enableservicemessages"); + + if (snapshot != null && !snapshot.isEmpty()) { + commands.add("--snapshot"); + commands.add(snapshot); + } + + for (String env : splitCommaSeparatedValues(runIn)) { + commands.add("--environment"); + commands.add(env); + } + + for (String tenant : splitCommaSeparatedValues(tenants)) { + commands.add("--tenant"); + commands.add(tenant); + } + + for (String tenanttag : splitCommaSeparatedValues(tenanttags)) { + commands.add("--tenantTag"); + commands.add(tenanttag); + } + + if (wait) { + commands.add("--progress"); + commands.add("--waitForRun"); + + if (runTimeout != null && !runTimeout.isEmpty()) { + commands.add("--runTimeout"); + commands.add(runTimeout); + } + + if (cancelOnTimeout) { + commands.add("--cancelOnTimeout"); + } + } + + if (commandLineArguments != null && !commandLineArguments.isEmpty()) { + commands.addAll(splitSpaceSeparatedValues(commandLineArguments)); + } + + return commands.toArray(new String[commands.size()]); + } + }; + } +} diff --git a/octopus-agent/src/main/java/octopus/teamcity/agent/OctopusRunRunbookRunner.java b/octopus-agent/src/main/java/octopus/teamcity/agent/OctopusRunRunbookRunner.java new file mode 100644 index 00000000..291be345 --- /dev/null +++ b/octopus-agent/src/main/java/octopus/teamcity/agent/OctopusRunRunbookRunner.java @@ -0,0 +1,58 @@ +/* + * Copyright 2000-2012 Octopus Deploy Pty. Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package octopus.teamcity.agent; + +import jetbrains.buildServer.RunBuildException; +import jetbrains.buildServer.agent.AgentBuildRunner; +import jetbrains.buildServer.agent.AgentBuildRunnerInfo; +import jetbrains.buildServer.agent.AgentRunningBuild; +import jetbrains.buildServer.agent.BuildAgentConfiguration; +import jetbrains.buildServer.agent.BuildProcess; +import jetbrains.buildServer.agent.BuildRunnerContext; +import octopus.teamcity.agent.cli.RunRunbookBuildProcess; +import octopus.teamcity.common.OctopusConstants; +import org.jetbrains.annotations.NotNull; + +public class OctopusRunRunbookRunner implements AgentBuildRunner { + @Override + @NotNull + public BuildProcess createBuildProcess( + @NotNull AgentRunningBuild runningBuild, @NotNull BuildRunnerContext context) + throws RunBuildException { + if (OctopusCliSelector.shouldUseNewCli(runningBuild, context)) { + return new RunRunbookBuildProcess(runningBuild, context); + } + return new OctopusRunRunbookBuildProcess(runningBuild, context); + } + + @Override + @NotNull + public AgentBuildRunnerInfo getRunnerInfo() { + return new AgentBuildRunnerInfo() { + @Override + @NotNull + public String getType() { + return OctopusConstants.RUN_RUNBOOK_RUNNER_TYPE; + } + + @Override + public boolean canRun(@NotNull BuildAgentConfiguration agentConfiguration) { + return OctopusOsUtils.CanRunOcto(agentConfiguration); + } + }; + } +} diff --git a/octopus-agent/src/main/java/octopus/teamcity/agent/cli/CommandHelper.java b/octopus-agent/src/main/java/octopus/teamcity/agent/cli/CommandHelper.java index a44906d2..3d62596c 100644 --- a/octopus-agent/src/main/java/octopus/teamcity/agent/cli/CommandHelper.java +++ b/octopus-agent/src/main/java/octopus/teamcity/agent/cli/CommandHelper.java @@ -22,6 +22,8 @@ public class CommandHelper { static final String deployReleaseAdditionalArgumentsToBeIgnored = "-e,--environment,--tenant,--tenant-tag,--deploy-at,--deploy-at-expiry,--variable,--update-variables,--skip,--guided-failure,--force-package-download,--deployment-target,--exclude-deployment-target,--deployment-freeze-name,--deployment-freeze-override-reason"; + static final String runbookRunAdditionalArgumentsToBeIgnored = + "-p,--project,-n,--name,-e,--environment,--tenant,--tenant-tag,--snapshot,--space"; static final String createReleaseAdditionalArgumentsToBeIgnored = "-c,-r,-x,--channel,--git-ref,--git-commit,--package,--git-resource,--release-notes,--release-notes-file,--ignore-existing,--ignore-channel-rules,--custom-field"; static final String defaultSpace = "Default"; @@ -85,6 +87,65 @@ public static String[] deployRelease( return commands.toArray(new String[0]); } + public static String[] runbookRun(Map params) { + final OctopusConstants constants = OctopusConstants.Instance; + final ArrayList commands = new ArrayList<>(); + String spaceName = params.get(constants.getSpaceName()); + final String commandLineArguments = params.get(constants.getCommandLineArgumentsKey()); + final String projectName = params.get(constants.getProjectNameKey()); + final String runbookName = params.get(constants.getRunbookNameKey()); + final String snapshot = params.get(constants.getRunbookSnapshotKey()); + final String runIn = params.get(constants.getDeployToKey()); + final String tenants = params.get(constants.getTenantsKey()); + final String tenantTags = params.get(constants.getTenantTagsKey()); + + commands.add("runbook"); + commands.add("run"); + + if (StringUtils.isBlank(spaceName)) { + spaceName = defaultSpace; + } + commands.add("--space"); + commands.add(spaceName); + + commands.add("--project"); + commands.add(projectName); + + commands.add("--name"); + commands.add(runbookName); + + if (StringUtils.isNotBlank(snapshot)) { + commands.add("--snapshot"); + commands.add(snapshot); + } + + for (String env : splitCommaSeparatedValues(runIn)) { + commands.add("--environment"); + commands.add(env); + } + + for (String tenant : splitCommaSeparatedValues(tenants)) { + commands.add("--tenant"); + commands.add(tenant); + } + + for (String tenantTag : splitCommaSeparatedValues(tenantTags)) { + commands.add("--tenant-tag"); + commands.add(tenantTag); + } + + commands.add("--output-format"); + commands.add("json"); + + if (StringUtils.isNotBlank(commandLineArguments)) { + List commandArgs = splitSpaceSeparatedValues(commandLineArguments); + commands.addAll(sanitizeCommandArgs(commandArgs, runbookRunAdditionalArgumentsToBeIgnored)); + } + + commands.add("--no-prompt"); + return commands.toArray(new String[0]); + } + public static OctopusCommandBuilder login(Map params) { return new OctopusCommandBuilder() { @Override diff --git a/octopus-agent/src/main/java/octopus/teamcity/agent/cli/CommandUtils.java b/octopus-agent/src/main/java/octopus/teamcity/agent/cli/CommandUtils.java index 03eb7903..b429a6ff 100644 --- a/octopus-agent/src/main/java/octopus/teamcity/agent/cli/CommandUtils.java +++ b/octopus-agent/src/main/java/octopus/teamcity/agent/cli/CommandUtils.java @@ -27,6 +27,10 @@ protected static boolean isDeployReleaseCommand(String output) { return output != null && output.contains("ServerTaskId"); } + protected static boolean isRunbookRunCommand(String output) { + return output != null && output.contains("RunbookRunId"); + } + public static String getOverwriteMode(OverwriteMode overwriteMode) { switch (overwriteMode) { case FailIfExists: diff --git a/octopus-agent/src/main/java/octopus/teamcity/agent/cli/RunRunbookBuildProcess.java b/octopus-agent/src/main/java/octopus/teamcity/agent/cli/RunRunbookBuildProcess.java new file mode 100644 index 00000000..490f6f2d --- /dev/null +++ b/octopus-agent/src/main/java/octopus/teamcity/agent/cli/RunRunbookBuildProcess.java @@ -0,0 +1,68 @@ +package octopus.teamcity.agent.cli; + +import static octopus.teamcity.agent.cli.CommandUtils.getServerTaskId; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import jetbrains.buildServer.agent.AgentRunningBuild; +import jetbrains.buildServer.agent.BuildRunnerContext; +import octopus.teamcity.agent.OctopusCommandBuilder; +import octopus.teamcity.common.OctopusConstants; +import org.jetbrains.annotations.NotNull; + +public class RunRunbookBuildProcess extends CLIBuildProcess { + private String serverTaskId; + + public RunRunbookBuildProcess( + @NotNull AgentRunningBuild runningBuild, @NotNull BuildRunnerContext context) { + super(runningBuild, context); + } + + @Override + public void processOutput(String output, int exitCode) { + logger.message("Exit code: " + exitCode); + if (exitCode == 0) { + final OctopusConstants constants = OctopusConstants.Instance; + final Map parameters = getContext().getRunnerParameters(); + final boolean wait = Boolean.parseBoolean(parameters.get(constants.getWaitForDeployments())); + if (wait && CommandUtils.isRunbookRunCommand(output)) { + serverTaskId = getServerTaskId(output); + } + } + } + + @Override + protected List createCommand() { + final OctopusConstants constants = OctopusConstants.Instance; + List commands = new ArrayList<>(); + final Map parameters = getContext().getRunnerParameters(); + final boolean wait = Boolean.parseBoolean(parameters.get(constants.getWaitForDeployments())); + + commands.add(CommandHelper.login(parameters)); + commands.add( + new OctopusCommandBuilder() { + @Override + protected String[] buildCommand(boolean masked) { + return CommandHelper.runbookRun(parameters); + } + }); + + if (wait) { + commands.add( + new OctopusCommandBuilder() { + @Override + protected String[] buildCommand(boolean masked) { + return CommandHelper.wait(parameters, serverTaskId); + } + }); + } + return commands; + } + + @Override + protected String getLogMessage() { + return "Running Octopus Deploy runbook"; + } +} diff --git a/octopus-agent/src/main/resources/META-INF/build-agent-plugin-Octopus.TeamCity.xml b/octopus-agent/src/main/resources/META-INF/build-agent-plugin-Octopus.TeamCity.xml index 970aaec7..95ab5335 100644 --- a/octopus-agent/src/main/resources/META-INF/build-agent-plugin-Octopus.TeamCity.xml +++ b/octopus-agent/src/main/resources/META-INF/build-agent-plugin-Octopus.TeamCity.xml @@ -26,6 +26,7 @@ + diff --git a/octopus-agent/src/test/java/octopus/teamcity/agent/OctopusRunRunbookBuildProcessTest.java b/octopus-agent/src/test/java/octopus/teamcity/agent/OctopusRunRunbookBuildProcessTest.java new file mode 100644 index 00000000..69d66484 --- /dev/null +++ b/octopus-agent/src/test/java/octopus/teamcity/agent/OctopusRunRunbookBuildProcessTest.java @@ -0,0 +1,93 @@ +package octopus.teamcity.agent; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.util.HashMap; +import java.util.Map; + +import jetbrains.buildServer.agent.AgentRunningBuild; +import jetbrains.buildServer.agent.BuildProgressLogger; +import jetbrains.buildServer.agent.BuildRunnerContext; +import octopus.teamcity.common.OctopusConstants; +import org.junit.jupiter.api.Test; + +class OctopusRunRunbookBuildProcessTest { + private static final OctopusConstants CONSTANTS = OctopusConstants.Instance; + + private OctopusRunRunbookBuildProcess processFor(final Map params) { + AgentRunningBuild runningBuild = mock(AgentRunningBuild.class); + BuildProgressLogger logger = mock(BuildProgressLogger.class); + BuildRunnerContext context = mock(BuildRunnerContext.class); + when(context.getRunnerParameters()).thenReturn(params); + when(runningBuild.getBuildLogger()).thenReturn(logger); + return new OctopusRunRunbookBuildProcess(runningBuild, context); + } + + private Map mandatoryParams() { + Map params = new HashMap<>(); + params.put(CONSTANTS.getServerKey(), "https://octopus.example.com"); + params.put(CONSTANTS.getApiKey(), "API-KEY"); + params.put(CONSTANTS.getProjectNameKey(), "MyProject"); + params.put(CONSTANTS.getRunbookNameKey(), "Rebuild indexes"); + params.put(CONSTANTS.getDeployToKey(), "Production"); + return params; + } + + @Test + void buildCommand_runsTheNamedRunbookInEachEnvironment() { + Map params = mandatoryParams(); + params.put(CONSTANTS.getDeployToKey(), "Development,Production"); + params.put(CONSTANTS.getRunbookSnapshotKey(), "Snapshot ABC"); + params.put(CONSTANTS.getTenantTagsKey(), "Regions/South"); + + String[] command = processFor(params).createCommand().buildCommand(); + + assertThat(command) + .contains( + "run-runbook", + "--project", + "MyProject", + "--runbook", + "Rebuild indexes", + "--snapshot", + "Snapshot ABC", + "--environment", + "Development", + "--environment", + "Production", + "--tenantTag", + "Regions/South"); + } + + @Test + void buildCommand_includesWaitForRun_whenWaitingWithTimeout() { + Map params = mandatoryParams(); + params.put(CONSTANTS.getWaitForDeployments(), "true"); + params.put(CONSTANTS.getDeploymentTimeout(), "00:30:00"); + params.put(CONSTANTS.getCancelDeploymentOnTimeout(), "true"); + + String[] command = processFor(params).createCommand().buildCommand(); + + // --runTimeout/--cancelOnTimeout are silently ignored by the Octopus CLI unless --waitForRun + // is also passed, so it must be present when waiting on a runbook run. + assertThat(command).contains("--waitForRun"); + assertThat(command).contains("--runTimeout", "00:30:00"); + assertThat(command).contains("--cancelOnTimeout"); + } + + @Test + void buildCommand_omitsWaitOptions_whenNotWaiting() { + String[] command = processFor(mandatoryParams()).createCommand().buildCommand(); + + assertThat(command).doesNotContain("--waitForRun", "--progress"); + } + + @Test + void buildMaskedCommand_hidesTheApiKey() { + String[] command = processFor(mandatoryParams()).createCommand().buildMaskedCommand(); + + assertThat(command).contains("SECRET").doesNotContain("API-KEY"); + } +} diff --git a/octopus-agent/src/test/java/octopus/teamcity/agent/cli/CommandHelperTest.java b/octopus-agent/src/test/java/octopus/teamcity/agent/cli/CommandHelperTest.java index 74a9350e..0a311a4f 100644 --- a/octopus-agent/src/test/java/octopus/teamcity/agent/cli/CommandHelperTest.java +++ b/octopus-agent/src/test/java/octopus/teamcity/agent/cli/CommandHelperTest.java @@ -82,6 +82,65 @@ void deployCommand() { "--no-prompt"); } + @Test + void runbookRunCommand() { + Map params = new HashMap<>(); + final OctopusConstants constants = OctopusConstants.Instance; + + params.put(constants.getProjectNameKey(), "MyProject"); + params.put(constants.getRunbookNameKey(), "Rebuild indexes"); + params.put(constants.getRunbookSnapshotKey(), "Snapshot ABC"); + params.put(constants.getDeployToKey(), "Env1,Env2"); + params.put(constants.getTenantsKey(), "TenantA"); + params.put(constants.getTenantTagsKey(), "TagX"); + params.put(constants.getCommandLineArgumentsKey(), "--variable Name:Value"); + + String[] command = CommandHelper.runbookRun(params); + + assertThat(command) + .containsExactly( + "runbook", + "run", + "--space", + "Default", + "--project", + "MyProject", + "--name", + "Rebuild indexes", + "--snapshot", + "Snapshot ABC", + "--environment", + "Env1", + "--environment", + "Env2", + "--tenant", + "TenantA", + "--tenant-tag", + "TagX", + "--output-format", + "json", + "--variable", + "Name:Value", + "--no-prompt"); + } + + @Test + void runbookRunCommandDropsAdditionalArgsTheStepAlreadySets() { + Map params = new HashMap<>(); + final OctopusConstants constants = OctopusConstants.Instance; + + params.put(constants.getProjectNameKey(), "MyProject"); + params.put(constants.getRunbookNameKey(), "Rebuild indexes"); + params.put(constants.getDeployToKey(), "Env1"); + params.put( + constants.getCommandLineArgumentsKey(), "--environment Sneaky --force-package-download"); + + String[] command = CommandHelper.runbookRun(params); + + assertThat(command).doesNotContain("Sneaky"); + assertThat(command).contains("--force-package-download"); + } + @Test void waitCommand() { Map params = new HashMap<>(); diff --git a/octopus-common/src/main/java/octopus/teamcity/common/OctopusConstants.java b/octopus-common/src/main/java/octopus/teamcity/common/OctopusConstants.java index ded4de6c..3a4df880 100644 --- a/octopus-common/src/main/java/octopus/teamcity/common/OctopusConstants.java +++ b/octopus-common/src/main/java/octopus/teamcity/common/OctopusConstants.java @@ -125,6 +125,14 @@ public String getPromoteFromKey() { return "octopus_promotefrom"; } + public String getRunbookNameKey() { + return "octopus_runbook_name"; + } + + public String getRunbookSnapshotKey() { + return "octopus_runbook_snapshot"; + } + public String getCommandLineArgumentsKey() { return "octopus_additionalcommandlinearguments"; } @@ -188,6 +196,7 @@ public String getOidcIdTokenKey() { public static final String CREATE_RELEASE_RUNNER_TYPE = "octopus.create.release"; public static final String DEPLOY_RELEASE_RUNNER_TYPE = "octopus.deploy.release"; public static final String PROMOTE_RELEASE_RUNNER_TYPE = "octopus.promote.release"; + public static final String RUN_RUNBOOK_RUNNER_TYPE = "octopus.run.runbook"; public static final String PACK_PACKAGE_RUNNER_TYPE = "octopus.pack.package"; public static final String PUSH_PACKAGE_RUNNER_TYPE = "octopus.push.package"; public static final String METADATA_RUNNER_TYPE = "octopus.metadata"; diff --git a/octopus-server/src/main/java/octopus/teamcity/server/OctopusRunRunbookRunType.java b/octopus-server/src/main/java/octopus/teamcity/server/OctopusRunRunbookRunType.java new file mode 100644 index 00000000..e6bb9c3f --- /dev/null +++ b/octopus-server/src/main/java/octopus/teamcity/server/OctopusRunRunbookRunType.java @@ -0,0 +1,106 @@ +/* + * Copyright 2000-2012 Octopus Deploy Pty. Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package octopus.teamcity.server; + +import static octopus.teamcity.server.PropertiesValidator.checkCredentialsUnlessUsingConnection; +import static octopus.teamcity.server.PropertiesValidator.checkNotEmpty; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashMap; +import java.util.Map; + +import jetbrains.buildServer.serverSide.InvalidProperty; +import jetbrains.buildServer.serverSide.PropertiesProcessor; +import jetbrains.buildServer.serverSide.RunType; +import jetbrains.buildServer.serverSide.RunTypeRegistry; +import jetbrains.buildServer.web.openapi.PluginDescriptor; +import octopus.teamcity.common.OctopusConstants; +import octopus.teamcity.server.connection.ConnectionInlineFieldCleaner; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +public class OctopusRunRunbookRunType extends RunType { + private final PluginDescriptor pluginDescriptor; + + public OctopusRunRunbookRunType( + final RunTypeRegistry runTypeRegistry, final PluginDescriptor pluginDescriptor) { + this.pluginDescriptor = pluginDescriptor; + runTypeRegistry.registerRunType(this); + } + + @NotNull + @Override + public String getType() { + return OctopusConstants.RUN_RUNBOOK_RUNNER_TYPE; + } + + @NotNull + @Override + public String getDisplayName() { + return "OctopusDeploy: Run runbook"; + } + + @NotNull + @Override + public String getDescription() { + return "Runs a runbook in Octopus Deploy"; + } + + @Nullable + @Override + public PropertiesProcessor getRunnerPropertiesProcessor() { + final OctopusConstants c = new OctopusConstants(); + return new PropertiesProcessor() { + @Override + @NotNull + public Collection process(@Nullable final Map p) { + final Collection result = new ArrayList<>(); + if (p == null) return result; + + checkCredentialsUnlessUsingConnection(p, c, result); + checkNotEmpty(p, c.getProjectNameKey(), "Project name must be specified", result); + checkNotEmpty(p, c.getRunbookNameKey(), "Runbook must be specified", result); + checkNotEmpty(p, c.getDeployToKey(), "Environment(s) must be specified", result); + + if (result.isEmpty()) { + ConnectionInlineFieldCleaner.stripInlineFieldsIfUsingConnection(p); + } + + return result; + } + }; + } + + @Nullable + @Override + public String getEditRunnerParamsJspFilePath() { + return pluginDescriptor.getPluginResourcesPath("forms/editOctopusRunRunbookForm.jsp"); + } + + @Nullable + @Override + public String getViewRunnerParamsJspFilePath() { + return pluginDescriptor.getPluginResourcesPath("viewOctopusRunRunbook.jsp"); + } + + @Nullable + @Override + public Map getDefaultRunnerProperties() { + return new HashMap<>(); + } +} diff --git a/octopus-server/src/main/java/octopus/teamcity/server/connection/OctopusConnectionBuildStartProcessor.java b/octopus-server/src/main/java/octopus/teamcity/server/connection/OctopusConnectionBuildStartProcessor.java index 910a4f1d..1177e50a 100644 --- a/octopus-server/src/main/java/octopus/teamcity/server/connection/OctopusConnectionBuildStartProcessor.java +++ b/octopus-server/src/main/java/octopus/teamcity/server/connection/OctopusConnectionBuildStartProcessor.java @@ -48,6 +48,7 @@ public class OctopusConnectionBuildStartProcessor implements BuildStartContextPr OctopusConstants.CREATE_RELEASE_RUNNER_TYPE, OctopusConstants.DEPLOY_RELEASE_RUNNER_TYPE, OctopusConstants.PROMOTE_RELEASE_RUNNER_TYPE, + OctopusConstants.RUN_RUNBOOK_RUNNER_TYPE, OctopusConstants.PUSH_PACKAGE_RUNNER_TYPE, OctopusConstants.METADATA_RUNNER_TYPE)); diff --git a/octopus-server/src/main/resources/META-INF/build-server-plugin-Octopus.TeamCity.xml b/octopus-server/src/main/resources/META-INF/build-server-plugin-Octopus.TeamCity.xml index c3f86e03..1f987976 100644 --- a/octopus-server/src/main/resources/META-INF/build-server-plugin-Octopus.TeamCity.xml +++ b/octopus-server/src/main/resources/META-INF/build-server-plugin-Octopus.TeamCity.xml @@ -11,6 +11,7 @@ + diff --git a/octopus-server/src/main/resources/buildServerResources/forms/editOctopusRunRunbookForm.jsp b/octopus-server/src/main/resources/buildServerResources/forms/editOctopusRunRunbookForm.jsp new file mode 100644 index 00000000..aa50c5a7 --- /dev/null +++ b/octopus-server/src/main/resources/buildServerResources/forms/editOctopusRunRunbookForm.jsp @@ -0,0 +1,126 @@ +<%@ include file="/include-internal.jsp"%> +<%@ taglib prefix="props" tagdir="/WEB-INF/tags/props" %> +<%@ taglib prefix="forms" tagdir="/WEB-INF/tags/forms" %> +<%@ taglib prefix="l" tagdir="/WEB-INF/tags/layout" %> +<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %> + + + + + + + + Octopus URL: + + + + Specify Octopus web portal URL + + + + API key: + + + + Specify Octopus API key. You can get this from your user page in the Octopus web portal. + You can also reference a build parameter here, e.g. %octopus.apikey%. + + + + Space name: + + + + Specify the Octopus Space name to run within. Leave blank to use the default space. + + + + + + + Project: + + + + Enter the name of the Octopus project that contains the runbook + + + + Runbook: + + + + The name of the runbook to run + + + + Environment(s): + + + + Comma separated list of environments to run the runbook in. + + + + + + + + Comma separated list of tenants to run for. +
Note that when supplying tenant filters then only one environment may be provided above.
+ + + + + + + + Comma separated list of tenant tags that match tenants to run for. +
Note that when supplying tag filters then only one environment may be provided above.
+ + + + + + + + The name of the runbook snapshot to run. Leave blank to use the published snapshot. + + + + Show runbook run progress: + + + + If checked, the build process will only succeed if the runbook run is successful. + + + + Time to wait for runbook run: + + + + The amount of time, specified in timespan format, to wait for the runbook run to complete. Default is 00:10:00 if left blank. The runbook run itself does not timeout, this timeout is purely how long the client will keep polling to see if it has completed. + + + + Cancel runbook run on timeout: + + + + If checked, and Show runbook run progress is also checked, then the runbook run will be explicitly canceled if the time to wait has expired and the task has not completed. + + +
+ + + + + Additional command line arguments: + + + + Additional arguments to be passed to Octopus CLI + + + diff --git a/octopus-server/src/main/resources/buildServerResources/viewOctopusRunRunbook.jsp b/octopus-server/src/main/resources/buildServerResources/viewOctopusRunRunbook.jsp new file mode 100644 index 00000000..54b899d4 --- /dev/null +++ b/octopus-server/src/main/resources/buildServerResources/viewOctopusRunRunbook.jsp @@ -0,0 +1,53 @@ +<%@ include file="/include-internal.jsp"%> +<%@ taglib prefix="props" tagdir="/WEB-INF/tags/props" %> +<%@ taglib prefix="forms" tagdir="/WEB-INF/tags/forms" %> +<%@ taglib prefix="l" tagdir="/WEB-INF/tags/layout" %> +<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %> + + + + +
+ Octopus URL: + +
+ +
+ Space name: + +
+ +
+ Project: + +
+ +
+ Runbook: + +
+ +
+ Environment(s): + +
+ +
+ Snapshot: + +
+ +
+ Show runbook run progress: + +
+ +
+ Time to wait for runbook run: + +
+ +
+ Cancel runbook run on timeout: + +
diff --git a/octopus-server/src/test/java/octopus/teamcity/server/OctopusRunRunbookRunTypeValidationTest.java b/octopus-server/src/test/java/octopus/teamcity/server/OctopusRunRunbookRunTypeValidationTest.java new file mode 100644 index 00000000..3c9200bf --- /dev/null +++ b/octopus-server/src/test/java/octopus/teamcity/server/OctopusRunRunbookRunTypeValidationTest.java @@ -0,0 +1,101 @@ +package octopus.teamcity.server; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +import java.util.Collection; +import java.util.HashMap; +import java.util.Map; +import java.util.stream.Collectors; + +import jetbrains.buildServer.serverSide.InvalidProperty; +import jetbrains.buildServer.serverSide.RunTypeRegistry; +import jetbrains.buildServer.web.openapi.PluginDescriptor; +import octopus.teamcity.common.OctopusConstants; +import org.junit.jupiter.api.Test; + +class OctopusRunRunbookRunTypeValidationTest { + private static final OctopusConstants CONSTANTS = new OctopusConstants(); + + private Collection validate(final Map properties) { + final OctopusRunRunbookRunType runType = + new OctopusRunRunbookRunType(mock(RunTypeRegistry.class), mock(PluginDescriptor.class)); + return runType.getRunnerPropertiesProcessor().process(properties).stream() + .map(InvalidProperty::getPropertyName) + .collect(Collectors.toList()); + } + + private Map withMandatoryNonCredentialFields( + final Map properties) { + properties.put(CONSTANTS.getProjectNameKey(), "MyProject"); + properties.put(CONSTANTS.getRunbookNameKey(), "Rebuild indexes"); + properties.put(CONSTANTS.getDeployToKey(), "Production"); + return properties; + } + + @Test + void connectionOnlyIsValidForServerAndKey() { + final Map properties = withMandatoryNonCredentialFields(new HashMap<>()); + properties.put(CONSTANTS.getConnectionIdKey(), "PROJECT_EXT_1"); + final Collection errors = validate(properties); + assertThat(errors).doesNotContain(CONSTANTS.getServerKey(), CONSTANTS.getApiKey()); + } + + @Test + void manualOnlyIsValid() { + final Map properties = withMandatoryNonCredentialFields(new HashMap<>()); + properties.put(CONSTANTS.getServerKey(), "https://octo"); + properties.put(CONSTANTS.getApiKey(), "API-KEY"); + assertThat(validate(properties)) + .doesNotContain(CONSTANTS.getServerKey(), CONSTANTS.getApiKey()); + } + + @Test + void neitherConnectionNorManualIsInvalid() { + final Map properties = withMandatoryNonCredentialFields(new HashMap<>()); + assertThat(validate(properties)).contains(CONSTANTS.getServerKey(), CONSTANTS.getApiKey()); + } + + @Test + void runbookAndEnvironmentAreMandatory() { + final Map properties = new HashMap<>(); + properties.put(CONSTANTS.getConnectionIdKey(), "PROJECT_EXT_1"); + properties.put(CONSTANTS.getProjectNameKey(), "MyProject"); + + assertThat(validate(properties)) + .contains(CONSTANTS.getRunbookNameKey(), CONSTANTS.getDeployToKey()); + } + + @Test + void stripsInlineCredentialFieldsWhenConnectionSelectedAndValidationPasses() { + final Map properties = withMandatoryNonCredentialFields(new HashMap<>()); + properties.put(CONSTANTS.getConnectionIdKey(), "PROJECT_EXT_1"); + properties.put(CONSTANTS.getServerKey(), "https://octo"); + properties.put(CONSTANTS.getApiKey(), "API-KEY"); + properties.put(CONSTANTS.getOctopusVersion(), "3.0+"); + properties.put(CONSTANTS.getSpaceName(), "Default"); + + assertThat(validate(properties)).isEmpty(); + + assertThat(properties) + .doesNotContainKeys( + CONSTANTS.getServerKey(), CONSTANTS.getApiKey(), CONSTANTS.getOctopusVersion()); + assertThat(properties).containsEntry(CONSTANTS.getSpaceName(), "Default"); + } + + @Test + void retainsInlineCredentialFieldsWhenValidationFails() { + final Map properties = new HashMap<>(); + properties.put(CONSTANTS.getConnectionIdKey(), "PROJECT_EXT_1"); + properties.put(CONSTANTS.getServerKey(), "https://octo"); + properties.put(CONSTANTS.getApiKey(), "API-KEY"); + properties.put(CONSTANTS.getOctopusVersion(), "3.0+"); + + final Collection errors = validate(properties); + + assertThat(errors).contains(CONSTANTS.getProjectNameKey()); + assertThat(properties) + .containsKeys( + CONSTANTS.getServerKey(), CONSTANTS.getApiKey(), CONSTANTS.getOctopusVersion()); + } +}