Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion USAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -765,7 +765,7 @@ tw managed-identities credentials -o my-org -n hpc1 add -l my_linux_user -k ~/.s
- `tw launch`, `tw pipelines add|update`, `tw runs relaunch`, and `tw actions add|update`: `--tower-config`.
- `tw runs star|unstar`, `tw runs view log [--next <cursor>]`, and `tw runs delete -i <id>,<id>`.
- `tw pipelines schema`: show a pipeline's parameter schema.
- `tw actions add bucket`.
- `tw actions add cron|bucket`.
- `tw studios extend|rename-checkpoint`. `--mount-data-uris` also accepts a folder inside a data link.
- `tw data-links delete-content|download-script`.
- `tw datasets add --url`, `tw datasets update --url`, and `tw datasets disable-version`.
Expand Down
7 changes: 7 additions & 0 deletions conf/reflect-config.json
Original file line number Diff line number Diff line change
Expand Up @@ -384,6 +384,13 @@
"fields":[{"name":"dataLinkId"}, {"name":"events"}, {"name":"markerFile"}],
"methods":[{"name":"<init>","parameterTypes":[] }]
},
{
"name":"io.seqera.tower.cli.commands.actions.add.AddCronCmd",
"queryAllDeclaredMethods":true,
"queryAllDeclaredConstructors":true,
"fields":[{"name":"cronExpression"}, {"name":"timezone"}],
"methods":[{"name":"<init>","parameterTypes":[] }]
},
{
"name":"io.seqera.tower.cli.commands.actions.add.AddGitHubCmd",
"allDeclaredFields":true,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@

import io.seqera.tower.ApiException;
import io.seqera.tower.cli.commands.actions.add.AddBucketCmd;
import io.seqera.tower.cli.commands.actions.add.AddCronCmd;
import io.seqera.tower.cli.commands.actions.add.AddGitHubCmd;
import io.seqera.tower.cli.commands.actions.add.AddTowerCmd;
import io.seqera.tower.cli.exceptions.ShowUsageException;
Expand All @@ -32,6 +33,7 @@
subcommands = {
AddGitHubCmd.class,
AddTowerCmd.class,
AddCronCmd.class,
AddBucketCmd.class,
}
)
Expand Down
12 changes: 12 additions & 0 deletions src/main/java/io/seqera/tower/cli/commands/actions/UpdateCmd.java
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
import io.seqera.tower.model.ActionResponseDto;
import io.seqera.tower.model.ActionSource;
import io.seqera.tower.model.BucketActionRequest;
import io.seqera.tower.model.CronActionRequest;
import io.seqera.tower.model.UpdateActionRequest;
import io.seqera.tower.model.WorkflowLaunchRequest;
import picocli.CommandLine;
Expand All @@ -52,6 +53,12 @@ public class UpdateCmd extends AbstractActionsCmd {
@CommandLine.Option(names = {"--new-name"}, description = "Updated action name. Must be unique per workspace. Names consist of alphanumeric, hyphen, and underscore characters.")
public String newName;

@CommandLine.Option(names = {"--cron-expression"}, description = "Schedule of a cron action, as a standard 5-field cron expression.")
public String cronExpression;

@CommandLine.Option(names = {"--timezone"}, description = "IANA timezone the schedule of a cron action runs in.")
public String timezone;

@CommandLine.Option(names = {"--marker-file"}, description = "Marker file of a bucket action, relative to the data link path.")
public String markerFile;

Expand Down Expand Up @@ -115,6 +122,11 @@ protected Response exec() throws ApiException, IOException {
request.setName(newName != null ? newName : actionName);
request.setLaunch(workflowLaunchRequest);

if (cronExpression != null || timezone != null) {
requireSource(action, ActionSource.cron, "--cron-expression and --timezone");
request.setCron(new CronActionRequest().expression(cronExpression).timezone(timezone));
}

if (markerFile != null || events != null) {
requireSource(action, ActionSource.bucket, "--marker-file and --events");
request.setBucket(new BucketActionRequest().markerFile(markerFile).events(events));
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
/*
* Copyright 2021-2026, Seqera.
*
* 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 io.seqera.tower.cli.commands.actions.add;

import io.seqera.tower.model.ActionSource;
import io.seqera.tower.model.CreateActionRequest;
import io.seqera.tower.model.CronActionRequest;
import picocli.CommandLine;

@CommandLine.Command(
name = "cron",
description = "Add a pipeline action triggered on a recurring schedule"
)
public class AddCronCmd extends AbstractAddCmd {

@CommandLine.Option(names = {"--cron-expression"}, description = "Schedule as a standard 5-field cron expression (e.g., '0 2 * * *').", required = true)
public String cronExpression;

@CommandLine.Option(names = {"--timezone"}, description = "IANA timezone the schedule runs in (e.g., Europe/London). Default: UTC.")
public String timezone;

@Override
protected ActionSource getSource() {
return ActionSource.cron;
}

@Override
protected void configureTrigger(CreateActionRequest request) {
request.setCron(new CronActionRequest().expression(cronExpression).timezone(timezone));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ public void toString(PrintWriter out) {

actions.forEach(element -> {

// Arrays.asList, not List.of: bucket actions have no endpoint until provisioned.
// Arrays.asList, not List.of: cron actions, and bucket actions until provisioned, have no endpoint.
List<String> rows = new ArrayList<>(Arrays.asList(
formatActionId(element.getId(), baseWorkspaceUrl),
element.getName(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
import io.seqera.tower.cli.utils.TableList;
import io.seqera.tower.model.ActionResponseDto;
import io.seqera.tower.model.BucketActionConfig;
import io.seqera.tower.model.CronActionConfig;
import io.seqera.tower.model.WorkflowLaunchRequest;

import java.io.PrintWriter;
Expand Down Expand Up @@ -80,6 +81,11 @@ public void toString(PrintWriter out) {

private void addTriggerRows(TableList table) {
switch (action.getConfig()) {
case CronActionConfig cron -> {
table.addRow("Cron expression", cron.getExpression());
table.addRow("Timezone", cron.getTimezone());
table.addRow("Next execution", FormatHelper.formatTime(action.getNextExecution()));
}
case BucketActionConfig bucket -> {
table.addRow("Data link ID", bucket.getDataLinkId());
table.addRow("Bucket", bucket.getBucketName());
Expand Down
78 changes: 78 additions & 0 deletions src/test/java/io/seqera/tower/cli/actions/ActionsCmdTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -794,6 +794,68 @@ void testPauseError(MockServerClient mock) throws IOException {
assertEquals(errorMessage(out.app, new TowerException(String.format("An error has occur while setting the action '%s' to '%s'", "hello", "PAUSE"))), out.stdErr);
}

@ParameterizedTest
@EnumSource(OutputType.class)
void testAddCron(OutputType format, MockServerClient mock) {
mock.reset();
mockPrimaryComputeEnv(mock);

mock.when(
request().withMethod("POST").withPath("/actions")
.withBody(json("{\"name\": \"nightly\", \"source\": \"cron\", \"cron\": {\"expression\": \"0 2 * * *\", \"timezone\": \"Europe/London\"}}", MatchType.ONLY_MATCHING_FIELDS)),
exactly(1)
).respond(
response().withStatusCode(200).withBody(loadResource("/actions/action_add")).withContentType(MediaType.APPLICATION_JSON)
);

ExecOut out = exec(format, mock, "actions", "add", "cron", "-n", "nightly", "--pipeline", "https://github.com/pditommaso/nf-sleep", "--cron-expression", "0 2 * * *", "--timezone", "Europe/London");
assertOutput(format, out, new ActionAdd("nightly", USER_WORKSPACE_NAME, "2Z1g6MCWpOLgHLA65cw1qt"));
}

@Test
void testUpdateCron(MockServerClient mock) {
mock.reset();
mockActionLookup(mock, "cron", CRON_CONFIG);

mock.when(
request().withMethod("PUT").withPath("/actions/57byWxhmUDLLWIF4J97XEP")
.withBody(json("{\"name\": \"hello\", \"cron\": {\"expression\": \"*/15 * * * *\"}}", MatchType.ONLY_MATCHING_FIELDS)),
exactly(1)
).respond(
response().withStatusCode(204)
);

ExecOut out = exec(mock, "actions", "update", "-n", "hello", "--cron-expression", "*/15 * * * *");
assertOutput(OutputType.console, out, new ActionUpdate("hello", USER_WORKSPACE_NAME, "57byWxhmUDLLWIF4J97XEP"));
}

@Test
void testUpdateCronOptionsOnGithubAction(MockServerClient mock) {
mock.reset();
mockActionLookup(mock, "github", "{\"events\": [\"push\"], \"discriminator\": \"github\"}");

ExecOut out = exec(mock, "actions", "update", "-n", "hello", "--timezone", "UTC");

assertEquals("", out.stdOut);
assertEquals(1, out.exitCode);
assertEquals(errorMessage(out.app, new TowerException("Options --cron-expression and --timezone apply only to cron actions, but action 'hello' is a github action")), out.stdErr);
mock.verify(request().withMethod("PUT"), VerificationTimes.never());
}

@Test
void testViewCron(MockServerClient mock) {
mock.reset();
mockActionLookup(mock, "cron", CRON_CONFIG);
mockUserInfo(mock);

ExecOut out = exec(mock, "actions", "view", "-n", "hello");

assertEquals("", out.stdErr);
assertEquals(0, out.exitCode);
assertTrue(out.stdOut.contains("Cron expression | 0 2 * * *"), out.stdOut);
assertTrue(out.stdOut.matches("(?s).*Timezone +\\| Europe/London.*"), out.stdOut);
}

@ParameterizedTest
@EnumSource(OutputType.class)
void testAddBucket(OutputType format, MockServerClient mock) {
Expand Down Expand Up @@ -842,6 +904,18 @@ void testUpdateBucketOptionsOnGithubAction(MockServerClient mock) {
mock.verify(request().withMethod("PUT"), VerificationTimes.never());
}

@Test
void testUpdateBucketOptionsOnCronAction(MockServerClient mock) {
mock.reset();
mockActionLookup(mock, "cron", CRON_CONFIG);

ExecOut out = exec(mock, "actions", "update", "-n", "hello", "--events", "object:created");

assertEquals(1, out.exitCode);
assertEquals(errorMessage(out.app, new TowerException("Options --marker-file and --events apply only to bucket actions, but action 'hello' is a cron action")), out.stdErr);
mock.verify(request().withMethod("PUT"), VerificationTimes.never());
}

@Test
void testViewBucket(MockServerClient mock) {
mock.reset();
Expand Down Expand Up @@ -869,6 +943,7 @@ void testListNewSources(MockServerClient mock) {
request().withMethod("GET").withPath("/actions"), exactly(1)
).respond(
response().withStatusCode(200).withBody("{\"actions\": [" +
"{\"id\": \"1a\", \"name\": \"nightly\", \"source\": \"cron\", \"status\": \"ACTIVE\", \"endpoint\": null, \"config\": " + CRON_CONFIG + "}," +
"{\"id\": \"2b\", \"name\": \"on-upload\", \"source\": \"bucket\", \"status\": \"ERROR\", \"endpoint\": null, \"config\": " + BUCKET_CONFIG + "}" +
"]}").withContentType(MediaType.APPLICATION_JSON)
);
Expand All @@ -877,9 +952,12 @@ void testListNewSources(MockServerClient mock) {

assertEquals("", out.stdErr);
assertEquals(0, out.exitCode);
assertTrue(out.stdOut.matches("(?s).*nightly .*cron.*"), out.stdOut);
assertTrue(out.stdOut.matches("(?s).*on-upload .*bucket.*"), out.stdOut);
}

private static final String CRON_CONFIG = "{\"expression\": \"0 2 * * *\", \"timezone\": \"Europe/London\", \"discriminator\": \"cron\"}";

private void mockPrimaryComputeEnv(MockServerClient mock) {
mock.when(
request().withMethod("GET").withPath("/compute-envs").withQueryStringParameter("status", "AVAILABLE"), exactly(1)
Expand Down
Loading