diff --git a/USAGE.md b/USAGE.md index c7ac488a..5062c0e8 100644 --- a/USAGE.md +++ b/USAGE.md @@ -754,6 +754,16 @@ tw managed-identities add -o my-org -n hpc1 -p slurm -H login.hpc.example.com tw managed-identities credentials -o my-org -n hpc1 add -l my_linux_user -k ~/.ssh/id_rsa ``` +## Lineage + +Run `tw lineage -h` to view the supported operations: `resolve`, `view`, `search`, `upstream`, and `downstream`. Lineage must be enabled for the workspace. + +```bash +tw lineage resolve -w my-org/my-workspace --file-path s3://bucket/results/out.bam +tw lineage resolve -w my-org/my-workspace --session-id --run-name +tw lineage upstream -w my-org/my-workspace -i +``` + ## Other additions - `tw compute-envs enable|disable`: enable or disable a compute environment. @@ -765,7 +775,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`. List the Nextflow and Fusion versions you can launch with `tw info versions` (`-c fusion` for Fusion). - `tw runs star|unstar`, `tw runs view log [--next ]`, and `tw runs delete -i ,`. - `tw pipelines schema`: show a pipeline's parameter schema. -- `tw studios star|unstar|extend|rename-checkpoint`. `--mount-data-uris` also accepts a folder inside a data link. +- `tw studios star|unstar|extend|logs|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`. diff --git a/conf/reflect-config.json b/conf/reflect-config.json index 2cc75b0b..3854e104 100644 --- a/conf/reflect-config.json +++ b/conf/reflect-config.json @@ -214,6 +214,12 @@ "allDeclaredMethods":true, "methods":[{"name":"","parameterTypes":[] }] }, +{ + "name":"io.seqera.tower.cli.commands.LineageCmd", + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true, + "methods":[{"name":"","parameterTypes":[] }] +}, { "name":"io.seqera.tower.cli.commands.ManagedIdentitiesCmd", "queryAllDeclaredMethods":true, @@ -1503,6 +1509,71 @@ "queryAllDeclaredMethods":true, "methods":[{"name":"","parameterTypes":[] }] }, +{ + "name":"io.seqera.tower.cli.commands.lineage.AbstractLineageCmd", + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true +}, +{ + "name":"io.seqera.tower.cli.commands.lineage.AbstractNavigateCmd", + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true, + "fields":[{"name":"lidOptions"}, {"name":"type"}, {"name":"workspace"}] +}, +{ + "name":"io.seqera.tower.cli.commands.lineage.DownstreamCmd", + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true, + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.seqera.tower.cli.commands.lineage.LidOptions", + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true, + "fields":[{"name":"lid"}], + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.seqera.tower.cli.commands.lineage.ResolveCmd", + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true, + "fields":[{"name":"source"}, {"name":"workspace"}], + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.seqera.tower.cli.commands.lineage.ResolveCmd$Run", + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true, + "fields":[{"name":"runName"}, {"name":"sessionId"}], + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.seqera.tower.cli.commands.lineage.ResolveCmd$Source", + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true, + "fields":[{"name":"filePath"}, {"name":"run"}], + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.seqera.tower.cli.commands.lineage.SearchCmd", + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true, + "fields":[{"name":"max"}, {"name":"pageToken"}, {"name":"query"}], + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.seqera.tower.cli.commands.lineage.UpstreamCmd", + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true, + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.seqera.tower.cli.commands.lineage.ViewCmd", + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true, + "fields":[{"name":"lidOptions"}, {"name":"summary"}, {"name":"workspace"}], + "methods":[{"name":"","parameterTypes":[] }] +}, { "name":"io.seqera.tower.cli.commands.managedidentities.AbstractManagedIdentitiesCmd", "queryAllDeclaredMethods":true, @@ -2171,6 +2242,13 @@ "queryAllDeclaredMethods":true, "methods":[{"name":"","parameterTypes":[] }] }, +{ + "name":"io.seqera.tower.cli.commands.studios.LogsCmd", + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true, + "fields":[{"name":"maxLength"}, {"name":"next"}, {"name":"studioRefOptions"}, {"name":"workspace"}], + "methods":[{"name":"","parameterTypes":[] }] +}, { "name":"io.seqera.tower.cli.commands.studios.ParentStudioRefOptions", "allDeclaredFields":true, @@ -2776,6 +2854,24 @@ "queryAllDeclaredMethods":true, "queryAllDeclaredConstructors":true }, +{ + "name":"io.seqera.tower.cli.responses.lineage.LineageRecordView", + "allDeclaredFields":true, + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true +}, +{ + "name":"io.seqera.tower.cli.responses.lineage.LineageRecordsList", + "allDeclaredFields":true, + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true +}, +{ + "name":"io.seqera.tower.cli.responses.lineage.LineageResolved", + "allDeclaredFields":true, + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true +}, { "name":"io.seqera.tower.cli.responses.managedidentities.ManagedCredentialsAdded", "allDeclaredFields":true, @@ -3210,6 +3306,12 @@ "queryAllDeclaredMethods":true, "queryAllDeclaredConstructors":true }, +{ + "name":"io.seqera.tower.cli.responses.studios.StudioLog", + "allDeclaredFields":true, + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true +}, { "name":"io.seqera.tower.cli.responses.studios.StudioStarred", "allDeclaredFields":true, diff --git a/src/main/java/io/seqera/tower/cli/Tower.java b/src/main/java/io/seqera/tower/cli/Tower.java index 68c8c9e7..b90593d9 100644 --- a/src/main/java/io/seqera/tower/cli/Tower.java +++ b/src/main/java/io/seqera/tower/cli/Tower.java @@ -31,6 +31,7 @@ import io.seqera.tower.cli.commands.DatasetsCmd; import io.seqera.tower.cli.commands.InfoCmd; import io.seqera.tower.cli.commands.LaunchCmd; +import io.seqera.tower.cli.commands.LineageCmd; import io.seqera.tower.cli.commands.ManagedIdentitiesCmd; import io.seqera.tower.cli.commands.MembersCmd; import io.seqera.tower.cli.commands.OrganizationsCmd; @@ -72,6 +73,7 @@ InfoCmd.class, LabelsCmd.class, LaunchCmd.class, + LineageCmd.class, ManagedIdentitiesCmd.class, MembersCmd.class, OrganizationsCmd.class, diff --git a/src/main/java/io/seqera/tower/cli/commands/AbstractApiCmd.java b/src/main/java/io/seqera/tower/cli/commands/AbstractApiCmd.java index 41de2bb1..83ca26af 100644 --- a/src/main/java/io/seqera/tower/cli/commands/AbstractApiCmd.java +++ b/src/main/java/io/seqera/tower/cli/commands/AbstractApiCmd.java @@ -31,6 +31,7 @@ import io.seqera.tower.api.IdentitiesApi; import io.seqera.tower.api.LabelsApi; import io.seqera.tower.api.LaunchApi; +import io.seqera.tower.api.LineageApi; import io.seqera.tower.api.NextflowApi; import io.seqera.tower.api.OrgsApi; import io.seqera.tower.api.PipelineSchemasApi; @@ -253,6 +254,10 @@ protected LaunchApi launchApi() throws ApiException { return launchApi == null ? new LaunchApi(apiClient()) : launchApi; } + protected LineageApi lineageApi() throws ApiException { + return new LineageApi(apiClient()); + } + protected NextflowApi nextflowApi() throws ApiException { return nextflowApi == null ? new NextflowApi(apiClient()) : nextflowApi; } diff --git a/src/main/java/io/seqera/tower/cli/commands/LineageCmd.java b/src/main/java/io/seqera/tower/cli/commands/LineageCmd.java new file mode 100644 index 00000000..b405922b --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/commands/LineageCmd.java @@ -0,0 +1,39 @@ +/* + * 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; + +import io.seqera.tower.cli.commands.lineage.DownstreamCmd; +import io.seqera.tower.cli.commands.lineage.ResolveCmd; +import io.seqera.tower.cli.commands.lineage.SearchCmd; +import io.seqera.tower.cli.commands.lineage.UpstreamCmd; +import io.seqera.tower.cli.commands.lineage.ViewCmd; +import picocli.CommandLine.Command; + +@Command( + name = "lineage", + description = "Explore data lineage records. Lineage must be enabled for the workspace.", + subcommands = { + ResolveCmd.class, + ViewCmd.class, + SearchCmd.class, + UpstreamCmd.class, + DownstreamCmd.class, + } +) +public class LineageCmd extends AbstractRootCmd { +} diff --git a/src/main/java/io/seqera/tower/cli/commands/StudiosCmd.java b/src/main/java/io/seqera/tower/cli/commands/StudiosCmd.java index 8cbb28b0..ba1e9fce 100644 --- a/src/main/java/io/seqera/tower/cli/commands/StudiosCmd.java +++ b/src/main/java/io/seqera/tower/cli/commands/StudiosCmd.java @@ -20,6 +20,7 @@ import io.seqera.tower.cli.commands.studios.CheckpointsCmd; import io.seqera.tower.cli.commands.studios.DeleteCmd; import io.seqera.tower.cli.commands.studios.ExtendCmd; +import io.seqera.tower.cli.commands.studios.LogsCmd; import io.seqera.tower.cli.commands.studios.RenameCheckpointCmd; import io.seqera.tower.cli.commands.studios.StarCmd; import io.seqera.tower.cli.commands.studios.UnstarCmd; @@ -50,6 +51,7 @@ StarCmd.class, UnstarCmd.class, ExtendCmd.class, + LogsCmd.class, } ) public class StudiosCmd extends AbstractRootCmd { diff --git a/src/main/java/io/seqera/tower/cli/commands/lineage/AbstractLineageCmd.java b/src/main/java/io/seqera/tower/cli/commands/lineage/AbstractLineageCmd.java new file mode 100644 index 00000000..a9669b88 --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/commands/lineage/AbstractLineageCmd.java @@ -0,0 +1,36 @@ +/* + * 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.lineage; + +import io.seqera.tower.cli.commands.AbstractApiCmd; +import picocli.CommandLine.Command; + +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; + +@Command +public abstract class AbstractLineageCmd extends AbstractApiCmd { + + /** + * The SDK encodes the LID path segment once, and the API decodes it twice: proxies normalize an + * encoded '/' or ':' in paths, so the web UI double-encodes LIDs. Encode once more here to match. + */ + protected static String lidPathSegment(String lid) { + return URLEncoder.encode(lid, StandardCharsets.UTF_8); + } +} diff --git a/src/main/java/io/seqera/tower/cli/commands/lineage/AbstractNavigateCmd.java b/src/main/java/io/seqera/tower/cli/commands/lineage/AbstractNavigateCmd.java new file mode 100644 index 00000000..49fd5ef1 --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/commands/lineage/AbstractNavigateCmd.java @@ -0,0 +1,53 @@ +/* + * 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.lineage; + +import io.seqera.tower.ApiException; +import io.seqera.tower.cli.commands.global.WorkspaceRequiredOptions; +import io.seqera.tower.cli.responses.Response; +import io.seqera.tower.cli.responses.lineage.LineageRecordsList; +import io.seqera.tower.model.SearchResult; +import picocli.CommandLine.Command; +import picocli.CommandLine.Mixin; +import picocli.CommandLine.Option; + +@Command +public abstract class AbstractNavigateCmd extends AbstractLineageCmd { + + public enum RecordType {FileOutput, TaskRun} + + @Mixin + public WorkspaceRequiredOptions workspace; + + @Mixin + public LidOptions lidOptions; + + @Option(names = {"-t", "--type"}, description = "Show only records of this type (${COMPLETION-CANDIDATES})") + public RecordType type; + + protected abstract String direction(); + + protected abstract SearchResult navigate(String lid, Long wspId, String type) throws ApiException; + + @Override + protected Response exec() throws ApiException { + Long wspId = workspaceId(workspace.workspace); + SearchResult result = navigate(lidPathSegment(lidOptions.lid), wspId, type == null ? null : type.name()); + return new LineageRecordsList(String.format("Lineage records %s of '%s'", direction(), lidOptions.lid), result.getRecords(), result.getNextPageToken()); + } +} diff --git a/src/main/java/io/seqera/tower/cli/commands/lineage/DownstreamCmd.java b/src/main/java/io/seqera/tower/cli/commands/lineage/DownstreamCmd.java new file mode 100644 index 00000000..8b09df15 --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/commands/lineage/DownstreamCmd.java @@ -0,0 +1,39 @@ +/* + * 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.lineage; + +import io.seqera.tower.ApiException; +import io.seqera.tower.model.SearchResult; +import picocli.CommandLine.Command; + +@Command( + name = "downstream", + description = "List the records one hop downstream of a task or file record. By default, the tasks that consumed a file, or the output files of a task." +) +public class DownstreamCmd extends AbstractNavigateCmd { + + @Override + protected String direction() { + return "downstream"; + } + + @Override + protected SearchResult navigate(String lid, Long wspId, String type) throws ApiException { + return lineageApi().navigateLineageDownstream(lid, wspId, type); + } +} diff --git a/src/main/java/io/seqera/tower/cli/commands/lineage/LidOptions.java b/src/main/java/io/seqera/tower/cli/commands/lineage/LidOptions.java new file mode 100644 index 00000000..ffc9d025 --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/commands/lineage/LidOptions.java @@ -0,0 +1,26 @@ +/* + * 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.lineage; + +import picocli.CommandLine.Option; + +public class LidOptions { + + @Option(names = {"-i", "--id"}, description = "Lineage record identifier (LID), e.g. lid:///", required = true) + public String lid; +} diff --git a/src/main/java/io/seqera/tower/cli/commands/lineage/ResolveCmd.java b/src/main/java/io/seqera/tower/cli/commands/lineage/ResolveCmd.java new file mode 100644 index 00000000..6300add2 --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/commands/lineage/ResolveCmd.java @@ -0,0 +1,69 @@ +/* + * 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.lineage; + +import io.seqera.tower.ApiException; +import io.seqera.tower.cli.commands.global.WorkspaceRequiredOptions; +import io.seqera.tower.cli.responses.Response; +import io.seqera.tower.cli.responses.lineage.LineageResolved; +import picocli.CommandLine.ArgGroup; +import picocli.CommandLine.Command; +import picocli.CommandLine.Mixin; +import picocli.CommandLine.Option; + +@Command( + name = "resolve", + description = "Resolve a pipeline run or a file path to its lineage record identifier (LID)" +) +public class ResolveCmd extends AbstractLineageCmd { + + @Mixin + public WorkspaceRequiredOptions workspace; + + @ArgGroup(multiplicity = "1") + public Source source; + + public static class Source { + + @ArgGroup(exclusive = false) + public Run run; + + @Option(names = {"--file-path"}, description = "Absolute path of a file produced by a pipeline run") + public String filePath; + } + + // A resumed run shares the session ID of its parent, so the run name is needed to pick one run + public static class Run { + + @Option(names = {"--session-id"}, description = "Nextflow session ID of the run", required = true) + public String sessionId; + + @Option(names = {"--run-name"}, description = "Nextflow run name", required = true) + public String runName; + } + + @Override + protected Response exec() throws ApiException { + Long wspId = workspaceId(workspace.workspace); + String sessionId = source.run != null ? source.run.sessionId : null; + String runName = source.run != null ? source.run.runName : null; + + String lid = lineageApi().resolveLineage(wspId, sessionId, runName, source.filePath).getLid(); + return new LineageResolved(workspaceRef(wspId), lid); + } +} diff --git a/src/main/java/io/seqera/tower/cli/commands/lineage/SearchCmd.java b/src/main/java/io/seqera/tower/cli/commands/lineage/SearchCmd.java new file mode 100644 index 00000000..27d8b586 --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/commands/lineage/SearchCmd.java @@ -0,0 +1,47 @@ +/* + * 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.lineage; + +import io.seqera.tower.ApiException; +import io.seqera.tower.cli.responses.Response; +import io.seqera.tower.cli.responses.lineage.LineageRecordsList; +import io.seqera.tower.model.SearchResult; +import picocli.CommandLine.Command; +import picocli.CommandLine.Option; + +@Command( + name = "search", + description = "Search lineage records across all the workspaces you can access" +) +public class SearchCmd extends AbstractLineageCmd { + + @Option(names = {"-q", "--query"}, description = "Search query. Combines free text and the qualifiers: `type`, `workflow`, `task`, `pipeline`, `pipelineId`, `label`, and `workspace` (org/name) or `workspaceId` to narrow the scope. Whitespace means AND, a comma inside a qualifier means OR. Example: -q 'type:FileOutput workspace:acme/dev multiqc'.") + public String query; + + @Option(names = {"--max"}, description = "Maximum number of records per page (capped by the server)") + public Integer max; + + @Option(names = {"--page-token"}, description = "Token of the page to show, as printed by a previous search with the same query") + public String pageToken; + + @Override + protected Response exec() throws ApiException { + SearchResult result = lineageApi().searchLineage(query, pageToken, max); + return new LineageRecordsList("Lineage records", result.getRecords(), result.getNextPageToken()); + } +} diff --git a/src/main/java/io/seqera/tower/cli/commands/lineage/UpstreamCmd.java b/src/main/java/io/seqera/tower/cli/commands/lineage/UpstreamCmd.java new file mode 100644 index 00000000..66cf239f --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/commands/lineage/UpstreamCmd.java @@ -0,0 +1,39 @@ +/* + * 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.lineage; + +import io.seqera.tower.ApiException; +import io.seqera.tower.model.SearchResult; +import picocli.CommandLine.Command; + +@Command( + name = "upstream", + description = "List the records one hop upstream of a task or file record. By default, the task that produced a file, or the input files of a task." +) +public class UpstreamCmd extends AbstractNavigateCmd { + + @Override + protected String direction() { + return "upstream"; + } + + @Override + protected SearchResult navigate(String lid, Long wspId, String type) throws ApiException { + return lineageApi().navigateLineageUpstream(lid, wspId, type); + } +} diff --git a/src/main/java/io/seqera/tower/cli/commands/lineage/ViewCmd.java b/src/main/java/io/seqera/tower/cli/commands/lineage/ViewCmd.java new file mode 100644 index 00000000..f0fd9ea5 --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/commands/lineage/ViewCmd.java @@ -0,0 +1,53 @@ +/* + * 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.lineage; + +import io.seqera.tower.ApiException; +import io.seqera.tower.cli.commands.global.WorkspaceRequiredOptions; +import io.seqera.tower.cli.responses.Response; +import io.seqera.tower.cli.responses.lineage.LineageRecordView; +import io.seqera.tower.model.LineageRecordResponse; +import picocli.CommandLine.Command; +import picocli.CommandLine.Mixin; +import picocli.CommandLine.Option; + +@Command( + name = "view", + description = "View a lineage record" +) +public class ViewCmd extends AbstractLineageCmd { + + @Mixin + public WorkspaceRequiredOptions workspace; + + @Mixin + public LidOptions lidOptions; + + @Option(names = {"--summary"}, description = "Show only the record summary (name, path or run, and labels), read from the lineage index instead of the full record.") + public boolean summary; + + @Override + protected Response exec() throws ApiException { + Long wspId = workspaceId(workspace.workspace); + String lid = lidPathSegment(lidOptions.lid); + LineageRecordResponse record = summary + ? lineageApi().getLineageSummary(lid, wspId) + : lineageApi().getLineageRecord(lid, wspId); + return new LineageRecordView(workspaceRef(wspId), record); + } +} diff --git a/src/main/java/io/seqera/tower/cli/commands/studios/LogsCmd.java b/src/main/java/io/seqera/tower/cli/commands/studios/LogsCmd.java new file mode 100644 index 00000000..bd18625e --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/commands/studios/LogsCmd.java @@ -0,0 +1,53 @@ +/* + * 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.studios; + +import io.seqera.tower.ApiException; +import io.seqera.tower.cli.commands.global.WorkspaceOptionalOptions; +import io.seqera.tower.cli.responses.Response; +import io.seqera.tower.cli.responses.studios.StudioLog; +import io.seqera.tower.model.DataStudioLogResponse; +import picocli.CommandLine; + +@CommandLine.Command( + name = "logs", + description = "Display the process log of the latest studio session." +) +public class LogsCmd extends AbstractStudiosCmd { + + @CommandLine.Mixin + public WorkspaceOptionalOptions workspace; + + @CommandLine.Mixin + public StudioRefOptions studioRefOptions; + + @CommandLine.Option(names = {"--next"}, description = "Pagination cursor of the log page to display, as printed at the end of the previous page.") + public String next; + + @CommandLine.Option(names = {"--max-length"}, description = "Maximum number of characters to return.") + public Long maxLength; + + @Override + protected Response exec() throws ApiException { + Long wspId = workspaceId(workspace.workspace); + String sessionId = getSessionId(studioRefOptions, wspId); + + DataStudioLogResponse response = studiosApi().getDataStudioLog(sessionId, wspId, next, maxLength); + + return new StudioLog(studioRefOptions.getStudioIdentifier(), workspaceRef(wspId), response.getLog()); + } +} diff --git a/src/main/java/io/seqera/tower/cli/responses/lineage/LineageRecordView.java b/src/main/java/io/seqera/tower/cli/responses/lineage/LineageRecordView.java new file mode 100644 index 00000000..80a5fe4d --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/responses/lineage/LineageRecordView.java @@ -0,0 +1,67 @@ +/* + * 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.responses.lineage; + +import com.fasterxml.jackson.core.JsonProcessingException; +import io.seqera.tower.cli.exceptions.TowerRuntimeException; +import io.seqera.tower.cli.responses.Response; +import io.seqera.tower.cli.utils.TableList; +import io.seqera.tower.model.DisplayData; +import io.seqera.tower.model.LineageRecordResponse; + +import java.io.PrintWriter; +import java.util.Objects; + +import static io.seqera.tower.cli.utils.JsonHelper.prettyJson; + +public class LineageRecordView extends Response { + + public final String workspaceRef; + public final LineageRecordResponse record; + + public LineageRecordView(String workspaceRef, LineageRecordResponse record) { + this.workspaceRef = workspaceRef; + this.record = record; + } + + @Override + public void toString(PrintWriter out) { + out.println(ansi(String.format("%n @|bold Lineage record at %s workspace:|@%n", workspaceRef))); + DisplayData display = record.getDisplayData() == null ? new DisplayData() : record.getDisplayData(); + TableList table = new TableList(out, 2); + table.setPrefix(" "); + table.addRow("LID", record.getLid()); + table.addRow("Type", record.getType()); + table.addRow("Record URI", Objects.toString(record.getRecordUri(), "")); + table.addRow("Run ID", Objects.toString(display.getWorkflowId(), "")); + table.addRow("Run name", Objects.toString(display.getRunName(), "")); + table.addRow("Process", Objects.toString(display.getProcessName(), "")); + table.addRow("Pipeline", Objects.toString(display.getPipelineName(), "")); + table.print(); + + if (record.getData() != null) { + out.println(ansi(String.format("%n @|bold Data:|@%n"))); + try { + out.println(prettyJson(record.getData())); + } catch (JsonProcessingException e) { + throw new TowerRuntimeException("Unable to format the lineage record data: " + e.getMessage()); + } + } + out.println(""); + } +} diff --git a/src/main/java/io/seqera/tower/cli/responses/lineage/LineageRecordsList.java b/src/main/java/io/seqera/tower/cli/responses/lineage/LineageRecordsList.java new file mode 100644 index 00000000..9c9e6036 --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/responses/lineage/LineageRecordsList.java @@ -0,0 +1,67 @@ +/* + * 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.responses.lineage; + +import io.seqera.tower.cli.responses.Response; +import io.seqera.tower.cli.utils.TableList; +import io.seqera.tower.model.DisplayData; +import io.seqera.tower.model.LineageRecordResponse; + +import java.io.PrintWriter; +import java.util.List; +import java.util.Objects; + +public class LineageRecordsList extends Response { + + public final String title; + public final List records; + public final String nextPageToken; + + public LineageRecordsList(String title, List records, String nextPageToken) { + this.title = title; + this.records = records; + this.nextPageToken = nextPageToken; + } + + @Override + public void toString(PrintWriter out) { + out.println(ansi(String.format("%n @|bold %s:|@%n", title))); + if (records == null || records.isEmpty()) { + out.println(ansi(" @|yellow No lineage records found|@")); + return; + } + + TableList table = new TableList(out, 5, "LID", "Type", "Run name", "Process", "Workspace ID"); + table.setPrefix(" "); + records.forEach(record -> { + DisplayData display = record.getDisplayData() == null ? new DisplayData() : record.getDisplayData(); + table.addRow( + record.getLid(), + record.getType(), + Objects.toString(display.getRunName(), ""), + Objects.toString(display.getProcessName(), ""), + Objects.toString(display.getWorkspaceId(), "") + ); + }); + table.print(); + if (nextPageToken != null) { + out.println(String.format("%n More records available, use: --page-token %s", nextPageToken)); + } + out.println(""); + } +} diff --git a/src/main/java/io/seqera/tower/cli/responses/lineage/LineageResolved.java b/src/main/java/io/seqera/tower/cli/responses/lineage/LineageResolved.java new file mode 100644 index 00000000..f06ce91d --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/responses/lineage/LineageResolved.java @@ -0,0 +1,36 @@ +/* + * 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.responses.lineage; + +import io.seqera.tower.cli.responses.Response; + +public class LineageResolved extends Response { + + public final String workspaceRef; + public final String lid; + + public LineageResolved(String workspaceRef, String lid) { + this.workspaceRef = workspaceRef; + this.lid = lid; + } + + @Override + public String toString() { + return ansi(String.format("%n @|bold Lineage record at %s workspace:|@ %s%n", workspaceRef, lid)); + } +} diff --git a/src/main/java/io/seqera/tower/cli/responses/studios/StudioLog.java b/src/main/java/io/seqera/tower/cli/responses/studios/StudioLog.java new file mode 100644 index 00000000..729af04e --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/responses/studios/StudioLog.java @@ -0,0 +1,59 @@ +/* + * 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.responses.studios; + +import io.seqera.tower.cli.responses.Response; +import io.seqera.tower.model.LogPage; + +import java.io.PrintWriter; + +public class StudioLog extends Response { + + public final String userSuppliedStudioIdentifier; + public final String workspaceRef; + public final LogPage log; + + public StudioLog(String userSuppliedStudioIdentifier, String workspaceRef, LogPage log) { + this.userSuppliedStudioIdentifier = userSuppliedStudioIdentifier; + this.workspaceRef = workspaceRef; + this.log = log; + } + + @Override + public void toString(PrintWriter out) { + out.println(ansi(String.format("%n @|bold Log of studio %s at %s workspace:|@%n", userSuppliedStudioIdentifier, workspaceRef))); + + if (log == null || log.getEntries() == null || log.getEntries().isEmpty()) { + if (log != null && Boolean.TRUE.equals(log.getPending())) { + out.println(ansi(" @|yellow Waiting for the output log|@")); + } else { + out.println(ansi(" @|yellow No log entries found|@")); + } + return; + } + + log.getEntries().forEach(out::println); + + if (Boolean.TRUE.equals(log.getTruncated())) { + out.println(ansi(String.format("%n @|yellow Log truncated|@"))); + } + if (log.getForwardToken() != null) { + out.println(ansi(String.format("%n @|bold Next page:|@ --next %s", log.getForwardToken()))); + } + out.println(""); + } +} diff --git a/src/test/java/io/seqera/tower/cli/lineage/LineageCmdTest.java b/src/test/java/io/seqera/tower/cli/lineage/LineageCmdTest.java new file mode 100644 index 00000000..ecb2d648 --- /dev/null +++ b/src/test/java/io/seqera/tower/cli/lineage/LineageCmdTest.java @@ -0,0 +1,196 @@ +/* + * 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.lineage; + +import com.fasterxml.jackson.core.JsonProcessingException; +import io.seqera.tower.cli.BaseCmdTest; +import io.seqera.tower.cli.commands.enums.OutputType; +import io.seqera.tower.cli.responses.lineage.LineageRecordView; +import io.seqera.tower.cli.responses.lineage.LineageRecordsList; +import io.seqera.tower.cli.responses.lineage.LineageResolved; +import io.seqera.tower.model.LineageRecordResponse; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.mockserver.client.MockServerClient; +import org.mockserver.model.MediaType; + +import java.util.List; + +import static io.seqera.tower.cli.utils.JsonHelper.parseJson; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockserver.matchers.Times.exactly; +import static org.mockserver.model.HttpRequest.request; +import static org.mockserver.model.HttpResponse.response; + +class LineageCmdTest extends BaseCmdTest { + + private static final String WSP_ID = "75887156211589"; + private static final String WSP_REF = "[organization1 / workspace1]"; + private static final String LID = "lid://abc123/multiqc report.html"; + // The LID is sent double-encoded, as the web UI does: the API decodes it twice back to LID + private static final String LID_PATH = "lid%253A%252F%252Fabc123%252Fmultiqc%2Breport.html"; + + private static final String FILE_RECORD = """ + { + "lid": "lid://abc123/multiqc report.html", + "type": "FileOutput", + "recordUri": "s3://bucket/lineage/abc123/multiqc report.html.data.json", + "data": {"path": "s3://bucket/results/multiqc report.html", "labels": ["qc"]}, + "displayData": {"workspaceId": 75887156211589, "workflowId": "4abc", "runName": "happy_turing", "processName": "MULTIQC", "pipelineName": "nf-core/rnaseq"} + } + """; + + private static final String TASK_RECORD = """ + { + "lid": "lid://def456", + "type": "TaskRun", + "displayData": {"workspaceId": 75887156211589, "workflowId": "4abc", "runName": "happy_turing", "processName": "MULTIQC"} + } + """; + + @BeforeEach + void init(MockServerClient mock) { + mock.reset(); + } + + private void mockWorkspace(MockServerClient mock) { + mock.when(request().withMethod("GET").withPath("/user-info"), exactly(1)) + .respond(response().withStatusCode(200).withBody(loadResource("user")).withContentType(MediaType.APPLICATION_JSON)); + mock.when(request().withMethod("GET").withPath("/user/1264/workspaces"), exactly(1)) + .respond(response().withStatusCode(200).withBody(loadResource("workspaces/workspaces_list")).withContentType(MediaType.APPLICATION_JSON)); + } + + private void assertSentRawPath(MockServerClient mock, String rawPath) { + String sent = mock.retrieveRecordedRequests(request().withMethod("GET").withPath("/lineage/.*"))[0].getPath().getValue(); + assertEquals(rawPath, sent); + } + + @ParameterizedTest + @EnumSource(OutputType.class) + void testResolveRun(OutputType format, MockServerClient mock) { + mockWorkspace(mock); + mock.when(request().withMethod("GET").withPath("/lineage/resolve") + .withQueryStringParameter("workspaceId", WSP_ID) + .withQueryStringParameter("sessionId", "f1e2d3") + .withQueryStringParameter("runName", "happy_turing"), exactly(1)) + .respond(response().withStatusCode(200).withBody("{\"lid\":\"lid://wf789\"}").withContentType(MediaType.APPLICATION_JSON)); + + ExecOut out = exec(format, mock, "lineage", "resolve", "-w", WSP_ID, "--session-id", "f1e2d3", "--run-name", "happy_turing"); + + assertOutput(format, out, new LineageResolved(WSP_REF, "lid://wf789")); + } + + @ParameterizedTest + @EnumSource(OutputType.class) + void testResolveFilePath(OutputType format, MockServerClient mock) { + mockWorkspace(mock); + mock.when(request().withMethod("GET").withPath("/lineage/resolve") + .withQueryStringParameter("workspaceId", WSP_ID) + .withQueryStringParameter("filePath", "s3://bucket/results/report.html"), exactly(1)) + .respond(response().withStatusCode(200).withBody("{\"lid\":\"lid://abc123/report.html\"}").withContentType(MediaType.APPLICATION_JSON)); + + ExecOut out = exec(format, mock, "lineage", "resolve", "-w", WSP_ID, "--file-path", "s3://bucket/results/report.html"); + + assertOutput(format, out, new LineageResolved(WSP_REF, "lid://abc123/report.html")); + } + + @Test + void testResolveRequiresRunNameWithSessionId(MockServerClient mock) { + ExecOut out = exec(mock, "lineage", "resolve", "-w", WSP_ID, "--session-id", "f1e2d3"); + + assertEquals(2, out.exitCode); + } + + @Test + void testResolveRejectsRunAndFilePath(MockServerClient mock) { + ExecOut out = exec(mock, "lineage", "resolve", "-w", WSP_ID, "--session-id", "f1e2d3", "--run-name", "happy_turing", "--file-path", "/data/x"); + + assertEquals(2, out.exitCode); + } + + @ParameterizedTest + @EnumSource(OutputType.class) + void testView(OutputType format, MockServerClient mock) throws JsonProcessingException { + mockWorkspace(mock); + mock.when(request().withMethod("GET").withPath("/lineage/.*").withQueryStringParameter("workspaceId", WSP_ID), exactly(1)) + .respond(response().withStatusCode(200).withBody(FILE_RECORD).withContentType(MediaType.APPLICATION_JSON)); + + ExecOut out = exec(format, mock, "lineage", "view", "-w", WSP_ID, "-i", LID); + + assertSentRawPath(mock, "/lineage/" + LID_PATH); + assertOutput(format, out, new LineageRecordView(WSP_REF, parseJson(FILE_RECORD, LineageRecordResponse.class))); + } + + @ParameterizedTest + @EnumSource(OutputType.class) + void testViewSummary(OutputType format, MockServerClient mock) throws JsonProcessingException { + mockWorkspace(mock); + mock.when(request().withMethod("GET").withPath("/lineage/.*/summary").withQueryStringParameter("workspaceId", WSP_ID), exactly(1)) + .respond(response().withStatusCode(200).withBody(FILE_RECORD).withContentType(MediaType.APPLICATION_JSON)); + + ExecOut out = exec(format, mock, "lineage", "view", "-w", WSP_ID, "-i", LID, "--summary"); + + assertSentRawPath(mock, "/lineage/" + LID_PATH + "/summary"); + assertOutput(format, out, new LineageRecordView(WSP_REF, parseJson(FILE_RECORD, LineageRecordResponse.class))); + } + + @ParameterizedTest + @EnumSource(OutputType.class) + void testSearch(OutputType format, MockServerClient mock) throws JsonProcessingException { + mock.when(request().withMethod("GET").withPath("/lineage/search") + .withQueryStringParameter("q", "type:FileOutput workspace:organization1/workspace1 multiqc") + .withQueryStringParameter("pageToken", "tok1") + .withQueryStringParameter("pageSize", "2"), exactly(1)) + .respond(response().withStatusCode(200).withBody("{\"records\":[" + FILE_RECORD + "],\"nextPageToken\":\"tok2\"}").withContentType(MediaType.APPLICATION_JSON)); + + ExecOut out = exec(format, mock, "lineage", "search", "-q", "type:FileOutput workspace:organization1/workspace1 multiqc", "--page-token", "tok1", "--max", "2"); + + assertOutput(format, out, new LineageRecordsList("Lineage records", List.of(parseJson(FILE_RECORD, LineageRecordResponse.class)), "tok2")); + } + + @ParameterizedTest + @EnumSource(OutputType.class) + void testUpstream(OutputType format, MockServerClient mock) throws JsonProcessingException { + mockWorkspace(mock); + mock.when(request().withMethod("GET").withPath("/lineage/.*/upstream") + .withQueryStringParameter("workspaceId", WSP_ID) + .withQueryStringParameter("type", "TaskRun"), exactly(1)) + .respond(response().withStatusCode(200).withBody("{\"records\":[" + TASK_RECORD + "]}").withContentType(MediaType.APPLICATION_JSON)); + + ExecOut out = exec(format, mock, "lineage", "upstream", "-w", WSP_ID, "-i", LID, "--type", "taskrun"); + + assertSentRawPath(mock, "/lineage/" + LID_PATH + "/upstream"); + assertOutput(format, out, new LineageRecordsList("Lineage records upstream of '" + LID + "'", List.of(parseJson(TASK_RECORD, LineageRecordResponse.class)), null)); + } + + @ParameterizedTest + @EnumSource(OutputType.class) + void testDownstream(OutputType format, MockServerClient mock) throws JsonProcessingException { + mockWorkspace(mock); + mock.when(request().withMethod("GET").withPath("/lineage/.*/downstream") + .withQueryStringParameter("workspaceId", WSP_ID), exactly(1)) + .respond(response().withStatusCode(200).withBody("{\"records\":[" + FILE_RECORD + "]}").withContentType(MediaType.APPLICATION_JSON)); + + ExecOut out = exec(format, mock, "lineage", "downstream", "-w", WSP_ID, "-i", "lid://def456"); + + assertSentRawPath(mock, "/lineage/lid%253A%252F%252Fdef456/downstream"); + assertOutput(format, out, new LineageRecordsList("Lineage records downstream of 'lid://def456'", List.of(parseJson(FILE_RECORD, LineageRecordResponse.class)), null)); + } +} diff --git a/src/test/java/io/seqera/tower/cli/studios/StudiosCmdTest.java b/src/test/java/io/seqera/tower/cli/studios/StudiosCmdTest.java index ec0458d4..0046aa39 100644 --- a/src/test/java/io/seqera/tower/cli/studios/StudiosCmdTest.java +++ b/src/test/java/io/seqera/tower/cli/studios/StudiosCmdTest.java @@ -32,6 +32,7 @@ import io.seqera.tower.cli.responses.studios.StudioDeleted; import io.seqera.tower.cli.responses.studios.StudioCheckpointUpdated; import io.seqera.tower.cli.responses.studios.StudioLifespanExtended; +import io.seqera.tower.cli.responses.studios.StudioLog; import io.seqera.tower.cli.responses.studios.StudioStarred; import io.seqera.tower.cli.responses.studios.StudioStartSubmitted; import io.seqera.tower.cli.responses.studios.StudioStopSubmitted; @@ -42,6 +43,7 @@ import io.seqera.tower.model.DataStudioCheckpointDto; import io.seqera.tower.model.DataStudioDto; import io.seqera.tower.model.DataStudioTemplatesListResponse; +import io.seqera.tower.model.LogPage; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; @@ -3141,4 +3143,49 @@ void testViewShowsMountPaths(OutputType format, MockServerClient mock) throws Js assertTrue(out.stdOut.contains("s3://aaa-my-bucket/data/inputs, s3://aaa-my-bucket"), out.stdOut); } } + @ParameterizedTest + @EnumSource(OutputType.class) + void testLogs(OutputType format, MockServerClient mock) throws JsonProcessingException { + mockWorkspace(mock); + String logPage = """ + { + "entries": ["Starting studio", "Studio ready"], + "forwardToken": "f/123", + "rewindToken": "b/123", + "pending": false, + "truncated": false + } + """; + mock.when( + request().withMethod("GET").withPath("/studios/3e8370e7/log") + .withQueryStringParameter("workspaceId", "75887156211589") + .withQueryStringParameter("next", "f/100") + .withQueryStringParameter("maxLength", "500"), exactly(1) + ).respond( + response().withStatusCode(200).withBody("{\"log\": " + logPage + "}").withContentType(MediaType.APPLICATION_JSON) + ); + + ExecOut out = exec(format, mock, "studios", "logs", "-w", "75887156211589", "-i", "3e8370e7", "--next", "f/100", "--max-length", "500"); + + assertOutput(format, out, new StudioLog("3e8370e7", "[organization1 / workspace1]", parseJson(logPage, LogPage.class))); + if (format == OutputType.console) { + assertTrue(out.stdOut.contains(String.format("Starting studio%nStudio ready")), out.stdOut); + assertTrue(out.stdOut.contains("--next f/123"), out.stdOut); + } + } + + @Test + void testLogsPending(MockServerClient mock) { + mockWorkspace(mock); + mock.when( + request().withMethod("GET").withPath("/studios/3e8370e7/log").withQueryStringParameter("workspaceId", "75887156211589"), exactly(1) + ).respond( + response().withStatusCode(200).withBody("{\"log\": {\"entries\": [], \"pending\": true}}").withContentType(MediaType.APPLICATION_JSON) + ); + + ExecOut out = exec(mock, "studios", "logs", "-w", "75887156211589", "-i", "3e8370e7"); + + assertEquals(0, out.exitCode); + assertTrue(out.stdOut.contains("Waiting for the output log"), out.stdOut); + } }