diff --git a/USAGE.md b/USAGE.md index fd2b9673c..a5032e429 100644 --- a/USAGE.md +++ b/USAGE.md @@ -709,6 +709,49 @@ $ tw data-links browse -w seqeralabs/showcase -i v1-user-6d8f44c239e2a098b3e02e9 FOLDER | technical/ | 0 ``` +## Organization administration + +These commands act on an organization (`-o`/`--organization`). Most require the organization `OWNER` role. + +### Teams and quotas + +- `tw teams view|update|workspaces`: view a team, update its name or description, and list its workspaces. +- `tw organizations quotas`: view organization quotas. + +## Personal access + +### Access tokens + +Run `tw access-tokens -h` to view the supported operations: `list`, `add`, and `delete`. The token value is only shown when the token is created. + +```bash +tw access-tokens add -n ci +``` + +## Managed identities + +Managed identities give organization members individual SSH access to an HPC cluster (Slurm, LSF, UGE, Altair PBS, or Moab). Run `tw managed-identities -h` to view the supported operations. Each member then adds their own credentials: + +```bash +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 +``` + +## Other additions + +- `tw compute-envs enable|disable`: enable or disable a compute environment. +- `tw compute-envs add`: + - AWS Batch: CloudWatch log group and DRAGEN options. + - AWS Cloud: `--log-group`. Google Cloud: `--project-id`. + - HPC platforms: `--propagate-head-job-options`. + - Seqera Compute: `--data-retention-policy`. +- `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 ]`, and `tw runs delete -i ,`. +- `tw pipelines schema`: show a pipeline's parameter schema. +- `tw studios extend|rename-checkpoint`. +- `tw data-links delete-content|download-script`. +- `tw datasets disable-version`. + [compute-envs]: https://docs.seqera.io/platform/latest/compute-envs/overview [credentials]: https://docs.seqera.io/platform/latest/credentials/overview [git-integration]: https://docs.seqera.io/platform/latest/git/overview diff --git a/conf/reflect-config.json b/conf/reflect-config.json index c144d6570..d45dff074 100644 --- a/conf/reflect-config.json +++ b/conf/reflect-config.json @@ -154,6 +154,12 @@ "allDeclaredFields":true, "allDeclaredMethods":true }, +{ + "name":"io.seqera.tower.cli.commands.AccessTokensCmd", + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true, + "methods":[{"name":"","parameterTypes":[] }] +}, { "name":"io.seqera.tower.cli.commands.ActionsCmd", "allDeclaredFields":true, @@ -208,6 +214,12 @@ "allDeclaredMethods":true, "methods":[{"name":"","parameterTypes":[] }] }, +{ + "name":"io.seqera.tower.cli.commands.ManagedIdentitiesCmd", + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true, + "methods":[{"name":"","parameterTypes":[] }] +}, { "name":"io.seqera.tower.cli.commands.MembersCmd", "allDeclaredFields":true, @@ -268,6 +280,33 @@ "allDeclaredMethods":true, "methods":[{"name":"","parameterTypes":[] }] }, +{ + "name":"io.seqera.tower.cli.commands.accesstokens.AddCmd", + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true, + "fields":[{"name":"name"}], + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.seqera.tower.cli.commands.accesstokens.DeleteCmd", + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true, + "fields":[{"name":"ref"}], + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.seqera.tower.cli.commands.accesstokens.DeleteCmd$TokenRef", + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true, + "fields":[{"name":"id"}, {"name":"name"}], + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.seqera.tower.cli.commands.accesstokens.ListCmd", + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true, + "methods":[{"name":"","parameterTypes":[] }] +}, { "name":"io.seqera.tower.cli.commands.actions.AbstractActionsCmd", "allDeclaredFields":true, @@ -378,6 +417,20 @@ "allDeclaredMethods":true, "methods":[{"name":"","parameterTypes":[] }] }, +{ + "name":"io.seqera.tower.cli.commands.computeenvs.DisableCmd", + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true, + "fields":[{"name":"computeEnvRefOptions"}, {"name":"workspace"}], + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.seqera.tower.cli.commands.computeenvs.EnableCmd", + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true, + "fields":[{"name":"computeEnvRefOptions"}, {"name":"workspace"}], + "methods":[{"name":"","parameterTypes":[] }] +}, { "name":"io.seqera.tower.cli.commands.computeenvs.ExportCmd", "allDeclaredFields":true, @@ -408,6 +461,13 @@ "queryAllDeclaredMethods":true, "methods":[{"name":"","parameterTypes":[] }] }, +{ + "name":"io.seqera.tower.cli.commands.computeenvs.ValidateCmd", + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true, + "fields":[{"name":"computeEnvRefOptions"}, {"name":"force"}, {"name":"workspace"}], + "methods":[{"name":"","parameterTypes":[] }] +}, { "name":"io.seqera.tower.cli.commands.computeenvs.ViewCmd", "allDeclaredFields":true, @@ -672,6 +732,13 @@ "queryAllDeclaredMethods":true, "methods":[{"name":"","parameterTypes":[] }] }, +{ + "name":"io.seqera.tower.cli.commands.computeenvs.platforms.AzCloudPlatform$SchedOptions", + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true, + "fields":[{"name":"machineTypes"}, {"name":"maxCpusPerUser"}, {"name":"maxSpotAttempts"}, {"name":"nvmeEnabled"}, {"name":"predictionModel"}, {"name":"provisioningModel"}, {"name":"schedEnabled"}], + "methods":[{"name":"","parameterTypes":[] }] +}, { "name":"io.seqera.tower.cli.commands.computeenvs.platforms.EksPlatform", "allDeclaredFields":true, @@ -708,6 +775,13 @@ "queryAllDeclaredMethods":true, "methods":[{"name":"","parameterTypes":[] }] }, +{ + "name":"io.seqera.tower.cli.commands.computeenvs.platforms.GoogleCloudPlatform$SchedOptions", + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true, + "fields":[{"name":"billingExportTable"}, {"name":"machineTypes"}, {"name":"maxCpusPerUser"}, {"name":"maxSpotAttempts"}, {"name":"nvmeEnabled"}, {"name":"predictionModel"}, {"name":"provisioningModel"}, {"name":"schedEnabled"}], + "methods":[{"name":"","parameterTypes":[] }] +}, { "name":"io.seqera.tower.cli.commands.computeenvs.platforms.K8sPlatform", "allDeclaredFields":true, @@ -832,6 +906,13 @@ "allDeclaredMethods":true, "methods":[{"name":"","parameterTypes":[] }] }, +{ + "name":"io.seqera.tower.cli.commands.credentials.ValidateCmd", + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true, + "fields":[{"name":"credentialsRefOptions"}, {"name":"force"}, {"name":"workspace"}], + "methods":[{"name":"","parameterTypes":[] }] +}, { "name":"io.seqera.tower.cli.commands.credentials.add.AbstractAddCmd", "allDeclaredFields":true, @@ -1133,12 +1214,26 @@ "queryAllDeclaredMethods":true, "methods":[{"name":"","parameterTypes":[] }] }, +{ + "name":"io.seqera.tower.cli.commands.data.links.DeleteContentCmd", + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true, + "fields":[{"name":"credentialsRef"}, {"name":"dataLinkRefOptions"}, {"name":"dirs"}, {"name":"files"}, {"name":"workspace"}], + "methods":[{"name":"","parameterTypes":[] }] +}, { "name":"io.seqera.tower.cli.commands.data.links.DownloadCmd", "allDeclaredFields":true, "queryAllDeclaredMethods":true, "methods":[{"name":"","parameterTypes":[] }] }, +{ + "name":"io.seqera.tower.cli.commands.data.links.DownloadScriptCmd", + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true, + "fields":[{"name":"credentialsRef"}, {"name":"dataLinkRefOptions"}, {"name":"dirs"}, {"name":"files"}, {"name":"workspace"}], + "methods":[{"name":"","parameterTypes":[] }] +}, { "name":"io.seqera.tower.cli.commands.data.links.ListCmd", "allDeclaredFields":true, @@ -1227,6 +1322,13 @@ "queryAllDeclaredMethods":true, "methods":[{"name":"","parameterTypes":[] }] }, +{ + "name":"io.seqera.tower.cli.commands.datasets.DisableVersionCmd", + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true, + "fields":[{"name":"datasetRefOptions"}, {"name":"version"}, {"name":"workspace"}], + "methods":[{"name":"","parameterTypes":[] }] +}, { "name":"io.seqera.tower.cli.commands.datasets.DownloadCmd", "allDeclaredFields":true, @@ -1382,6 +1484,101 @@ "queryAllDeclaredMethods":true, "methods":[{"name":"","parameterTypes":[] }] }, +{ + "name":"io.seqera.tower.cli.commands.managedidentities.AbstractManagedIdentitiesCmd", + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true +}, +{ + "name":"io.seqera.tower.cli.commands.managedidentities.AddCmd", + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true, + "fields":[{"name":"hostName"}, {"name":"name"}, {"name":"organizationRef"}, {"name":"platform"}, {"name":"port"}], + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.seqera.tower.cli.commands.managedidentities.CredentialsCmd", + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true, + "fields":[{"name":"organizationRef"}, {"name":"ref"}], + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.seqera.tower.cli.commands.managedidentities.DeleteCmd", + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true, + "fields":[{"name":"force"}, {"name":"organizationRef"}, {"name":"ref"}], + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.seqera.tower.cli.commands.managedidentities.ListCmd", + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true, + "fields":[{"name":"organizationRef"}], + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.seqera.tower.cli.commands.managedidentities.ManagedIdentityRefOptions", + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true, + "fields":[{"name":"managedIdentity"}], + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.seqera.tower.cli.commands.managedidentities.ManagedIdentityRefOptions$ManagedIdentityRef", + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true, + "fields":[{"name":"id"}, {"name":"name"}], + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.seqera.tower.cli.commands.managedidentities.SshManagedCredentialsMetadata", + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true, + "fields":[{"name":"userName"}] +}, +{ + "name":"io.seqera.tower.cli.commands.managedidentities.UpdateCmd", + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true, + "fields":[{"name":"hostName"}, {"name":"newName"}, {"name":"organizationRef"}, {"name":"port"}, {"name":"ref"}], + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.seqera.tower.cli.commands.managedidentities.ViewCmd", + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true, + "fields":[{"name":"organizationRef"}, {"name":"ref"}], + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.seqera.tower.cli.commands.managedidentities.credentials.AddCmd", + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true, + "fields":[{"name":"member"}, {"name":"parent"}, {"name":"ssh"}], + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.seqera.tower.cli.commands.managedidentities.credentials.DeleteCmd", + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true, + "fields":[{"name":"force"}, {"name":"member"}, {"name":"parent"}], + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.seqera.tower.cli.commands.managedidentities.credentials.SshCredentialsOptions", + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true, + "fields":[{"name":"linuxUserName"}, {"name":"passphrase"}, {"name":"privateKey"}], + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.seqera.tower.cli.commands.managedidentities.credentials.UpdateCmd", + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true, + "fields":[{"name":"member"}, {"name":"parent"}, {"name":"ssh"}], + "methods":[{"name":"","parameterTypes":[] }] +}, { "name":"io.seqera.tower.cli.commands.members.AbstractMembersClass", "allDeclaredFields":true, @@ -1458,6 +1655,13 @@ "allDeclaredMethods":true, "methods":[{"name":"","parameterTypes":[] }] }, +{ + "name":"io.seqera.tower.cli.commands.organizations.QuotasCmd", + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true, + "fields":[{"name":"organizationRefOptions"}], + "methods":[{"name":"","parameterTypes":[] }] +}, { "name":"io.seqera.tower.cli.commands.organizations.UpdateCmd", "allDeclaredFields":true, @@ -1546,6 +1750,13 @@ "allDeclaredMethods":true, "methods":[{"name":"","parameterTypes":[] }] }, +{ + "name":"io.seqera.tower.cli.commands.pipelines.NullableLaunchOptions", + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true, + "fields":[{"name":"fusionVersion"}, {"name":"nextflowVersion"}, {"name":"outputDir"}, {"name":"syntaxParser"}], + "methods":[{"name":"","parameterTypes":[] }] +}, { "name":"io.seqera.tower.cli.commands.pipelines.PipelineRefOptions", "allDeclaredFields":true, @@ -1558,6 +1769,13 @@ "queryAllDeclaredMethods":true, "methods":[{"name":"","parameterTypes":[] }] }, +{ + "name":"io.seqera.tower.cli.commands.pipelines.SchemaCmd", + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true, + "fields":[{"name":"params"}, {"name":"pipelineRefOptions"}, {"name":"versionRef"}, {"name":"workspace"}], + "methods":[{"name":"","parameterTypes":[] }] +}, { "name":"io.seqera.tower.cli.commands.pipelines.UpdateCmd", "allDeclaredFields":true, @@ -1682,6 +1900,20 @@ "allDeclaredMethods":true, "methods":[{"name":"","parameterTypes":[] }] }, +{ + "name":"io.seqera.tower.cli.commands.runs.StarCmd", + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true, + "fields":[{"name":"id"}, {"name":"workspace"}], + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.seqera.tower.cli.commands.runs.UnstarCmd", + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true, + "fields":[{"name":"id"}, {"name":"workspace"}], + "methods":[{"name":"","parameterTypes":[] }] +}, { "name":"io.seqera.tower.cli.commands.runs.ViewCmd", "allDeclaredFields":true, @@ -1694,6 +1926,13 @@ "allDeclaredMethods":true, "methods":[{"name":"","parameterTypes":[] }] }, +{ + "name":"io.seqera.tower.cli.commands.runs.log.LogCmd", + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true, + "fields":[{"name":"parentCommand"}, {"name":"task"}], + "methods":[{"name":"","parameterTypes":[] }] +}, { "name":"io.seqera.tower.cli.commands.runs.metrics.MetricsCmd", "allDeclaredFields":true, @@ -1805,6 +2044,13 @@ "queryAllDeclaredMethods":true, "methods":[{"name":"","parameterTypes":[] }] }, +{ + "name":"io.seqera.tower.cli.commands.studios.ExtendCmd", + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true, + "fields":[{"name":"studioRefOptions"}, {"name":"workspace"}], + "methods":[{"name":"","parameterTypes":[] }] +}, { "name":"io.seqera.tower.cli.commands.studios.ListCmd", "allDeclaredFields":true, @@ -1823,6 +2069,13 @@ "queryAllDeclaredMethods":true, "methods":[{"name":"","parameterTypes":[] }] }, +{ + "name":"io.seqera.tower.cli.commands.studios.RenameCheckpointCmd", + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true, + "fields":[{"name":"checkpointId"}, {"name":"newName"}, {"name":"studioRefOptions"}, {"name":"workspace"}], + "methods":[{"name":"","parameterTypes":[] }] +}, { "name":"io.seqera.tower.cli.commands.studios.StartCmd", "allDeclaredFields":true, @@ -1865,6 +2118,13 @@ "queryAllDeclaredMethods":true, "methods":[{"name":"","parameterTypes":[] }] }, +{ + "name":"io.seqera.tower.cli.commands.studios.StudioRemoteConfigOptions", + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true, + "fields":[{"name":"repository"}, {"name":"revision"}], + "methods":[{"name":"","parameterTypes":[] }] +}, { "name":"io.seqera.tower.cli.commands.studios.StudioTemplateOptions", "allDeclaredFields":true, @@ -1924,6 +2184,41 @@ "allDeclaredMethods":true, "methods":[{"name":"","parameterTypes":[] }] }, +{ + "name":"io.seqera.tower.cli.commands.teams.TeamRefOptions", + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true, + "fields":[{"name":"team"}], + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.seqera.tower.cli.commands.teams.TeamRefOptions$TeamRef", + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true, + "fields":[{"name":"id"}, {"name":"name"}], + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.seqera.tower.cli.commands.teams.UpdateCmd", + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true, + "fields":[{"name":"description"}, {"name":"idpGroup"}, {"name":"newName"}, {"name":"organizationRef"}, {"name":"ref"}], + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.seqera.tower.cli.commands.teams.ViewCmd", + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true, + "fields":[{"name":"organizationRef"}, {"name":"ref"}], + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.seqera.tower.cli.commands.teams.WorkspacesCmd", + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true, + "fields":[{"name":"organizationRef"}, {"name":"paginationOptions"}, {"name":"ref"}], + "methods":[{"name":"","parameterTypes":[] }] +}, { "name":"io.seqera.tower.cli.commands.teams.members.AddCmd", "allDeclaredFields":true, @@ -2048,6 +2343,14 @@ "queryAllDeclaredMethods":true, "queryAllDeclaredConstructors":true }, +{ + "name":"io.seqera.tower.cli.responses.CredentialsValidated", + "allDeclaredFields":true, + "allDeclaredMethods":true, + "allDeclaredConstructors":true, + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true +}, { "name":"io.seqera.tower.cli.responses.InfoResponse", "allDeclaredFields":true, @@ -2059,6 +2362,24 @@ "allDeclaredFields":true, "allDeclaredMethods":true }, +{ + "name":"io.seqera.tower.cli.responses.accesstokens.AccessTokenAdded", + "allDeclaredFields":true, + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true +}, +{ + "name":"io.seqera.tower.cli.responses.accesstokens.AccessTokenDeleted", + "allDeclaredFields":true, + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true +}, +{ + "name":"io.seqera.tower.cli.responses.accesstokens.AccessTokensList", + "allDeclaredFields":true, + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true +}, { "name":"io.seqera.tower.cli.responses.actions.ActionAdd", "allDeclaredFields":true, @@ -2107,6 +2428,18 @@ "queryAllDeclaredMethods":true, "queryAllDeclaredConstructors":true }, +{ + "name":"io.seqera.tower.cli.responses.computeenvs.ComputeEnvDisabled", + "allDeclaredFields":true, + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true +}, +{ + "name":"io.seqera.tower.cli.responses.computeenvs.ComputeEnvEnabled", + "allDeclaredFields":true, + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true +}, { "name":"io.seqera.tower.cli.responses.computeenvs.ComputeEnvExport", "allDeclaredFields":true, @@ -2125,6 +2458,44 @@ "queryAllDeclaredMethods":true, "queryAllDeclaredConstructors":true }, +{ + "name":"io.seqera.tower.cli.responses.computeenvs.ComputeEnvValidated", + "allDeclaredFields":true, + "allDeclaredMethods":true, + "allDeclaredConstructors":true, + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true +}, +{ + "name":"io.seqera.tower.cli.responses.computeenvs.ComputeEnvView", + "allDeclaredFields":true, + "allDeclaredMethods":true, + "allDeclaredConstructors":true, + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true +}, +{ + "name":"io.seqera.tower.cli.responses.computeenvs.ComputeEnvsPrimaryGet", + "allDeclaredFields":true, + "allDeclaredMethods":true, + "allDeclaredConstructors":true, + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true +}, +{ + "name":"io.seqera.tower.cli.responses.computeenvs.ComputeEnvsPrimarySet", + "allDeclaredFields":true, + "allDeclaredMethods":true, + "allDeclaredConstructors":true, + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true +}, +{ + "name":"io.seqera.tower.cli.responses.data.DataLinkContentDeleted", + "allDeclaredFields":true, + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true +}, { "name":"io.seqera.tower.cli.responses.data.DataLinkContentList", "allDeclaredFields":true, @@ -2137,6 +2508,12 @@ "queryAllDeclaredMethods":true, "queryAllDeclaredConstructors":true }, +{ + "name":"io.seqera.tower.cli.responses.data.DataLinkDownloadScript", + "allDeclaredFields":true, + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true +}, { "name":"io.seqera.tower.cli.responses.data.DataLinkFileTransferResult", "allDeclaredFields":true, @@ -2178,6 +2555,14 @@ "queryAllDeclaredMethods":true, "queryAllDeclaredConstructors":true }, +{ + "name":"io.seqera.tower.cli.responses.datasets.DatasetDownload", + "allDeclaredFields":true, + "allDeclaredMethods":true, + "allDeclaredConstructors":true, + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true +}, { "name":"io.seqera.tower.cli.responses.datasets.DatasetList", "allDeclaredFields":true, @@ -2196,6 +2581,12 @@ "queryAllDeclaredMethods":true, "queryAllDeclaredConstructors":true }, +{ + "name":"io.seqera.tower.cli.responses.datasets.DatasetVersionDisabled", + "allDeclaredFields":true, + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true +}, { "name":"io.seqera.tower.cli.responses.datasets.DatasetVersionsList", "allDeclaredFields":true, @@ -2244,6 +2635,60 @@ "queryAllDeclaredMethods":true, "queryAllDeclaredConstructors":true }, +{ + "name":"io.seqera.tower.cli.responses.managedidentities.ManagedCredentialsAdded", + "allDeclaredFields":true, + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true +}, +{ + "name":"io.seqera.tower.cli.responses.managedidentities.ManagedCredentialsDeleted", + "allDeclaredFields":true, + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true +}, +{ + "name":"io.seqera.tower.cli.responses.managedidentities.ManagedCredentialsList", + "allDeclaredFields":true, + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true +}, +{ + "name":"io.seqera.tower.cli.responses.managedidentities.ManagedCredentialsUpdated", + "allDeclaredFields":true, + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true +}, +{ + "name":"io.seqera.tower.cli.responses.managedidentities.ManagedIdentitiesList", + "allDeclaredFields":true, + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true +}, +{ + "name":"io.seqera.tower.cli.responses.managedidentities.ManagedIdentityAdded", + "allDeclaredFields":true, + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true +}, +{ + "name":"io.seqera.tower.cli.responses.managedidentities.ManagedIdentityDeleted", + "allDeclaredFields":true, + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true +}, +{ + "name":"io.seqera.tower.cli.responses.managedidentities.ManagedIdentityUpdated", + "allDeclaredFields":true, + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true +}, +{ + "name":"io.seqera.tower.cli.responses.managedidentities.ManagedIdentityView", + "allDeclaredFields":true, + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true +}, { "name":"io.seqera.tower.cli.responses.members.MembersAdded", "allDeclaredFields":true, @@ -2274,6 +2719,22 @@ "allDeclaredMethods":true, "allDeclaredConstructors":true }, +{ + "name":"io.seqera.tower.cli.responses.organizations.OrganizationQuotasView", + "allDeclaredFields":true, + "allDeclaredMethods":true, + "allDeclaredConstructors":true, + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true +}, +{ + "name":"io.seqera.tower.cli.responses.organizations.OrganizationsAdded", + "allDeclaredFields":true, + "allDeclaredMethods":true, + "allDeclaredConstructors":true, + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true +}, { "name":"io.seqera.tower.cli.responses.organizations.OrganizationsDeleted", "allDeclaredFields":true, @@ -2292,6 +2753,14 @@ "queryAllDeclaredMethods":true, "queryAllDeclaredConstructors":true }, +{ + "name":"io.seqera.tower.cli.responses.organizations.OrganizationsView", + "allDeclaredFields":true, + "allDeclaredMethods":true, + "allDeclaredConstructors":true, + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true +}, { "name":"io.seqera.tower.cli.responses.participants.ParticipantAdded", "allDeclaredFields":true, @@ -2328,6 +2797,14 @@ "allDeclaredMethods":true, "allDeclaredConstructors":true }, +{ + "name":"io.seqera.tower.cli.responses.pipelines.PipelinesDeleted", + "allDeclaredFields":true, + "allDeclaredMethods":true, + "allDeclaredConstructors":true, + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true +}, { "name":"io.seqera.tower.cli.responses.pipelines.PipelinesExport", "allDeclaredFields":true, @@ -2340,6 +2817,14 @@ "allDeclaredMethods":true, "allDeclaredConstructors":true }, +{ + "name":"io.seqera.tower.cli.responses.pipelines.PipelinesSchema", + "allDeclaredFields":true, + "allDeclaredMethods":true, + "allDeclaredConstructors":true, + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true +}, { "name":"io.seqera.tower.cli.responses.pipelines.PipelinesUpdated", "allDeclaredFields":true, @@ -2400,18 +2885,68 @@ "allDeclaredMethods":true, "allDeclaredConstructors":true }, +{ + "name":"io.seqera.tower.cli.responses.runs.RunLog", + "allDeclaredFields":true, + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true +}, +{ + "name":"io.seqera.tower.cli.responses.runs.RunStarred", + "allDeclaredFields":true, + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true +}, { "name":"io.seqera.tower.cli.responses.runs.RunSubmited", "allDeclaredFields":true, "queryAllDeclaredMethods":true, "queryAllDeclaredConstructors":true }, +{ + "name":"io.seqera.tower.cli.responses.runs.RunView", + "allDeclaredFields":true, + "allDeclaredMethods":true, + "allDeclaredConstructors":true, + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true +}, +{ + "name":"io.seqera.tower.cli.responses.runs.RunViewMetrics", + "allDeclaredFields":true, + "allDeclaredMethods":true, + "allDeclaredConstructors":true, + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true +}, +{ + "name":"io.seqera.tower.cli.responses.runs.RunsDeleted", + "allDeclaredFields":true, + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true +}, +{ + "name":"io.seqera.tower.cli.responses.runs.tasks.TaskView", + "allDeclaredFields":true, + "allDeclaredMethods":true, + "allDeclaredConstructors":true, + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true +}, { "name":"io.seqera.tower.cli.responses.runs.tasks.TasksView", "allDeclaredFields":true, "queryAllDeclaredMethods":true, "queryAllDeclaredConstructors":true }, +{ + "name":"io.seqera.tower.cli.responses.runs.utils.MetricFormatMapper", + "allDeclaredFields":true, + "allDeclaredMethods":true, + "allDeclaredConstructors":true, + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true +}, { "name":"io.seqera.tower.cli.responses.secrets.SecretAdded", "allDeclaredFields":true, @@ -2442,6 +2977,12 @@ "queryAllDeclaredMethods":true, "queryAllDeclaredConstructors":true }, +{ + "name":"io.seqera.tower.cli.responses.studios.StudioCheckpointUpdated", + "allDeclaredFields":true, + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true +}, { "name":"io.seqera.tower.cli.responses.studios.StudioCheckpointsList", "allDeclaredFields":true, @@ -2454,6 +2995,12 @@ "queryAllDeclaredMethods":true, "queryAllDeclaredConstructors":true }, +{ + "name":"io.seqera.tower.cli.responses.studios.StudioLifespanExtended", + "allDeclaredFields":true, + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true +}, { "name":"io.seqera.tower.cli.responses.studios.StudioStartSubmitted", "allDeclaredFields":true, @@ -2508,6 +3055,24 @@ "allDeclaredMethods":true, "allDeclaredConstructors":true }, +{ + "name":"io.seqera.tower.cli.responses.teams.TeamUpdated", + "allDeclaredFields":true, + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true +}, +{ + "name":"io.seqera.tower.cli.responses.teams.TeamView", + "allDeclaredFields":true, + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true +}, +{ + "name":"io.seqera.tower.cli.responses.teams.TeamWorkspacesList", + "allDeclaredFields":true, + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true +}, { "name":"io.seqera.tower.cli.responses.teams.TeamsList", "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 4cf2f248c..3b3eeee57 100644 --- a/src/main/java/io/seqera/tower/cli/Tower.java +++ b/src/main/java/io/seqera/tower/cli/Tower.java @@ -21,6 +21,7 @@ package io.seqera.tower.cli; import io.seqera.tower.cli.commands.AbstractCmd; +import io.seqera.tower.cli.commands.AccessTokensCmd; import io.seqera.tower.cli.commands.ActionsCmd; import io.seqera.tower.cli.commands.CollaboratorsCmd; import io.seqera.tower.cli.commands.ComputeEnvsCmd; @@ -30,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.ManagedIdentitiesCmd; import io.seqera.tower.cli.commands.MembersCmd; import io.seqera.tower.cli.commands.OrganizationsCmd; import io.seqera.tower.cli.commands.ParticipantsCmd; @@ -56,6 +58,7 @@ name = "tw", description = "Seqera Platform CLI", subcommands = { + AccessTokensCmd.class, ActionsCmd.class, CollaboratorsCmd.class, ComputeEnvsCmd.class, @@ -67,6 +70,7 @@ InfoCmd.class, LabelsCmd.class, LaunchCmd.class, + ManagedIdentitiesCmd.class, MembersCmd.class, OrganizationsCmd.class, ParticipantsCmd.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 20d15b2d3..1fccc926c 100644 --- a/src/main/java/io/seqera/tower/cli/commands/AbstractApiCmd.java +++ b/src/main/java/io/seqera/tower/cli/commands/AbstractApiCmd.java @@ -18,6 +18,8 @@ import io.seqera.tower.ApiClient; import io.seqera.tower.ApiException; +import io.seqera.tower.ApiResponse; +import io.seqera.tower.Pair; import io.seqera.tower.api.ActionsApi; import io.seqera.tower.api.AvatarsApi; import io.seqera.tower.api.ComputeEnvsApi; @@ -25,6 +27,7 @@ import io.seqera.tower.api.DataLinksApi; import io.seqera.tower.api.DatasetsApi; import io.seqera.tower.api.Ga4ghApi; +import io.seqera.tower.api.IdentitiesApi; import io.seqera.tower.api.LabelsApi; import io.seqera.tower.api.LaunchApi; import io.seqera.tower.api.OrgsApi; @@ -77,6 +80,7 @@ import picocli.CommandLine; import javax.ws.rs.client.Entity; +import javax.ws.rs.core.GenericType; import javax.ws.rs.core.MediaType; import java.io.IOException; import java.net.URLConnection; @@ -98,6 +102,14 @@ public abstract class AbstractApiCmd extends AbstractCmd { public static final String USER_WORKSPACE_NAME = "user"; public static final String WORKSPACE_REF_SEPARATOR = "/"; + // Some endpoints take a body on DELETE (e.g. data-link content). The SDK suppresses Jersey's HTTP compliance + // check, so the request is sent, but Jersey still logs a warning to stderr. Strong reference: JUL holds loggers weakly. + private static final Logger JERSEY_INVOCATION_LOGGER = Logger.getLogger("org.glassfish.jersey.client.JerseyInvocation"); + + static { + JERSEY_INVOCATION_LOGGER.setLevel(java.util.logging.Level.SEVERE); + } + // No attributes constants public static final List NO_CE_ATTRIBUTES = Collections.EMPTY_LIST; public static final List NO_WORKFLOW_ATTRIBUTES = Collections.EMPTY_LIST; @@ -114,6 +126,7 @@ public abstract class AbstractApiCmd extends AbstractCmd { private DataLinksApi dataLinksApi; private DatasetsApi datasetsApi; private Ga4ghApi ga4ghApi; + private IdentitiesApi identitiesApi; private LabelsApi labelsApi; private LaunchApi launchApi; private OrgsApi orgsApi; @@ -216,6 +229,10 @@ protected Ga4ghApi ga4ghApi() throws ApiException { return ga4ghApi == null ? new Ga4ghApi(apiClient()) : ga4ghApi; } + protected IdentitiesApi identitiesApi() throws ApiException { + return identitiesApi == null ? new IdentitiesApi(apiClient()) : identitiesApi; + } + protected LabelsApi labelsApi() throws ApiException { return labelsApi == null ? new LabelsApi(apiClient()) : labelsApi; } @@ -304,6 +321,17 @@ public ClientConfig getDefaultClientConfig() { return config; } + @Override + public ApiResponse invokeAPI(String operation, String path, String method, List queryParams, Object body, Map headerParams, Map cookieParams, Map formParams, String accept, String contentType, String[] authNames, GenericType returnType, boolean isBodyNullable) throws ApiException { + // Some operations are missing their security requirement in the OpenAPI spec (e.g. the workflow + // star endpoints), so the generated SDK passes no auth names and skips authentication entirely, + // sending the request unauthenticated. Fall back to the only scheme this client uses. + if (authNames == null || authNames.length == 0) { + authNames = new String[]{"BearerAuth"}; + } + return super.invokeAPI(operation, path, method, queryParams, body, headerParams, cookieParams, formParams, accept, contentType, authNames, returnType, isBodyNullable); + } + @Override protected void applyDebugSetting(ClientConfig clientConfig) { if (app().verbose) { diff --git a/src/main/java/io/seqera/tower/cli/commands/AccessTokensCmd.java b/src/main/java/io/seqera/tower/cli/commands/AccessTokensCmd.java new file mode 100644 index 000000000..5c048e8b9 --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/commands/AccessTokensCmd.java @@ -0,0 +1,35 @@ +/* + * 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.accesstokens.AddCmd; +import io.seqera.tower.cli.commands.accesstokens.DeleteCmd; +import io.seqera.tower.cli.commands.accesstokens.ListCmd; +import picocli.CommandLine.Command; + +@Command( + name = "access-tokens", + description = "Manage your personal access tokens", + subcommands = { + ListCmd.class, + AddCmd.class, + DeleteCmd.class, + } +) +public class AccessTokensCmd extends AbstractRootCmd { +} diff --git a/src/main/java/io/seqera/tower/cli/commands/ComputeEnvsCmd.java b/src/main/java/io/seqera/tower/cli/commands/ComputeEnvsCmd.java index 53cd2ce62..e775dafd3 100644 --- a/src/main/java/io/seqera/tower/cli/commands/ComputeEnvsCmd.java +++ b/src/main/java/io/seqera/tower/cli/commands/ComputeEnvsCmd.java @@ -18,6 +18,8 @@ import io.seqera.tower.cli.commands.computeenvs.AddCmd; import io.seqera.tower.cli.commands.computeenvs.DeleteCmd; +import io.seqera.tower.cli.commands.computeenvs.DisableCmd; +import io.seqera.tower.cli.commands.computeenvs.EnableCmd; import io.seqera.tower.cli.commands.computeenvs.ExportCmd; import io.seqera.tower.cli.commands.computeenvs.ImportCmd; import io.seqera.tower.cli.commands.computeenvs.ListCmd; @@ -41,6 +43,8 @@ ImportCmd.class, PrimaryCmd.class, ValidateCmd.class, + EnableCmd.class, + DisableCmd.class, } ) public class ComputeEnvsCmd extends AbstractRootCmd { diff --git a/src/main/java/io/seqera/tower/cli/commands/DataLinksCmd.java b/src/main/java/io/seqera/tower/cli/commands/DataLinksCmd.java index d802cd21a..0e56fbe25 100644 --- a/src/main/java/io/seqera/tower/cli/commands/DataLinksCmd.java +++ b/src/main/java/io/seqera/tower/cli/commands/DataLinksCmd.java @@ -18,6 +18,8 @@ import io.seqera.tower.cli.commands.data.links.AddCmd; import io.seqera.tower.cli.commands.data.links.DeleteCmd; +import io.seqera.tower.cli.commands.data.links.DeleteContentCmd; +import io.seqera.tower.cli.commands.data.links.DownloadScriptCmd; import io.seqera.tower.cli.commands.data.links.DownloadCmd; import io.seqera.tower.cli.commands.data.links.ListCmd; import io.seqera.tower.cli.commands.data.links.UpdateCmd; @@ -32,9 +34,11 @@ ListCmd.class, AddCmd.class, DeleteCmd.class, + DeleteContentCmd.class, UpdateCmd.class, BrowseCmd.class, DownloadCmd.class, + DownloadScriptCmd.class, UploadCmd.class } ) diff --git a/src/main/java/io/seqera/tower/cli/commands/DatasetsCmd.java b/src/main/java/io/seqera/tower/cli/commands/DatasetsCmd.java index fbb568f46..fd931ee45 100644 --- a/src/main/java/io/seqera/tower/cli/commands/DatasetsCmd.java +++ b/src/main/java/io/seqera/tower/cli/commands/DatasetsCmd.java @@ -19,6 +19,7 @@ import io.seqera.tower.cli.commands.datasets.AddCmd; import io.seqera.tower.cli.commands.datasets.DeleteCmd; +import io.seqera.tower.cli.commands.datasets.DisableVersionCmd; import io.seqera.tower.cli.commands.datasets.DownloadCmd; import io.seqera.tower.cli.commands.datasets.HideCmd; import io.seqera.tower.cli.commands.datasets.LabelsCmd; @@ -35,6 +36,7 @@ subcommands = { AddCmd.class, DeleteCmd.class, + DisableVersionCmd.class, DownloadCmd.class, HideCmd.class, LabelsCmd.class, diff --git a/src/main/java/io/seqera/tower/cli/commands/LaunchCmd.java b/src/main/java/io/seqera/tower/cli/commands/LaunchCmd.java index 4dbfa1a3f..6b3252b4a 100644 --- a/src/main/java/io/seqera/tower/cli/commands/LaunchCmd.java +++ b/src/main/java/io/seqera/tower/cli/commands/LaunchCmd.java @@ -156,6 +156,7 @@ private WorkflowLaunchRequest updateLaunchRequest(WorkflowLaunchRequest base) th .userSecrets(coalesce(removeEmptyValues(adv().userSecrets), base.getUserSecrets())) .workspaceSecrets(coalesce(removeEmptyValues(adv().workspaceSecrets), base.getWorkspaceSecrets())) .configText(coalesce(readStringOrStdin(adv().config), base.getConfigText())) + .towerConfig(coalesce(readStringOrStdin(adv().towerConfig), base.getTowerConfig())) .paramsText(coalesce(readStringOrStdin(paramsFile), base.getParamsText())) .preRunScript(coalesce(readStringOrStdin(adv().preRunScript), base.getPreRunScript())) .postRunScript(coalesce(readStringOrStdin(adv().postRunScript), base.getPostRunScript())) @@ -283,6 +284,9 @@ public static class AdvancedOptions { @Option(names = {"--config"}, description = "Nextflow configuration as text (overrides config files). Provide the path to a file containing the content. Use '-' to read from stdin.") public Path config; + @Option(names = {"--tower-config"}, description = "Seqera Platform configuration in tower.yml format. Overrides the pipeline's tower.yml for this run. Provide the path to a file containing the content. Use '-' to read from stdin.") + public Path towerConfig; + @Option(names = {"--pre-run"}, description = "Add a script that executes in the nf-launch script prior to invoking Nextflow processes. See: https://docs.seqera.io/platform-cloud/launch/advanced#pre-and-post-run-scripts. Provide the path to a file containing the content. Use '-' to read from stdin.") public Path preRunScript; diff --git a/src/main/java/io/seqera/tower/cli/commands/ManagedIdentitiesCmd.java b/src/main/java/io/seqera/tower/cli/commands/ManagedIdentitiesCmd.java new file mode 100644 index 000000000..61dbeca0c --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/commands/ManagedIdentitiesCmd.java @@ -0,0 +1,41 @@ +/* + * 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.managedidentities.AddCmd; +import io.seqera.tower.cli.commands.managedidentities.CredentialsCmd; +import io.seqera.tower.cli.commands.managedidentities.DeleteCmd; +import io.seqera.tower.cli.commands.managedidentities.ListCmd; +import io.seqera.tower.cli.commands.managedidentities.UpdateCmd; +import io.seqera.tower.cli.commands.managedidentities.ViewCmd; +import picocli.CommandLine.Command; + +@Command( + name = "managed-identities", + description = "Manage organization managed identities for HPC clusters", + subcommands = { + ListCmd.class, + AddCmd.class, + ViewCmd.class, + UpdateCmd.class, + DeleteCmd.class, + CredentialsCmd.class, + } +) +public class ManagedIdentitiesCmd extends AbstractRootCmd { +} diff --git a/src/main/java/io/seqera/tower/cli/commands/OrganizationsCmd.java b/src/main/java/io/seqera/tower/cli/commands/OrganizationsCmd.java index ed8a99a6e..543a1d04a 100644 --- a/src/main/java/io/seqera/tower/cli/commands/OrganizationsCmd.java +++ b/src/main/java/io/seqera/tower/cli/commands/OrganizationsCmd.java @@ -19,6 +19,7 @@ import io.seqera.tower.cli.commands.organizations.AddCmd; import io.seqera.tower.cli.commands.organizations.DeleteCmd; import io.seqera.tower.cli.commands.organizations.ListCmd; +import io.seqera.tower.cli.commands.organizations.QuotasCmd; import io.seqera.tower.cli.commands.organizations.UpdateCmd; import io.seqera.tower.cli.commands.organizations.ViewCmd; import picocli.CommandLine; @@ -32,6 +33,7 @@ AddCmd.class, UpdateCmd.class, ViewCmd.class, + QuotasCmd.class, } ) public class OrganizationsCmd extends AbstractRootCmd { diff --git a/src/main/java/io/seqera/tower/cli/commands/PipelinesCmd.java b/src/main/java/io/seqera/tower/cli/commands/PipelinesCmd.java index f4c50ce55..fcdc27ec9 100644 --- a/src/main/java/io/seqera/tower/cli/commands/PipelinesCmd.java +++ b/src/main/java/io/seqera/tower/cli/commands/PipelinesCmd.java @@ -22,6 +22,7 @@ import io.seqera.tower.cli.commands.pipelines.ImportCmd; import io.seqera.tower.cli.commands.pipelines.labels.LabelsCmd; import io.seqera.tower.cli.commands.pipelines.ListCmd; +import io.seqera.tower.cli.commands.pipelines.SchemaCmd; import io.seqera.tower.cli.commands.pipelines.UpdateCmd; import io.seqera.tower.cli.commands.pipelines.ViewCmd; import io.seqera.tower.cli.commands.pipelines.versions.VersionsCmd; @@ -40,6 +41,7 @@ ExportCmd.class, ImportCmd.class, LabelsCmd.class, + SchemaCmd.class, VersionsCmd.class } ) diff --git a/src/main/java/io/seqera/tower/cli/commands/RunsCmd.java b/src/main/java/io/seqera/tower/cli/commands/RunsCmd.java index ade2bcc87..0725e6287 100644 --- a/src/main/java/io/seqera/tower/cli/commands/RunsCmd.java +++ b/src/main/java/io/seqera/tower/cli/commands/RunsCmd.java @@ -22,6 +22,8 @@ import io.seqera.tower.cli.commands.runs.DumpCmd; import io.seqera.tower.cli.commands.runs.ListCmd; import io.seqera.tower.cli.commands.runs.RelaunchCmd; +import io.seqera.tower.cli.commands.runs.StarCmd; +import io.seqera.tower.cli.commands.runs.UnstarCmd; import io.seqera.tower.cli.commands.runs.ViewCmd; import picocli.CommandLine; @@ -35,7 +37,9 @@ CancelCmd.class, LabelsCmd.class, DeleteCmd.class, - DumpCmd.class + DumpCmd.class, + StarCmd.class, + UnstarCmd.class } ) public class RunsCmd 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 fce6cb9d7..345d7d48f 100644 --- a/src/main/java/io/seqera/tower/cli/commands/StudiosCmd.java +++ b/src/main/java/io/seqera/tower/cli/commands/StudiosCmd.java @@ -19,6 +19,8 @@ import io.seqera.tower.cli.commands.studios.AddCmd; 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.RenameCheckpointCmd; import io.seqera.tower.cli.commands.studios.ListCmd; import io.seqera.tower.cli.commands.studios.AddAsNewCmd; import io.seqera.tower.cli.commands.studios.StartCmd; @@ -38,10 +40,12 @@ AddCmd.class, TemplatesCmd.class, CheckpointsCmd.class, + RenameCheckpointCmd.class, AddAsNewCmd.class, StopCmd.class, UpdateCmd.class, DeleteCmd.class, + ExtendCmd.class, } ) public class StudiosCmd extends AbstractRootCmd { diff --git a/src/main/java/io/seqera/tower/cli/commands/TeamsCmd.java b/src/main/java/io/seqera/tower/cli/commands/TeamsCmd.java index 9424be4e4..3b2eb7f2f 100644 --- a/src/main/java/io/seqera/tower/cli/commands/TeamsCmd.java +++ b/src/main/java/io/seqera/tower/cli/commands/TeamsCmd.java @@ -21,6 +21,9 @@ import io.seqera.tower.cli.commands.teams.DeleteCmd; import io.seqera.tower.cli.commands.teams.ListCmd; import io.seqera.tower.cli.commands.teams.MembersCmd; +import io.seqera.tower.cli.commands.teams.UpdateCmd; +import io.seqera.tower.cli.commands.teams.ViewCmd; +import io.seqera.tower.cli.commands.teams.WorkspacesCmd; import picocli.CommandLine; @CommandLine.Command( @@ -29,8 +32,11 @@ subcommands = { ListCmd.class, AddCmd.class, + ViewCmd.class, + UpdateCmd.class, DeleteCmd.class, MembersCmd.class, + WorkspacesCmd.class, } ) public class TeamsCmd extends AbstractRootCmd { diff --git a/src/main/java/io/seqera/tower/cli/commands/accesstokens/AddCmd.java b/src/main/java/io/seqera/tower/cli/commands/accesstokens/AddCmd.java new file mode 100644 index 000000000..22fdf297c --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/commands/accesstokens/AddCmd.java @@ -0,0 +1,43 @@ +/* + * 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.accesstokens; + +import io.seqera.tower.ApiException; +import io.seqera.tower.cli.commands.AbstractApiCmd; +import io.seqera.tower.cli.responses.Response; +import io.seqera.tower.cli.responses.accesstokens.AccessTokenAdded; +import io.seqera.tower.model.CreateAccessTokenRequest; +import io.seqera.tower.model.CreateAccessTokenResponse; +import picocli.CommandLine.Command; +import picocli.CommandLine.Option; + +@Command( + name = "add", + description = "Add a personal access token. The token value is shown only once." +) +public class AddCmd extends AbstractApiCmd { + + @Option(names = {"-n", "--name"}, description = "Token name. A label to remember what the token is for. Must be unique.", required = true) + public String name; + + @Override + protected Response exec() throws ApiException { + CreateAccessTokenResponse response = tokensApi().createToken(new CreateAccessTokenRequest().name(name)); + return new AccessTokenAdded(response.getToken().getId(), response.getToken().getName(), response.getAccessKey()); + } +} diff --git a/src/main/java/io/seqera/tower/cli/commands/accesstokens/DeleteCmd.java b/src/main/java/io/seqera/tower/cli/commands/accesstokens/DeleteCmd.java new file mode 100644 index 000000000..f34a662a7 --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/commands/accesstokens/DeleteCmd.java @@ -0,0 +1,57 @@ +/* + * 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.accesstokens; + +import io.seqera.tower.ApiException; +import io.seqera.tower.cli.commands.AbstractApiCmd; +import io.seqera.tower.cli.exceptions.AccessTokenNotFoundException; +import io.seqera.tower.cli.responses.Response; +import io.seqera.tower.cli.responses.accesstokens.AccessTokenDeleted; +import io.seqera.tower.model.AccessToken; +import picocli.CommandLine.ArgGroup; +import picocli.CommandLine.Command; +import picocli.CommandLine.Option; + +@Command( + name = "delete", + description = "Delete a personal access token. Clients using it can no longer authenticate." +) +public class DeleteCmd extends AbstractApiCmd { + + @ArgGroup(multiplicity = "1") + public TokenRef ref; + + public static class TokenRef { + + @Option(names = {"-i", "--id"}, description = "Token numeric identifier") + public Long id; + + @Option(names = {"-n", "--name"}, description = "Token name") + public String name; + } + + @Override + protected Response exec() throws ApiException { + AccessToken token = tokensApi().tokenList().getTokens().stream() + .filter(it -> ref.id != null ? ref.id.equals(it.getId()) : ref.name.equals(it.getName())) + .findFirst() + .orElseThrow(() -> new AccessTokenNotFoundException(ref.id != null ? ref.id.toString() : ref.name)); + tokensApi().deleteToken(token.getId()); + return new AccessTokenDeleted(token.getId(), token.getName()); + } +} diff --git a/src/main/java/io/seqera/tower/cli/commands/accesstokens/ListCmd.java b/src/main/java/io/seqera/tower/cli/commands/accesstokens/ListCmd.java new file mode 100644 index 000000000..604fb7326 --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/commands/accesstokens/ListCmd.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.accesstokens; + +import io.seqera.tower.ApiException; +import io.seqera.tower.cli.commands.AbstractApiCmd; +import io.seqera.tower.cli.responses.Response; +import io.seqera.tower.cli.responses.accesstokens.AccessTokensList; +import io.seqera.tower.model.AccessToken; +import picocli.CommandLine.Command; + +import java.util.List; + +@Command( + name = "list", + description = "List your personal access tokens" +) +public class ListCmd extends AbstractApiCmd { + + @Override + protected Response exec() throws ApiException { + List tokens = tokensApi().tokenList().getTokens().stream() + // Drop the deprecated basicAuth field: it is a usable credential for legacy tokens + .map(token -> new AccessToken() + .id(token.getId()) + .name(token.getName()) + .dateCreated(token.getDateCreated()) + .lastUsed(token.getLastUsed())) + .toList(); + return new AccessTokensList(tokens); + } +} diff --git a/src/main/java/io/seqera/tower/cli/commands/actions/UpdateCmd.java b/src/main/java/io/seqera/tower/cli/commands/actions/UpdateCmd.java index 0241bd413..066011a6f 100644 --- a/src/main/java/io/seqera/tower/cli/commands/actions/UpdateCmd.java +++ b/src/main/java/io/seqera/tower/cli/commands/actions/UpdateCmd.java @@ -92,6 +92,7 @@ protected Response exec() throws ApiException, IOException { // Advanced options .configText(coalesce(FilesHelper.readString(opts.config), action.getLaunch().getConfigText())) + .towerConfig(coalesce(FilesHelper.readString(opts.towerConfig), action.getLaunch().getTowerConfig())) .preRunScript(preRunScriptValue) .postRunScript(postRunScriptValue) .pullLatest(coalesce(opts.pullLatest, action.getLaunch().getPullLatest())) diff --git a/src/main/java/io/seqera/tower/cli/commands/actions/add/AbstractAddCmd.java b/src/main/java/io/seqera/tower/cli/commands/actions/add/AbstractAddCmd.java index b21b8239e..553c83155 100644 --- a/src/main/java/io/seqera/tower/cli/commands/actions/add/AbstractAddCmd.java +++ b/src/main/java/io/seqera/tower/cli/commands/actions/add/AbstractAddCmd.java @@ -81,6 +81,7 @@ protected Response exec() throws ApiException, IOException { // Advanced options .configText(FilesHelper.readString(opts.config)) + .towerConfig(FilesHelper.readString(opts.towerConfig)) .preRunScript(preRunScriptValue) .postRunScript(postRunScriptValue) .pullLatest(opts.pullLatest) diff --git a/src/main/java/io/seqera/tower/cli/commands/computeenvs/DisableCmd.java b/src/main/java/io/seqera/tower/cli/commands/computeenvs/DisableCmd.java new file mode 100644 index 000000000..12c5ebf5c --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/commands/computeenvs/DisableCmd.java @@ -0,0 +1,48 @@ +/* + * 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.computeenvs; + +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.computeenvs.ComputeEnvDisabled; +import picocli.CommandLine; +import picocli.CommandLine.Command; + +@Command( + name = "disable", + description = "Disable a compute environment so it cannot be used to launch workflows. A primary compute environment is unset as primary." +) +public class DisableCmd extends AbstractComputeEnvCmd { + + @CommandLine.Mixin + public ComputeEnvRefOptions computeEnvRefOptions; + + @CommandLine.Mixin + public WorkspaceOptionalOptions workspace; + + @Override + protected Response exec() throws ApiException { + Long wspId = workspaceId(workspace.workspace); + String id = computeEnvRefOptions.computeEnv.computeEnvId != null + ? computeEnvRefOptions.computeEnv.computeEnvId + : computeEnvByName(wspId, computeEnvRefOptions.computeEnv.computeEnvName).getId(); + + computeEnvsApi().disableComputeEnv(id, wspId, null); + return new ComputeEnvDisabled(id, workspaceRef(wspId)); + } +} diff --git a/src/main/java/io/seqera/tower/cli/commands/computeenvs/EnableCmd.java b/src/main/java/io/seqera/tower/cli/commands/computeenvs/EnableCmd.java new file mode 100644 index 000000000..ba08bcb13 --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/commands/computeenvs/EnableCmd.java @@ -0,0 +1,48 @@ +/* + * 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.computeenvs; + +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.computeenvs.ComputeEnvEnabled; +import picocli.CommandLine; +import picocli.CommandLine.Command; + +@Command( + name = "enable", + description = "Enable a disabled compute environment so it can be used to launch workflows." +) +public class EnableCmd extends AbstractComputeEnvCmd { + + @CommandLine.Mixin + public ComputeEnvRefOptions computeEnvRefOptions; + + @CommandLine.Mixin + public WorkspaceOptionalOptions workspace; + + @Override + protected Response exec() throws ApiException { + Long wspId = workspaceId(workspace.workspace); + String id = computeEnvRefOptions.computeEnv.computeEnvId != null + ? computeEnvRefOptions.computeEnv.computeEnvId + : computeEnvByName(wspId, computeEnvRefOptions.computeEnv.computeEnvName).getId(); + + computeEnvsApi().enableComputeEnv(id, wspId, null); + return new ComputeEnvEnabled(id, workspaceRef(wspId)); + } +} diff --git a/src/main/java/io/seqera/tower/cli/commands/computeenvs/platforms/AbstractPlatform.java b/src/main/java/io/seqera/tower/cli/commands/computeenvs/platforms/AbstractPlatform.java index 2843cf29f..2931284cf 100644 --- a/src/main/java/io/seqera/tower/cli/commands/computeenvs/platforms/AbstractPlatform.java +++ b/src/main/java/io/seqera/tower/cli/commands/computeenvs/platforms/AbstractPlatform.java @@ -18,6 +18,7 @@ import io.seqera.tower.ApiException; import io.seqera.tower.api.CredentialsApi; +import io.seqera.tower.cli.exceptions.TowerException; import io.seqera.tower.cli.utils.FilesHelper; import io.seqera.tower.model.ComputeConfig; import io.seqera.tower.model.ComputeEnvComputeConfig.PlatformEnum; @@ -93,6 +94,13 @@ protected List environmentVariables() { return vars; } + protected static Boolean propagateHeadJobOptions(Boolean propagate, String headJobOptions) throws TowerException { + if (Boolean.TRUE.equals(propagate) && headJobOptions == null) { + throw new TowerException("'--propagate-head-job-options' requires '--head-job-options'"); + } + return propagate; + } + public PlatformEnum type() { return type; } diff --git a/src/main/java/io/seqera/tower/cli/commands/computeenvs/platforms/AltairPlatform.java b/src/main/java/io/seqera/tower/cli/commands/computeenvs/platforms/AltairPlatform.java index 17a2d4470..4fd6dbc64 100644 --- a/src/main/java/io/seqera/tower/cli/commands/computeenvs/platforms/AltairPlatform.java +++ b/src/main/java/io/seqera/tower/cli/commands/computeenvs/platforms/AltairPlatform.java @@ -69,7 +69,8 @@ public AltairPbsComputeConfig computeConfig() throws ApiException, IOException { // Advanced .maxQueueSize(adv().maxQueueSize) - .headJobOptions(adv().headJobOptions); + .headJobOptions(adv().headJobOptions) + .propagateHeadJobOptions(propagateHeadJobOptions(adv().propagateHeadJobOptions, adv().headJobOptions)); // Common config.workDir(workDir) @@ -94,5 +95,8 @@ public static class AdvancedOptions { @Option(names = {"--head-job-options"}, description = "Additional submit options for the Nextflow head job. Appended to the submit command for the main orchestration process.") public String headJobOptions; + + @Option(names = {"--propagate-head-job-options"}, description = "Apply the head job submit options to the compute jobs as well. Requires --head-job-options.") + public Boolean propagateHeadJobOptions; } } diff --git a/src/main/java/io/seqera/tower/cli/commands/computeenvs/platforms/AwsBatchForgePlatform.java b/src/main/java/io/seqera/tower/cli/commands/computeenvs/platforms/AwsBatchForgePlatform.java index 514b0cb40..a83b58c9a 100644 --- a/src/main/java/io/seqera/tower/cli/commands/computeenvs/platforms/AwsBatchForgePlatform.java +++ b/src/main/java/io/seqera/tower/cli/commands/computeenvs/platforms/AwsBatchForgePlatform.java @@ -127,7 +127,8 @@ public AwsBatchConfig computeConfig() throws ApiException, IOException { .headJobCpus(adv().headJobCpus) .headJobMemoryMb(adv().headJobMemoryMb) .headJobRole(adv().headJobRole) - .secretsKmsKeyId(adv().secretsKmsKeyId); + .secretsKmsKeyId(adv().secretsKmsKeyId) + .logGroup(adv().logGroup); // Common config.workDir(workDir) @@ -152,6 +153,8 @@ private ForgeConfig buildForge() throws TowerException, IOException { throw new TowerException("Fusion v1 is deprecated, please use '--fusion-v2' instead"); } + validateDragen(); + ForgeConfig forge = new ForgeConfig() .type(provisioningModel) .maxCpus(maxCpus) @@ -171,7 +174,10 @@ private ForgeConfig buildForge() throws TowerException, IOException { .ebsBootSize(adv().bootDiskSizeGb) .bidPercentage(adv().bidPercentage) .fargateHeadEnabled(fargate) - .ecsConfig(FilesHelper.readString(ecsConfig)); + .ecsConfig(FilesHelper.readString(ecsConfig)) + .dragenEnabled(adv().dragen) + .dragenAmiId(adv().dragenAmiId) + .dragenInstanceType(adv().dragenInstanceType); if (efs != null) { @@ -191,6 +197,16 @@ private ForgeConfig buildForge() throws TowerException, IOException { return forge; } + private void validateDragen() throws TowerException { + boolean dragenSettings = adv().dragenAmiId != null || adv().dragenInstanceType != null; + if (!adv().dragen && dragenSettings) { + throw new TowerException("DRAGEN AMI ID and instance type require '--dragen'"); + } + if (adv().dragen && (adv().dragenAmiId == null || adv().dragenInstanceType == null)) { + throw new TowerException("DRAGEN requires '--dragen-ami-id' and '--dragen-instance-type'"); + } + } + private AdvancedOptions adv() { if (adv == null) { return new AdvancedOptions(); @@ -278,6 +294,18 @@ public static class AdvancedOptions { @Option(names = {"--secrets-kms-key"}, description = "Customer-managed KMS key used to encrypt the temporary Secrets Manager secrets created for runs that use pipeline secrets. Accepts a key ARN or a key id. When omitted, the AWS-managed default Secrets Manager key is used.") public String secretsKmsKeyId; + @Option(names = {"--log-group"}, description = "CloudWatch log group for the logs of pipeline executions. Must already exist. If absent, Platform defaults to /aws/batch/job.") + public String logGroup; + + @Option(names = {"--dragen"}, description = "Enable Illumina DRAGEN instances. Requires --dragen-ami-id and --dragen-instance-type. Not compatible with EFS, FSx or Fargate.") + public boolean dragen; + + @Option(names = {"--dragen-ami-id"}, description = "AMI ID for DRAGEN instances.") + public String dragenAmiId; + + @Option(names = {"--dragen-instance-type"}, description = "EC2 instance type for DRAGEN instances (e.g., f2.6xlarge, f2.12xlarge, f2.48xlarge).") + public String dragenInstanceType; + } } diff --git a/src/main/java/io/seqera/tower/cli/commands/computeenvs/platforms/AwsBatchManualPlatform.java b/src/main/java/io/seqera/tower/cli/commands/computeenvs/platforms/AwsBatchManualPlatform.java index 7dfd73f2a..2f8d44080 100644 --- a/src/main/java/io/seqera/tower/cli/commands/computeenvs/platforms/AwsBatchManualPlatform.java +++ b/src/main/java/io/seqera/tower/cli/commands/computeenvs/platforms/AwsBatchManualPlatform.java @@ -91,7 +91,8 @@ public AwsBatchConfig computeConfig() throws IOException, ApiException { .headJobCpus(adv().headJobCpus) .headJobMemoryMb(adv().headJobMemoryMb) .headJobRole(adv().headJobRole) - .secretsKmsKeyId(adv().secretsKmsKeyId); + .secretsKmsKeyId(adv().secretsKmsKeyId) + .logGroup(adv().logGroup); // Common config.workDir(workDir) @@ -137,5 +138,8 @@ public static class AdvancedOptions { @Option(names = {"--secrets-kms-key"}, description = "Customer-managed KMS key used to encrypt the temporary Secrets Manager secrets created for runs that use pipeline secrets. Accepts a key ARN or a key id. When omitted, the AWS-managed default Secrets Manager key is used.") public String secretsKmsKeyId; + + @Option(names = {"--log-group"}, description = "CloudWatch log group for the logs of pipeline executions. Must already exist. If absent, Platform defaults to /aws/batch/job.") + public String logGroup; } } diff --git a/src/main/java/io/seqera/tower/cli/commands/computeenvs/platforms/AwsCloudPlatform.java b/src/main/java/io/seqera/tower/cli/commands/computeenvs/platforms/AwsCloudPlatform.java index 86ca4a8bb..a67a8ae3d 100644 --- a/src/main/java/io/seqera/tower/cli/commands/computeenvs/platforms/AwsCloudPlatform.java +++ b/src/main/java/io/seqera/tower/cli/commands/computeenvs/platforms/AwsCloudPlatform.java @@ -103,7 +103,8 @@ public AwsCloudConfig computeConfig() throws ApiException, IOException { .vpcId(adv.vpcId) .subnetId(adv.subnetId) .subnetIds(adv.subnetIds) - .securityGroups(adv.securityGroups); + .securityGroups(adv.securityGroups) + .logGroup(adv.logGroup); } // Common @@ -146,6 +147,9 @@ public static class AdvancedOptions { @Option(names = {"--ec2-key-pair"}, description = "EC2 key pair name for SSH access to running instances. The key pair must already exist in the specified region.") public String ec2KeyPair; + @Option(names = {"--log-group"}, description = "CloudWatch Logs group where the compute environment logs are written. If absent, Platform uses /seqera/platform.") + public String logGroup; + @Option(names = {"--image-id"}, description = "AMI ID for launching EC2 instances. If omitted, Seqera-maintained default AMI is used. Use Seqera AMIs for best performance.") public String imageId; diff --git a/src/main/java/io/seqera/tower/cli/commands/computeenvs/platforms/GoogleCloudPlatform.java b/src/main/java/io/seqera/tower/cli/commands/computeenvs/platforms/GoogleCloudPlatform.java index 43879b8a7..eeea3031f 100644 --- a/src/main/java/io/seqera/tower/cli/commands/computeenvs/platforms/GoogleCloudPlatform.java +++ b/src/main/java/io/seqera/tower/cli/commands/computeenvs/platforms/GoogleCloudPlatform.java @@ -100,7 +100,8 @@ public GoogleCloudConfig computeConfig() throws ApiException, IOException { .network(adv.network) .subnetworks(adv.subnetworks) .networkTags(adv.networkTags) - .usePrivateAddress(adv.usePrivateAddress); + .usePrivateAddress(adv.usePrivateAddress) + .projectId(adv.projectId); } // Common @@ -168,6 +169,9 @@ public static class AdvancedOptions { @Option(names = {"--network"}, description = "Google Cloud VPC network name or URI. Required when using subnetworks or network tags. When omitted, the project's 'default' network is used.") public String network; + @Option(names = {"--project-id"}, description = "Google Cloud project ID. Set as google.project in the Nextflow configuration and used to qualify short --network and --subnetworks names.") + public String projectId; + @Option(names = {"--subnetworks"}, split = ",", paramLabel = "", description = "Google Cloud VPC subnetworks for instance placement. Comma-separated list of names or URIs in the same region as the compute environment; the first is used for basic placement while Intelligent Compute may use all of them. Requires --network.") public List subnetworks; diff --git a/src/main/java/io/seqera/tower/cli/commands/computeenvs/platforms/LsfPlatform.java b/src/main/java/io/seqera/tower/cli/commands/computeenvs/platforms/LsfPlatform.java index fb0b5478c..a066099eb 100644 --- a/src/main/java/io/seqera/tower/cli/commands/computeenvs/platforms/LsfPlatform.java +++ b/src/main/java/io/seqera/tower/cli/commands/computeenvs/platforms/LsfPlatform.java @@ -73,7 +73,8 @@ public LsfComputeConfig computeConfig() throws ApiException, IOException { .perJobMemLimit(adv().perJobMemLimit) .perTaskReserve(adv().perTaskReserve) .maxQueueSize(adv().maxQueueSize) - .headJobOptions(adv().headJobOptions); + .headJobOptions(adv().headJobOptions) + .propagateHeadJobOptions(propagateHeadJobOptions(adv().propagateHeadJobOptions, adv().headJobOptions)); // Common config.workDir(workDir) @@ -99,6 +100,9 @@ public static class AdvancedOptions { @Option(names = {"--head-job-options"}, description = "Additional submit options for the Nextflow head job. Appended to the bsub command for the main orchestration process.") public String headJobOptions; + @Option(names = {"--propagate-head-job-options"}, description = "Apply the head job submit options to the compute jobs as well. Requires --head-job-options.") + public Boolean propagateHeadJobOptions; + @Option(names = {"--unit-for-limits"}, description = "Memory limit unit for LSF cluster. Must match LSF_UNIT_FOR_LIMITS in lsf.conf configuration file.") public String unitForLimits; diff --git a/src/main/java/io/seqera/tower/cli/commands/computeenvs/platforms/MoabPlatform.java b/src/main/java/io/seqera/tower/cli/commands/computeenvs/platforms/MoabPlatform.java index 881d9bf95..5c2e5ce0e 100644 --- a/src/main/java/io/seqera/tower/cli/commands/computeenvs/platforms/MoabPlatform.java +++ b/src/main/java/io/seqera/tower/cli/commands/computeenvs/platforms/MoabPlatform.java @@ -69,7 +69,8 @@ public MoabComputeConfig computeConfig() throws ApiException, IOException { // Advanced .maxQueueSize(adv().maxQueueSize) - .headJobOptions(adv().headJobOptions); + .headJobOptions(adv().headJobOptions) + .propagateHeadJobOptions(propagateHeadJobOptions(adv().propagateHeadJobOptions, adv().headJobOptions)); // Common config.workDir(workDir) @@ -94,5 +95,8 @@ public static class AdvancedOptions { @Option(names = {"--head-job-options"}, description = "Additional submit options for the Nextflow head job. Appended to the submit command for the main orchestration process.") public String headJobOptions; + + @Option(names = {"--propagate-head-job-options"}, description = "Apply the head job submit options to the compute jobs as well. Requires --head-job-options.") + public Boolean propagateHeadJobOptions; } } diff --git a/src/main/java/io/seqera/tower/cli/commands/computeenvs/platforms/SeqeraComputePlatform.java b/src/main/java/io/seqera/tower/cli/commands/computeenvs/platforms/SeqeraComputePlatform.java index 86f9f1e6f..75a3d6700 100644 --- a/src/main/java/io/seqera/tower/cli/commands/computeenvs/platforms/SeqeraComputePlatform.java +++ b/src/main/java/io/seqera/tower/cli/commands/computeenvs/platforms/SeqeraComputePlatform.java @@ -35,6 +35,9 @@ public class SeqeraComputePlatform extends AbstractPlatform @CommandLine.Option(names = {"--instance-type-size"}, description = "Studios instance size, controlling compute resources and capabilities. Options: ${COMPLETION-CANDIDATES}. Free-tier organizations are limited to SMALL.") public SeqeraComputeCloudInstanceTypeSize instanceTypeSize; + @CommandLine.Option(names = {"--data-retention-policy"}, description = "Apply the automatic data retention policy to the S3 bucket created by Seqera Compute. Intermediate files are deleted after 28 days to manage storage cost.") + public Boolean dataRetentionPolicy; + public SeqeraComputePlatform() { super(PlatformEnum.SEQERACOMPUTE_PLATFORM); } @@ -47,7 +50,8 @@ public SeqeraComputeConfig computeConfig() throws ApiException, IOException { // settings will automatically be configured by seqera compute and can't // be overridden. - config.region(region); + config.region(region) + .defaultDataRetentionPolicy(dataRetentionPolicy); if (instanceTypeSize != null) { config.instanceTypeSize(instanceTypeSize); diff --git a/src/main/java/io/seqera/tower/cli/commands/computeenvs/platforms/SlurmPlatform.java b/src/main/java/io/seqera/tower/cli/commands/computeenvs/platforms/SlurmPlatform.java index 3412ea5c7..307df7bbd 100644 --- a/src/main/java/io/seqera/tower/cli/commands/computeenvs/platforms/SlurmPlatform.java +++ b/src/main/java/io/seqera/tower/cli/commands/computeenvs/platforms/SlurmPlatform.java @@ -69,7 +69,8 @@ public SlurmComputeConfig computeConfig() throws ApiException, IOException { // Advanced .maxQueueSize(adv().maxQueueSize) - .headJobOptions(adv().headJobOptions); + .headJobOptions(adv().headJobOptions) + .propagateHeadJobOptions(propagateHeadJobOptions(adv().propagateHeadJobOptions, adv().headJobOptions)); // Common config.workDir(workDir) @@ -94,5 +95,8 @@ public static class AdvancedOptions { @Option(names = {"--head-job-options"}, description = "Additional submit options for the Nextflow head job. Appended to the sbatch command for the main orchestration process.") public String headJobOptions; + + @Option(names = {"--propagate-head-job-options"}, description = "Apply the head job submit options to the compute jobs as well. Requires --head-job-options.") + public Boolean propagateHeadJobOptions; } } diff --git a/src/main/java/io/seqera/tower/cli/commands/computeenvs/platforms/UnivaPlatform.java b/src/main/java/io/seqera/tower/cli/commands/computeenvs/platforms/UnivaPlatform.java index 7ed1a1513..dc55d2a4a 100644 --- a/src/main/java/io/seqera/tower/cli/commands/computeenvs/platforms/UnivaPlatform.java +++ b/src/main/java/io/seqera/tower/cli/commands/computeenvs/platforms/UnivaPlatform.java @@ -69,7 +69,8 @@ public UnivaComputeConfig computeConfig() throws ApiException, IOException { // Advanced .maxQueueSize(adv().maxQueueSize) - .headJobOptions(adv().headJobOptions); + .headJobOptions(adv().headJobOptions) + .propagateHeadJobOptions(propagateHeadJobOptions(adv().propagateHeadJobOptions, adv().headJobOptions)); // Common config.workDir(workDir) @@ -94,5 +95,8 @@ public static class AdvancedOptions { @Option(names = {"--head-job-options"}, description = "Additional submit options for the Nextflow head job. Appended to the submit command for the main orchestration process.") public String headJobOptions; + + @Option(names = {"--propagate-head-job-options"}, description = "Apply the head job submit options to the compute jobs as well. Requires --head-job-options.") + public Boolean propagateHeadJobOptions; } } diff --git a/src/main/java/io/seqera/tower/cli/commands/data/links/DeleteContentCmd.java b/src/main/java/io/seqera/tower/cli/commands/data/links/DeleteContentCmd.java new file mode 100644 index 000000000..bb5614e3e --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/commands/data/links/DeleteContentCmd.java @@ -0,0 +1,93 @@ +/* + * 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.data.links; + +import com.fasterxml.jackson.core.JsonProcessingException; +import io.seqera.tower.ApiException; +import io.seqera.tower.cli.commands.global.WorkspaceOptionalOptions; +import io.seqera.tower.cli.exceptions.TowerException; +import io.seqera.tower.cli.responses.Response; +import io.seqera.tower.cli.responses.data.DataLinkContentDeleted; +import io.seqera.tower.model.DataLinkDeleteItemRequest; +import io.seqera.tower.model.DataLinkDeleteItemResponse; +import picocli.CommandLine; + +import java.util.List; +import java.util.stream.Collectors; + +import static io.seqera.tower.cli.utils.JsonHelper.parseJson; + +@CommandLine.Command( + name = "delete-content", + description = "Delete files or folders from a data link. Supported for Seqera Compute data links only." +) +public class DeleteContentCmd extends AbstractDataLinksCmd { + + @CommandLine.Mixin + public WorkspaceOptionalOptions workspace; + + @CommandLine.Mixin + public DataLinkRefOptions dataLinkRefOptions; + + @CommandLine.Option(names = {"-c", "--credentials"}, description = "Credentials identifier", required = true) + public String credentialsRef; + + @CommandLine.Option(names = {"--file"}, description = "File path to delete, relative to the data link root. Can be specified multiple times.") + public List files; + + @CommandLine.Option(names = {"--dir"}, description = "Folder path to delete with all its contents, relative to the data link root. Can be specified multiple times.") + public List dirs; + + @Override + protected Response exec() throws ApiException { + if (files == null && dirs == null) { + throw new TowerException("Provide at least one --file or --dir to delete"); + } + + Long wspId = workspaceId(workspace.workspace); + String credId = credentialsByRef(null, wspId, credentialsRef); + String id = getDataLinkId(dataLinkRefOptions, wspId, credId); + + DataLinkDeleteItemRequest request = new DataLinkDeleteItemRequest().files(files).dirs(dirs); + try { + dataLinksApi().deleteDataLinkItem(id, request, wspId, credId); + } catch (ApiException e) { + // The platform answers 500 with the list of items it could not delete when the deletion is partial. + DataLinkDeleteItemResponse failed = e.getCode() == 500 ? parseDeletionFailures(e.getResponseBody()) : null; + if (failed == null || failed.getDeletionFailures() == null || failed.getDeletionFailures().isEmpty()) { + throw e; + } + String failures = failed.getDeletionFailures().stream() + .map(f -> String.format("'%s': %s", f.getDataLinkItem() == null ? "" : f.getDataLinkItem().getName(), f.getErrorMessage())) + .collect(Collectors.joining(", ")); + throw new TowerException(String.format("Failed to delete some items from data link '%s': %s", id, failures)); + } + + return new DataLinkContentDeleted(id, wspId, files, dirs); + } + + private static DataLinkDeleteItemResponse parseDeletionFailures(String body) { + if (body == null) { + return null; + } + try { + return parseJson(body, DataLinkDeleteItemResponse.class); + } catch (JsonProcessingException e) { + return null; + } + } +} diff --git a/src/main/java/io/seqera/tower/cli/commands/data/links/DownloadScriptCmd.java b/src/main/java/io/seqera/tower/cli/commands/data/links/DownloadScriptCmd.java new file mode 100644 index 000000000..4124f5d18 --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/commands/data/links/DownloadScriptCmd.java @@ -0,0 +1,64 @@ +/* + * 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.data.links; + +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.data.DataLinkDownloadScript; +import io.seqera.tower.model.DataLinkDownloadScriptResponse; +import picocli.CommandLine; + +import java.util.ArrayList; +import java.util.List; + +@CommandLine.Command( + name = "download-script", + description = "Print the cloud provider CLI commands that download data link contents. Downloads the whole data link when no --file or --dir is given." +) +public class DownloadScriptCmd extends AbstractDataLinksCmd { + + @CommandLine.Mixin + public WorkspaceOptionalOptions workspace; + + @CommandLine.Mixin + public DataLinkRefOptions dataLinkRefOptions; + + @CommandLine.Option(names = {"-c", "--credentials"}, description = "Credentials identifier") + public String credentialsRef; + + @CommandLine.Option(names = {"--file"}, description = "File path to download, relative to the data link root. Can be specified multiple times.") + public List files; + + @CommandLine.Option(names = {"--dir"}, description = "Folder path to download, relative to the data link root. Can be specified multiple times.") + public List dirs; + + @Override + protected Response exec() throws ApiException { + Long wspId = workspaceId(workspace.workspace); + String credId = credentialsRef != null ? credentialsByRef(null, wspId, credentialsRef) : null; + String id = getDataLinkId(dataLinkRefOptions, wspId, credId); + + DataLinkDownloadScriptResponse response = dataLinksApi().generateDownloadScript(id, wspId, credId, toObjects(dirs), toObjects(files)); + + return new DataLinkDownloadScript(response.getScript()); + } + + private static List toObjects(List paths) { + return paths == null ? null : new ArrayList<>(paths); + } +} diff --git a/src/main/java/io/seqera/tower/cli/commands/datasets/DisableVersionCmd.java b/src/main/java/io/seqera/tower/cli/commands/datasets/DisableVersionCmd.java new file mode 100644 index 000000000..340b25bef --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/commands/datasets/DisableVersionCmd.java @@ -0,0 +1,50 @@ +/* + * 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.datasets; + +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.datasets.DatasetVersionDisabled; +import io.seqera.tower.model.DatasetDto; +import picocli.CommandLine; + +@CommandLine.Command( + name = "disable-version", + description = "Disable a dataset version so it can no longer be used for new runs. A disabled version cannot be enabled again." +) +public class DisableVersionCmd extends AbstractDatasetsCmd { + + @CommandLine.Mixin + public DatasetRefOptions datasetRefOptions; + + @CommandLine.Option(names = {"--dataset-version"}, description = "Dataset version to disable", required = true) + public Long version; + + @CommandLine.Mixin + public WorkspaceRequiredOptions workspace; + + @Override + protected Response exec() throws ApiException { + Long wspId = workspaceId(workspace.workspace); + DatasetDto dataset = fetchDescribeDatasetResponse(datasetRefOptions, wspId); + + datasetsApi().disableDatasetVersion(dataset.getId(), version, wspId); + + return new DatasetVersionDisabled(getDatasetRef(datasetRefOptions), version, workspace.workspace); + } +} diff --git a/src/main/java/io/seqera/tower/cli/commands/managedidentities/AbstractManagedIdentitiesCmd.java b/src/main/java/io/seqera/tower/cli/commands/managedidentities/AbstractManagedIdentitiesCmd.java new file mode 100644 index 000000000..2db390514 --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/commands/managedidentities/AbstractManagedIdentitiesCmd.java @@ -0,0 +1,84 @@ +/* + * 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.managedidentities; + +import com.fasterxml.jackson.core.JsonProcessingException; +import io.seqera.tower.ApiException; +import io.seqera.tower.cli.commands.AbstractApiCmd; +import io.seqera.tower.cli.exceptions.CredentialsInUseException; +import io.seqera.tower.cli.exceptions.ManagedIdentityNotFoundException; +import io.seqera.tower.model.DeleteManagedCredentialsConflictResponse; +import io.seqera.tower.model.ManagedIdentityDbDtoAbstractGridConfig; +import picocli.CommandLine.Command; + +import java.util.List; + +import static io.seqera.tower.cli.utils.JsonHelper.parseJson; + +@Command +public abstract class AbstractManagedIdentitiesCmd extends AbstractApiCmd { + + protected static final String ORGANIZATION_DESCRIPTION = "Organization name or numeric ID. Specify either the unique organization name or the numeric organization ID returned by 'tw organizations list'."; + + protected List listManagedIdentities(Long orgId) throws ApiException { + return identitiesApi().listManagedIdentities(orgId, null, null, null).getManagedIdentities(); + } + + // The describe endpoint response is not modelled correctly in the SDK (it wraps the identity in a + // 'managedIdentity' field), so look the identity up in the organization list instead. + protected ManagedIdentityDbDtoAbstractGridConfig findManagedIdentity(Long orgId, ManagedIdentityRefOptions ref) throws ApiException { + return listManagedIdentities(orgId).stream() + .filter(it -> ref.managedIdentity.id != null ? ref.managedIdentity.id.equals(it.getId()) : ref.managedIdentity.name.equals(it.getName())) + .findFirst() + .orElseThrow(() -> new ManagedIdentityNotFoundException(ref.ref(), orgId)); + } + + /** + * Runs a managed identity or managed credentials deletion, turning the 409 the Platform returns when running + * jobs use the credentials into a {@link CredentialsInUseException}. + */ + protected static void deleteUnlessInUse(String ref, DeleteCall call) throws ApiException { + try { + call.run(); + } catch (ApiException e) { + DeleteManagedCredentialsConflictResponse conflict = decodeConflict(e); + if (conflict != null) { + throw new CredentialsInUseException(ref, conflict.getConflicts()); + } + throw e; + } + } + + private static DeleteManagedCredentialsConflictResponse decodeConflict(ApiException e) { + if (e.getCode() != 409 || e.getResponseBody() == null) { + return null; + } + try { + DeleteManagedCredentialsConflictResponse conflict = parseJson(e.getResponseBody(), DeleteManagedCredentialsConflictResponse.class); + return conflict.getConflicts() == null || conflict.getConflicts().isEmpty() ? null : conflict; + } catch (JsonProcessingException ignored) { + // Not a conflict body: let the original error through + return null; + } + } + + @FunctionalInterface + protected interface DeleteCall { + void run() throws ApiException; + } +} diff --git a/src/main/java/io/seqera/tower/cli/commands/managedidentities/AddCmd.java b/src/main/java/io/seqera/tower/cli/commands/managedidentities/AddCmd.java new file mode 100644 index 000000000..5bdf9d690 --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/commands/managedidentities/AddCmd.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.commands.managedidentities; + +import io.seqera.tower.ApiException; +import io.seqera.tower.cli.responses.Response; +import io.seqera.tower.cli.responses.managedidentities.ManagedIdentityAdded; +import io.seqera.tower.model.CreateManagedIdentityRequest; +import io.seqera.tower.model.CreateManagedIdentityResponse; +import picocli.CommandLine.Command; +import picocli.CommandLine.Option; + +@Command( + name = "add", + description = "Add a managed identity for an HPC cluster. Each organization member then adds their own SSH credentials with 'tw managed-identities credentials add'." +) +public class AddCmd extends AbstractManagedIdentitiesCmd { + + @Option(names = {"-o", "--organization"}, description = ORGANIZATION_DESCRIPTION, required = true) + public String organizationRef; + + @Option(names = {"-n", "--name"}, description = "Cluster name. Must be unique per organization. Names consist of alphanumeric, hyphen, and underscore characters.", required = true) + public String name; + + @Option(names = {"-p", "--platform"}, description = "Cluster workload manager: ${COMPLETION-CANDIDATES}.", required = true) + public ManagedIdentityPlatform platform; + + @Option(names = {"-H", "--host-name"}, description = "Hostname of the cluster to connect to via SSH, usually the login node. Must be a fully qualified hostname, not a local IP address.", required = true) + public String hostName; + + @Option(names = {"--port"}, description = "SSH port for the login connection [default: 22].", defaultValue = "22") + public Integer port; + + @Override + protected Response exec() throws ApiException { + Long orgId = findOrganizationByRef(organizationRef).getOrgId(); + CreateManagedIdentityRequest request = new CreateManagedIdentityRequest() + .name(name) + .platform(CreateManagedIdentityRequest.PlatformEnum.fromValue(platform.value)) + .config(platform.config(hostName, port)); + CreateManagedIdentityResponse response = identitiesApi().createManagedIdentity(request, orgId); + return new ManagedIdentityAdded(organizationRef, response.getId(), response.getName()); + } +} diff --git a/src/main/java/io/seqera/tower/cli/commands/managedidentities/CredentialsCmd.java b/src/main/java/io/seqera/tower/cli/commands/managedidentities/CredentialsCmd.java new file mode 100644 index 000000000..cbab94884 --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/commands/managedidentities/CredentialsCmd.java @@ -0,0 +1,119 @@ +/* + * 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.managedidentities; + +import io.seqera.tower.ApiException; +import io.seqera.tower.cli.commands.managedidentities.credentials.AddCmd; +import io.seqera.tower.cli.commands.managedidentities.credentials.DeleteCmd; +import io.seqera.tower.cli.commands.managedidentities.credentials.UpdateCmd; +import io.seqera.tower.cli.exceptions.TowerException; +import io.seqera.tower.cli.responses.Response; +import io.seqera.tower.cli.responses.managedidentities.ManagedCredentialsList; +import io.seqera.tower.model.CreateManagedCredentialsRequest; +import io.seqera.tower.model.Credentials; +import io.seqera.tower.model.ListManagedCredentialsRespDto; +import io.seqera.tower.model.ManagedIdentityDbDtoAbstractGridConfig; +import io.seqera.tower.model.SSHSecurityKeys; +import io.seqera.tower.model.UpdateManagedCredentialsRequest; +import picocli.CommandLine.Command; +import picocli.CommandLine.Mixin; +import picocli.CommandLine.Option; + +import java.util.ArrayList; +import java.util.List; + +@Command( + name = "credentials", + description = "List the organization members' SSH credentials for a managed identity", + subcommands = { + AddCmd.class, + UpdateCmd.class, + DeleteCmd.class, + } +) +public class CredentialsCmd extends AbstractManagedIdentitiesCmd { + + @Option(names = {"-o", "--organization"}, description = ORGANIZATION_DESCRIPTION, required = true) + public String organizationRef; + + @Mixin + public ManagedIdentityRefOptions ref; + + @Override + protected Response exec() throws ApiException { + Long orgId = orgId(); + ManagedIdentityDbDtoAbstractGridConfig identity = findManagedIdentity(orgId, ref); + return new ManagedCredentialsList(organizationRef, identity.getName(), listManagedCredentials(orgId, identity.getId(), null)); + } + + public Long orgId() throws ApiException { + return findOrganizationByRef(organizationRef).getOrgId(); + } + + public ManagedIdentityDbDtoAbstractGridConfig managedIdentity(Long orgId) throws ApiException { + return findManagedIdentity(orgId, ref); + } + + /** + * Organization owners get a row for every member, with an empty managed credentials id when the member has + * not added credentials yet. Other members only get their own row. + */ + public ListManagedCredentialsRespDto memberRow(Long orgId, ManagedIdentityDbDtoAbstractGridConfig identity, String member) throws ApiException { + return listManagedCredentials(orgId, identity.getId(), member).stream() + .filter(it -> member.equals(it.getUserName())) + .findFirst() + .orElseThrow(() -> new TowerException(String.format("Member '%s' not found in the organization", member))); + } + + public ListManagedCredentialsRespDto memberCredentials(Long orgId, ManagedIdentityDbDtoAbstractGridConfig identity, String member) throws ApiException { + String userName = member != null ? member : userName(); + return listManagedCredentials(orgId, identity.getId(), userName).stream() + .filter(it -> userName.equals(it.getUserName()) && it.getManagedCredentialsId() != null) + .findFirst() + .orElseThrow(() -> new TowerException(String.format("Member '%s' has no credentials for managed identity '%s'", userName, identity.getName()))); + } + + // Platform returns one row per organization member, 50 by default and at most 100 per page + private static final int PAGE_SIZE = 100; + + private List listManagedCredentials(Long orgId, Long identityId, String search) throws ApiException { + List all = new ArrayList<>(); + List page; + do { + page = identitiesApi().listManagedCredentials(identityId, orgId, null, search, PAGE_SIZE, all.size()).getManagedCredentials(); + if (page != null) { + all.addAll(page); + } + } while (page != null && page.size() == PAGE_SIZE); + return all; + } + + public static CreateManagedCredentialsRequest createRequest(SSHSecurityKeys keys, String linuxUserName) { + return new CreateManagedCredentialsRequest() + .provider(CreateManagedCredentialsRequest.ProviderEnum.SSH) + .credentials(new Credentials().provider(Credentials.ProviderEnum.SSH).keys(keys)) + .metadata(new SshManagedCredentialsMetadata(linuxUserName)); + } + + public static UpdateManagedCredentialsRequest updateRequest(SSHSecurityKeys keys, String linuxUserName) { + return new UpdateManagedCredentialsRequest() + .provider(UpdateManagedCredentialsRequest.ProviderEnum.SSH) + .credentials(new Credentials().provider(Credentials.ProviderEnum.SSH).keys(keys)) + .metadata(new SshManagedCredentialsMetadata(linuxUserName)); + } +} diff --git a/src/main/java/io/seqera/tower/cli/commands/managedidentities/DeleteCmd.java b/src/main/java/io/seqera/tower/cli/commands/managedidentities/DeleteCmd.java new file mode 100644 index 000000000..bb1b91999 --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/commands/managedidentities/DeleteCmd.java @@ -0,0 +1,50 @@ +/* + * 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.managedidentities; + +import io.seqera.tower.ApiException; +import io.seqera.tower.cli.responses.Response; +import io.seqera.tower.cli.responses.managedidentities.ManagedIdentityDeleted; +import io.seqera.tower.model.ManagedIdentityDbDtoAbstractGridConfig; +import picocli.CommandLine.Command; +import picocli.CommandLine.Mixin; +import picocli.CommandLine.Option; + +@Command( + name = "delete", + description = "Delete a managed identity and all its members' credentials. Compute environments that use it become invalid." +) +public class DeleteCmd extends AbstractManagedIdentitiesCmd { + + @Option(names = {"-o", "--organization"}, description = ORGANIZATION_DESCRIPTION, required = true) + public String organizationRef; + + @Mixin + ManagedIdentityRefOptions ref; + + @Option(names = {"--force"}, description = "Delete the managed identity even if running jobs use its credentials. Those jobs are stopped. By default, a managed identity in use is not deleted.") + public boolean force = false; + + @Override + protected Response exec() throws ApiException { + Long orgId = findOrganizationByRef(organizationRef).getOrgId(); + ManagedIdentityDbDtoAbstractGridConfig identity = findManagedIdentity(orgId, ref); + deleteUnlessInUse(identity.getName(), () -> identitiesApi().deleteManagedIdentity(identity.getId(), orgId, !force)); + return new ManagedIdentityDeleted(organizationRef, identity.getId(), identity.getName()); + } +} diff --git a/src/main/java/io/seqera/tower/cli/commands/managedidentities/ListCmd.java b/src/main/java/io/seqera/tower/cli/commands/managedidentities/ListCmd.java new file mode 100644 index 000000000..9e37cc1c8 --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/commands/managedidentities/ListCmd.java @@ -0,0 +1,40 @@ +/* + * 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.managedidentities; + +import io.seqera.tower.ApiException; +import io.seqera.tower.cli.responses.Response; +import io.seqera.tower.cli.responses.managedidentities.ManagedIdentitiesList; +import picocli.CommandLine.Command; +import picocli.CommandLine.Option; + +@Command( + name = "list", + description = "List organization managed identities" +) +public class ListCmd extends AbstractManagedIdentitiesCmd { + + @Option(names = {"-o", "--organization"}, description = ORGANIZATION_DESCRIPTION, required = true) + public String organizationRef; + + @Override + protected Response exec() throws ApiException { + Long orgId = findOrganizationByRef(organizationRef).getOrgId(); + return new ManagedIdentitiesList(organizationRef, listManagedIdentities(orgId)); + } +} diff --git a/src/main/java/io/seqera/tower/cli/commands/managedidentities/ManagedIdentityPlatform.java b/src/main/java/io/seqera/tower/cli/commands/managedidentities/ManagedIdentityPlatform.java new file mode 100644 index 000000000..2dd0934c1 --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/commands/managedidentities/ManagedIdentityPlatform.java @@ -0,0 +1,65 @@ +/* + * 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.managedidentities; + +import io.seqera.tower.model.AbstractGridConfig; +import io.seqera.tower.model.AltairPbsComputeConfig; +import io.seqera.tower.model.LsfComputeConfig; +import io.seqera.tower.model.MoabComputeConfig; +import io.seqera.tower.model.SlurmComputeConfig; +import io.seqera.tower.model.UnivaComputeConfig; + +import java.util.function.Supplier; + +/** + * HPC schedulers a managed identity can target, named like the 'tw compute-envs add' subcommands. + */ +public enum ManagedIdentityPlatform { + altair("altair-platform", AltairPbsComputeConfig::new), + lsf("lsf-platform", LsfComputeConfig::new), + moab("moab-platform", MoabComputeConfig::new), + slurm("slurm-platform", SlurmComputeConfig::new), + uge("uge-platform", UnivaComputeConfig::new); + + public final String value; + private final Supplier configFactory; + + ManagedIdentityPlatform(String value, Supplier configFactory) { + this.value = value; + this.configFactory = configFactory; + } + + /** + * Platform reads the config subtype from its discriminator, so it must be the platform's own config class. + */ + public AbstractGridConfig config(String hostName, Integer port) { + AbstractGridConfig config = configFactory.get(); + config.setHostName(hostName); + config.setPort(port); + return config; + } + + public static ManagedIdentityPlatform fromValue(String value) { + for (ManagedIdentityPlatform platform : values()) { + if (platform.value.equals(value)) { + return platform; + } + } + throw new IllegalArgumentException(String.format("Unsupported managed identity platform '%s'", value)); + } +} diff --git a/src/main/java/io/seqera/tower/cli/commands/managedidentities/ManagedIdentityRefOptions.java b/src/main/java/io/seqera/tower/cli/commands/managedidentities/ManagedIdentityRefOptions.java new file mode 100644 index 000000000..087c7b2a0 --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/commands/managedidentities/ManagedIdentityRefOptions.java @@ -0,0 +1,40 @@ +/* + * 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.managedidentities; + +import picocli.CommandLine.ArgGroup; +import picocli.CommandLine.Option; + +public class ManagedIdentityRefOptions { + + @ArgGroup(multiplicity = "1") + public ManagedIdentityRef managedIdentity; + + public static class ManagedIdentityRef { + + @Option(names = {"-i", "--id"}, description = "Managed identity numeric identifier") + public Long id; + + @Option(names = {"-n", "--name"}, description = "Managed identity name") + public String name; + } + + public String ref() { + return managedIdentity.id != null ? managedIdentity.id.toString() : managedIdentity.name; + } +} diff --git a/src/main/java/io/seqera/tower/cli/commands/managedidentities/SshManagedCredentialsMetadata.java b/src/main/java/io/seqera/tower/cli/commands/managedidentities/SshManagedCredentialsMetadata.java new file mode 100644 index 000000000..ca437387f --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/commands/managedidentities/SshManagedCredentialsMetadata.java @@ -0,0 +1,35 @@ +/* + * 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.managedidentities; + +import com.fasterxml.jackson.annotation.JsonProperty; +import io.seqera.tower.model.ManagedCredentialsMetadata; + +/** + * The SDK models managed credentials metadata without the SSH 'userName' field that Platform requires + * (it only knows the read-only discriminator), so add it here. + */ +public class SshManagedCredentialsMetadata extends ManagedCredentialsMetadata { + + @JsonProperty("userName") + public final String userName; + + public SshManagedCredentialsMetadata(String userName) { + this.userName = userName; + } +} diff --git a/src/main/java/io/seqera/tower/cli/commands/managedidentities/UpdateCmd.java b/src/main/java/io/seqera/tower/cli/commands/managedidentities/UpdateCmd.java new file mode 100644 index 000000000..a457fe2c1 --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/commands/managedidentities/UpdateCmd.java @@ -0,0 +1,73 @@ +/* + * 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.managedidentities; + +import io.seqera.tower.ApiException; +import io.seqera.tower.cli.exceptions.TowerException; +import io.seqera.tower.cli.responses.Response; +import io.seqera.tower.cli.responses.managedidentities.ManagedIdentityUpdated; +import io.seqera.tower.model.AbstractGridConfig; +import io.seqera.tower.model.ManagedIdentityDbDtoAbstractGridConfig; +import io.seqera.tower.model.UpdateManagedIdentityRequest; +import picocli.CommandLine.Command; +import picocli.CommandLine.Mixin; +import picocli.CommandLine.Option; + +@Command( + name = "update", + description = "Update a managed identity. Changing the host of a cluster used by compute environments may break them." +) +public class UpdateCmd extends AbstractManagedIdentitiesCmd { + + @Option(names = {"-o", "--organization"}, description = ORGANIZATION_DESCRIPTION, required = true) + public String organizationRef; + + @Mixin + ManagedIdentityRefOptions ref; + + @Option(names = {"--new-name"}, description = "New cluster name. Must be unique per organization.") + public String newName; + + @Option(names = {"-H", "--host-name"}, description = "New hostname of the cluster to connect to via SSH.") + public String hostName; + + @Option(names = {"--port"}, description = "New SSH port for the login connection.") + public Integer port; + + @Override + protected Response exec() throws ApiException { + if (newName == null && hostName == null && port == null) { + throw new TowerException("Nothing to update: provide at least one of '--new-name', '--host-name' or '--port'"); + } + + Long orgId = findOrganizationByRef(organizationRef).getOrgId(); + ManagedIdentityDbDtoAbstractGridConfig current = findManagedIdentity(orgId, ref); + AbstractGridConfig currentConfig = current.getConfig(); + + // Platform only applies the name, host and port, but needs the platform to read the config + ManagedIdentityDbDtoAbstractGridConfig update = new ManagedIdentityDbDtoAbstractGridConfig() + .name(newName != null ? newName : current.getName()) + .platform(current.getPlatform()) + .config(ManagedIdentityPlatform.fromValue(current.getPlatform().getValue()).config( + hostName != null ? hostName : currentConfig.getHostName(), + port != null ? port : currentConfig.getPort())); + identitiesApi().updateManagedIdentity(current.getId(), new UpdateManagedIdentityRequest().managedIdentity(update), orgId); + + return new ManagedIdentityUpdated(organizationRef, current.getId(), update.getName()); + } +} diff --git a/src/main/java/io/seqera/tower/cli/commands/managedidentities/ViewCmd.java b/src/main/java/io/seqera/tower/cli/commands/managedidentities/ViewCmd.java new file mode 100644 index 000000000..4ad564637 --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/commands/managedidentities/ViewCmd.java @@ -0,0 +1,44 @@ +/* + * 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.managedidentities; + +import io.seqera.tower.ApiException; +import io.seqera.tower.cli.responses.Response; +import io.seqera.tower.cli.responses.managedidentities.ManagedIdentityView; +import picocli.CommandLine.Command; +import picocli.CommandLine.Mixin; +import picocli.CommandLine.Option; + +@Command( + name = "view", + description = "View managed identity details" +) +public class ViewCmd extends AbstractManagedIdentitiesCmd { + + @Option(names = {"-o", "--organization"}, description = ORGANIZATION_DESCRIPTION, required = true) + public String organizationRef; + + @Mixin + ManagedIdentityRefOptions ref; + + @Override + protected Response exec() throws ApiException { + Long orgId = findOrganizationByRef(organizationRef).getOrgId(); + return new ManagedIdentityView(organizationRef, findManagedIdentity(orgId, ref)); + } +} diff --git a/src/main/java/io/seqera/tower/cli/commands/managedidentities/credentials/AddCmd.java b/src/main/java/io/seqera/tower/cli/commands/managedidentities/credentials/AddCmd.java new file mode 100644 index 000000000..ffc0c9b49 --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/commands/managedidentities/credentials/AddCmd.java @@ -0,0 +1,60 @@ +/* + * 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.managedidentities.credentials; + +import io.seqera.tower.ApiException; +import io.seqera.tower.cli.commands.AbstractApiCmd; +import io.seqera.tower.cli.commands.managedidentities.CredentialsCmd; +import io.seqera.tower.cli.responses.Response; +import io.seqera.tower.cli.responses.managedidentities.ManagedCredentialsAdded; +import io.seqera.tower.model.CreateManagedCredentialsResponse; +import io.seqera.tower.model.ManagedIdentityDbDtoAbstractGridConfig; +import picocli.CommandLine.Command; +import picocli.CommandLine.Mixin; +import picocli.CommandLine.Option; +import picocli.CommandLine.ParentCommand; + +import java.io.IOException; + +@Command( + name = "add", + description = "Add SSH credentials to a managed identity" +) +public class AddCmd extends AbstractApiCmd { + + @ParentCommand + public CredentialsCmd parent; + + @Option(names = {"-m", "--member"}, description = "Platform username of the organization member the credentials belong to. Only organization owners can add credentials for other members [default: you].") + public String member; + + @Mixin + public SshCredentialsOptions ssh; + + @Override + protected Response exec() throws ApiException, IOException { + Long orgId = parent.orgId(); + ManagedIdentityDbDtoAbstractGridConfig identity = parent.managedIdentity(orgId); + Long userId = member != null ? parent.memberRow(orgId, identity, member).getUserId() : null; + + CreateManagedCredentialsResponse response = identitiesApi().createManagedCredentials( + identity.getId(), CredentialsCmd.createRequest(ssh.securityKeys(), ssh.linuxUserName), orgId, userId); + + return new ManagedCredentialsAdded(identity.getName(), response.getManagedCredentials().getId()); + } +} diff --git a/src/main/java/io/seqera/tower/cli/commands/managedidentities/credentials/DeleteCmd.java b/src/main/java/io/seqera/tower/cli/commands/managedidentities/credentials/DeleteCmd.java new file mode 100644 index 000000000..0be74c594 --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/commands/managedidentities/credentials/DeleteCmd.java @@ -0,0 +1,57 @@ +/* + * 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.managedidentities.credentials; + +import io.seqera.tower.ApiException; +import io.seqera.tower.cli.commands.managedidentities.AbstractManagedIdentitiesCmd; +import io.seqera.tower.cli.commands.managedidentities.CredentialsCmd; +import io.seqera.tower.cli.responses.Response; +import io.seqera.tower.cli.responses.managedidentities.ManagedCredentialsDeleted; +import io.seqera.tower.model.ListManagedCredentialsRespDto; +import io.seqera.tower.model.ManagedIdentityDbDtoAbstractGridConfig; +import picocli.CommandLine.Command; +import picocli.CommandLine.Option; +import picocli.CommandLine.ParentCommand; + +@Command( + name = "delete", + description = "Delete the SSH credentials of an organization member from a managed identity" +) +public class DeleteCmd extends AbstractManagedIdentitiesCmd { + + @ParentCommand + public CredentialsCmd parent; + + @Option(names = {"-m", "--member"}, description = "Platform username of the organization member the credentials belong to. Only organization owners can delete credentials of other members [default: you].") + public String member; + + @Option(names = {"--force"}, description = "Delete the credentials even if running jobs use them. Those jobs are stopped. By default, credentials in use are not deleted.") + public boolean force = false; + + @Override + protected Response exec() throws ApiException { + Long orgId = parent.orgId(); + ManagedIdentityDbDtoAbstractGridConfig identity = parent.managedIdentity(orgId); + ListManagedCredentialsRespDto credentials = parent.memberCredentials(orgId, identity, member); + Long id = credentials.getManagedCredentialsId(); + + deleteUnlessInUse(id.toString(), () -> identitiesApi().deleteManagedCredentials(identity.getId(), id, orgId, !force)); + + return new ManagedCredentialsDeleted(identity.getName(), id); + } +} diff --git a/src/main/java/io/seqera/tower/cli/commands/managedidentities/credentials/SshCredentialsOptions.java b/src/main/java/io/seqera/tower/cli/commands/managedidentities/credentials/SshCredentialsOptions.java new file mode 100644 index 000000000..8f04826b6 --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/commands/managedidentities/credentials/SshCredentialsOptions.java @@ -0,0 +1,43 @@ +/* + * 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.managedidentities.credentials; + +import io.seqera.tower.cli.utils.FilesHelper; +import io.seqera.tower.model.SSHSecurityKeys; +import picocli.CommandLine.Option; + +import java.io.IOException; +import java.nio.file.Path; + +public class SshCredentialsOptions { + + @Option(names = {"-l", "--linux-username"}, description = "Linux username of the organization member on the cluster.", required = true) + public String linuxUserName; + + @Option(names = {"-k", "--key"}, description = "Path to the SSH private key file used to connect to the cluster.", required = true) + public Path privateKey; + + @Option(names = {"-p", "--passphrase"}, description = "Passphrase for an encrypted SSH private key.", arity = "0..1", interactive = true) + public String passphrase; + + public SSHSecurityKeys securityKeys() throws IOException { + return new SSHSecurityKeys() + .privateKey(FilesHelper.readString(privateKey)) + .passphrase(passphrase); + } +} diff --git a/src/main/java/io/seqera/tower/cli/commands/managedidentities/credentials/UpdateCmd.java b/src/main/java/io/seqera/tower/cli/commands/managedidentities/credentials/UpdateCmd.java new file mode 100644 index 000000000..3ff83664c --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/commands/managedidentities/credentials/UpdateCmd.java @@ -0,0 +1,60 @@ +/* + * 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.managedidentities.credentials; + +import io.seqera.tower.ApiException; +import io.seqera.tower.cli.commands.AbstractApiCmd; +import io.seqera.tower.cli.commands.managedidentities.CredentialsCmd; +import io.seqera.tower.cli.responses.Response; +import io.seqera.tower.cli.responses.managedidentities.ManagedCredentialsUpdated; +import io.seqera.tower.model.ListManagedCredentialsRespDto; +import io.seqera.tower.model.ManagedIdentityDbDtoAbstractGridConfig; +import picocli.CommandLine.Command; +import picocli.CommandLine.Mixin; +import picocli.CommandLine.Option; +import picocli.CommandLine.ParentCommand; + +import java.io.IOException; + +@Command( + name = "update", + description = "Replace the SSH credentials of an organization member in a managed identity" +) +public class UpdateCmd extends AbstractApiCmd { + + @ParentCommand + public CredentialsCmd parent; + + @Option(names = {"-m", "--member"}, description = "Platform username of the organization member the credentials belong to. Only organization owners can update credentials of other members [default: you].") + public String member; + + @Mixin + public SshCredentialsOptions ssh; + + @Override + protected Response exec() throws ApiException, IOException { + Long orgId = parent.orgId(); + ManagedIdentityDbDtoAbstractGridConfig identity = parent.managedIdentity(orgId); + ListManagedCredentialsRespDto credentials = parent.memberCredentials(orgId, identity, member); + + identitiesApi().updateManagedCredentials( + identity.getId(), credentials.getManagedCredentialsId(), CredentialsCmd.updateRequest(ssh.securityKeys(), ssh.linuxUserName), orgId); + + return new ManagedCredentialsUpdated(identity.getName(), credentials.getManagedCredentialsId()); + } +} diff --git a/src/main/java/io/seqera/tower/cli/commands/organizations/QuotasCmd.java b/src/main/java/io/seqera/tower/cli/commands/organizations/QuotasCmd.java new file mode 100644 index 000000000..2556f1553 --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/commands/organizations/QuotasCmd.java @@ -0,0 +1,40 @@ +/* + * 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.organizations; + +import io.seqera.tower.ApiException; +import io.seqera.tower.cli.responses.Response; +import io.seqera.tower.cli.responses.organizations.OrganizationQuotasView; +import io.seqera.tower.model.OrganizationDbDto; +import picocli.CommandLine.Command; +import picocli.CommandLine.Mixin; + +@Command( + name = "quotas", + description = "View organization quotas" +) +public class QuotasCmd extends AbstractOrganizationsCmd { + + @Mixin + OrganizationRefOptions organizationRefOptions; + + @Override + protected Response exec() throws ApiException { + OrganizationDbDto organization = fetchOrganization(organizationRefOptions).getOrganization(); + return new OrganizationQuotasView(organization.getName(), orgsApi().describeOrganizationQuotas(organization.getOrgId(), null).getQuotas()); + } +} diff --git a/src/main/java/io/seqera/tower/cli/commands/participants/AbstractParticipantsCmd.java b/src/main/java/io/seqera/tower/cli/commands/participants/AbstractParticipantsCmd.java index 02750192a..cab5cd148 100644 --- a/src/main/java/io/seqera/tower/cli/commands/participants/AbstractParticipantsCmd.java +++ b/src/main/java/io/seqera/tower/cli/commands/participants/AbstractParticipantsCmd.java @@ -71,7 +71,7 @@ protected MemberDbDto findOrganizationCollaboratorByName(Long orgId, String name } protected TeamDbDto findOrganizationTeamByName(Long orgId, String name) throws ApiException { - ListTeamResponse listTeamResponse = teamsApi().listOrganizationTeams(orgId, null, null, null); + ListTeamResponse listTeamResponse = teamsApi().listOrganizationTeams(orgId, null, null, name); if (listTeamResponse.getTeams() != null) { TeamDbDto team = listTeamResponse.getTeams().stream().filter(it -> Objects.equals(it.getName(), name)).findFirst().orElse(null); diff --git a/src/main/java/io/seqera/tower/cli/commands/pipelines/AddCmd.java b/src/main/java/io/seqera/tower/cli/commands/pipelines/AddCmd.java index d57b62152..ac2d5321d 100644 --- a/src/main/java/io/seqera/tower/cli/commands/pipelines/AddCmd.java +++ b/src/main/java/io/seqera/tower/cli/commands/pipelines/AddCmd.java @@ -108,6 +108,7 @@ protected Response exec() throws ApiException, IOException { // Advanced options .configText(FilesHelper.readString(opts.config)) + .towerConfig(FilesHelper.readString(opts.towerConfig)) .preRunScript(preRunScriptValue) .postRunScript(postRunScriptValue) .pullLatest(opts.pullLatest) diff --git a/src/main/java/io/seqera/tower/cli/commands/pipelines/LaunchOptions.java b/src/main/java/io/seqera/tower/cli/commands/pipelines/LaunchOptions.java index fb66f05f4..b13348ed1 100644 --- a/src/main/java/io/seqera/tower/cli/commands/pipelines/LaunchOptions.java +++ b/src/main/java/io/seqera/tower/cli/commands/pipelines/LaunchOptions.java @@ -44,6 +44,9 @@ public class LaunchOptions extends NullableLaunchOptions { @Option(names = {"--config"}, description = "Nextflow configuration as text (overrides config files). Provide the path to a file containing the content.") public Path config; + @Option(names = {"--tower-config"}, description = "Seqera Platform configuration in tower.yml format. Overrides the pipeline's tower.yml for this run. Provide the path to a file containing the content.") + public Path towerConfig; + @Option(names = {"--pre-run"}, description = "Add a script that executes in the nf-launch script prior to invoking Nextflow processes. See: https://docs.seqera.io/platform-cloud/launch/advanced#pre-and-post-run-scripts. Provide the path to a file containing the content.") public Path preRunScript; diff --git a/src/main/java/io/seqera/tower/cli/commands/pipelines/SchemaCmd.java b/src/main/java/io/seqera/tower/cli/commands/pipelines/SchemaCmd.java new file mode 100644 index 000000000..3256b5bd9 --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/commands/pipelines/SchemaCmd.java @@ -0,0 +1,71 @@ +/* + * 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.pipelines; + +import io.seqera.tower.ApiException; +import io.seqera.tower.cli.commands.global.WorkspaceOptionalOptions; +import io.seqera.tower.cli.commands.pipelines.versions.VersionRefOptions; +import io.seqera.tower.cli.exceptions.TowerException; +import io.seqera.tower.cli.responses.Response; +import io.seqera.tower.cli.responses.pipelines.PipelinesSchema; +import io.seqera.tower.model.PipelineDbDto; +import io.seqera.tower.model.PipelineSchemaAttributes; +import io.seqera.tower.model.PipelineSchemaResponse; +import picocli.CommandLine; +import picocli.CommandLine.Command; +import picocli.CommandLine.Option; + +import java.util.List; + +@Command( + name = "schema", + description = "Display the parameter schema of a pipeline" +) +public class SchemaCmd extends AbstractPipelinesCmd { + + @CommandLine.Mixin + PipelineRefOptions pipelineRefOptions; + + @CommandLine.Mixin + public WorkspaceOptionalOptions workspace; + + // Explicit "0..1" for clarity — contrasts with the required "1" in VersionRefOptions. @Mixin won't work here as it would lose mutual exclusivity. + @CommandLine.ArgGroup(multiplicity = "0..1") + public VersionRefOptions.VersionRef versionRef; + + @Option(names = {"--params"}, description = "Display the parameter values the pipeline launches with (pipeline defaults merged with the launch configuration) instead of the schema.") + public boolean params; + + @Override + protected Response exec() throws ApiException { + Long wspId = workspaceId(workspace.workspace); + PipelineDbDto pipeline = fetchPipeline(pipelineRefOptions, wspId); + String versionId = resolvePipelineVersionId(pipeline.getPipelineId(), wspId, versionRef); + PipelineSchemaAttributes attribute = params ? PipelineSchemaAttributes.params : PipelineSchemaAttributes.schema; + + // Platform answers 204 when it cannot resolve a schema, which the SDK returns as null. + PipelineSchemaResponse response = pipelinesApi().describePipelineSchema( + pipeline.getPipelineId(), wspId, sourceWorkspaceId(wspId, pipeline), List.of(attribute), versionId); + String content = response == null ? null : params ? response.getParams() : response.getSchema(); + if (content == null) { + throw new TowerException(String.format("No %s found for pipeline '%s'", attribute, pipeline.getName())); + } + + return new PipelinesSchema(attribute, content); + } +} diff --git a/src/main/java/io/seqera/tower/cli/commands/pipelines/UpdateCmd.java b/src/main/java/io/seqera/tower/cli/commands/pipelines/UpdateCmd.java index edb22f3af..c968dcea1 100644 --- a/src/main/java/io/seqera/tower/cli/commands/pipelines/UpdateCmd.java +++ b/src/main/java/io/seqera/tower/cli/commands/pipelines/UpdateCmd.java @@ -25,8 +25,10 @@ import io.seqera.tower.cli.utils.FilesHelper; import io.seqera.tower.cli.utils.ModelHelper; import io.seqera.tower.cli.utils.VersionNameHelper; +import io.seqera.tower.model.LabelDbDto; import io.seqera.tower.model.LaunchDbDto; import io.seqera.tower.model.PipelineDbDto; +import io.seqera.tower.model.PipelineQueryAttribute; import io.seqera.tower.model.PipelineVersionFullInfoDto; import io.seqera.tower.model.PipelineVersionManageRequest; import io.seqera.tower.model.UpdatePipelineRequest; @@ -82,7 +84,8 @@ protected Response exec() throws ApiException, IOException { Long wspId = workspaceId(workspace.workspace); // Fetch uses describePipeline, which returns the default version on pipe.getVersion() - PipelineDbDto pipe = fetchPipeline(pipelineRefOptions, wspId); + // Labels are fetched only to be sent back: the update replaces the pipeline's labels with the given ones. + PipelineDbDto pipe = fetchPipeline(pipelineRefOptions, wspId, PipelineQueryAttribute.labels); // If the user wants to rename the pipeline (--new-name), validate early before making any // changes. The server checks uniqueness within the workspace/org scope. @@ -212,6 +215,7 @@ private UpdatePipelineRequest buildUpdateRequest(PipelineDbDto pipe, LaunchDbDto .configProfiles(coalesce(opts.profile, launch.getConfigProfiles())) .paramsText(coalesce(FilesHelper.readString(opts.paramsFile), launch.getParamsText())) .configText(coalesce(FilesHelper.readString(opts.config), launch.getConfigText())) + .towerConfig(coalesce(FilesHelper.readString(opts.towerConfig), launch.getTowerConfig())) .preRunScript(coalesce(FilesHelper.readString(opts.preRunScript), launch.getPreRunScript())) .postRunScript(coalesce(FilesHelper.readString(opts.postRunScript), launch.getPostRunScript())) .pullLatest(coalesce(opts.pullLatest, launch.getPullLatest())) @@ -228,6 +232,8 @@ private UpdatePipelineRequest buildUpdateRequest(PipelineDbDto pipe, LaunchDbDto return new UpdatePipelineRequest() .name(coalesce(newName, pipe.getName())) .description(coalesce(description, pipe.getDescription())) + .icon(pipe.getIcon()) + .labelIds(pipe.getLabels() == null ? null : pipe.getLabels().stream().map(LabelDbDto::getId).toList()) .launch(launchRequest); } diff --git a/src/main/java/io/seqera/tower/cli/commands/runs/DeleteCmd.java b/src/main/java/io/seqera/tower/cli/commands/runs/DeleteCmd.java index b89305ec4..1547f1d8e 100644 --- a/src/main/java/io/seqera/tower/cli/commands/runs/DeleteCmd.java +++ b/src/main/java/io/seqera/tower/cli/commands/runs/DeleteCmd.java @@ -14,24 +14,29 @@ * limitations under the License. */ + package io.seqera.tower.cli.commands.runs; 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.runs.RunDeleted; +import io.seqera.tower.cli.responses.runs.RunsDeleted; +import io.seqera.tower.model.DeleteWorkflowsRequest; import picocli.CommandLine; import java.io.IOException; +import java.util.List; +import java.util.Objects; @CommandLine.Command( name = "delete", - description = "Delete a pipeline run" + description = "Delete one or more pipeline runs" ) public class DeleteCmd extends AbstractRunsCmd { - @CommandLine.Option(names = {"-i", "-id"}, description = "Pipeline run identifier. The unique workflow ID to delete. Deletes the run record and associated metadata from Seqera Platform.", required = true) - public String id; + @CommandLine.Option(names = {"-i", "-id"}, split = ",", description = "Pipeline run identifier. The unique workflow ID to delete. Deletes the run record and associated metadata from Seqera Platform. Repeat the option or provide a comma-separated list to delete several runs at once.", required = true) + public List ids; @CommandLine.Option(names = {"--force"}, description = "Force deletion of active workflows. By default, only completed workflows can be deleted. Use this flag to delete running or pending workflows.") public boolean force = false; @@ -42,8 +47,17 @@ public class DeleteCmd extends AbstractRunsCmd { @Override protected Response exec() throws ApiException, IOException { Long wspId = workspaceId(workspace.workspace); - - workflowsApi().deleteWorkflow(id, wspId, force); - return new RunDeleted(id, workspaceRef(wspId)); + + if (ids.size() == 1) { + workflowsApi().deleteWorkflow(ids.getFirst(), wspId, force); + return new RunDeleted(ids.getFirst(), workspaceRef(wspId)); + } + + // The bulk endpoint reports the runs it could not delete instead of failing the whole request. + List failed = Objects.requireNonNullElse(workflowsApi() + .deleteWorkflowMany(new DeleteWorkflowsRequest().workflowIds(ids), wspId, force) + .getFailedWorkflowIds(), List.of()); + List deleted = ids.stream().filter(id -> !failed.contains(id)).toList(); + return new RunsDeleted(deleted, failed, workspaceRef(wspId)); } } diff --git a/src/main/java/io/seqera/tower/cli/commands/runs/RelaunchCmd.java b/src/main/java/io/seqera/tower/cli/commands/runs/RelaunchCmd.java index 6dc8a7e73..ea0131ed9 100644 --- a/src/main/java/io/seqera/tower/cli/commands/runs/RelaunchCmd.java +++ b/src/main/java/io/seqera/tower/cli/commands/runs/RelaunchCmd.java @@ -101,6 +101,7 @@ protected Response exec() throws ApiException, IOException { .revision(coalesce(opts.revision, (noResume ? launch.getRevision() : launch.getResumeCommitId()))) .configProfiles(coalesce(opts.profile, launch.getConfigProfiles())) .configText(opts.config != null ? FilesHelper.readString(opts.config) : launch.getConfigText()) + .towerConfig(opts.towerConfig != null ? FilesHelper.readString(opts.towerConfig) : launch.getTowerConfig()) .paramsText(opts.paramsFile != null ? FilesHelper.readString(opts.paramsFile) : launch.getParamsText()) .preRunScript(opts.preRunScript != null ? FilesHelper.readString(opts.preRunScript) : launch.getPreRunScript()) .postRunScript(opts.postRunScript != null ? FilesHelper.readString(opts.postRunScript) : launch.getPostRunScript()) diff --git a/src/main/java/io/seqera/tower/cli/commands/runs/StarCmd.java b/src/main/java/io/seqera/tower/cli/commands/runs/StarCmd.java new file mode 100644 index 000000000..d4a4cdbb8 --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/commands/runs/StarCmd.java @@ -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.runs; + +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.runs.RunStarred; +import picocli.CommandLine; + +@CommandLine.Command( + name = "star", + description = "Star a pipeline run" +) +public class StarCmd extends AbstractRunsCmd { + + @CommandLine.Option(names = {"-i", "--id"}, description = "Pipeline run identifier. The unique workflow ID to add to your starred runs.", required = true) + public String id; + + @CommandLine.Mixin + public WorkspaceOptionalOptions workspace; + + @Override + protected Response exec() throws ApiException { + Long wspId = workspaceId(workspace.workspace); + + workflowsApi().createWorkflowStar(id, wspId); + return new RunStarred(id, workspaceRef(wspId), true); + } +} diff --git a/src/main/java/io/seqera/tower/cli/commands/runs/UnstarCmd.java b/src/main/java/io/seqera/tower/cli/commands/runs/UnstarCmd.java new file mode 100644 index 000000000..7f70ab304 --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/commands/runs/UnstarCmd.java @@ -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.runs; + +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.runs.RunStarred; +import picocli.CommandLine; + +@CommandLine.Command( + name = "unstar", + description = "Unstar a pipeline run" +) +public class UnstarCmd extends AbstractRunsCmd { + + @CommandLine.Option(names = {"-i", "--id"}, description = "Pipeline run identifier. The unique workflow ID to remove from your starred runs.", required = true) + public String id; + + @CommandLine.Mixin + public WorkspaceOptionalOptions workspace; + + @Override + protected Response exec() throws ApiException { + Long wspId = workspaceId(workspace.workspace); + + workflowsApi().deleteWorkflowStar(id, wspId); + return new RunStarred(id, workspaceRef(wspId), false); + } +} diff --git a/src/main/java/io/seqera/tower/cli/commands/runs/ViewCmd.java b/src/main/java/io/seqera/tower/cli/commands/runs/ViewCmd.java index 0f87553ed..767b1f4da 100644 --- a/src/main/java/io/seqera/tower/cli/commands/runs/ViewCmd.java +++ b/src/main/java/io/seqera/tower/cli/commands/runs/ViewCmd.java @@ -19,6 +19,7 @@ import io.seqera.tower.ApiException; import io.seqera.tower.cli.commands.global.WorkspaceOptionalOptions; import io.seqera.tower.cli.commands.runs.download.DownloadCmd; +import io.seqera.tower.cli.commands.runs.log.LogCmd; import io.seqera.tower.cli.commands.runs.metrics.MetricsCmd; import io.seqera.tower.cli.commands.runs.tasks.TaskCmd; import io.seqera.tower.cli.commands.runs.tasks.TasksCmd; @@ -48,6 +49,7 @@ description = "View pipeline run details", subcommands = { DownloadCmd.class, + LogCmd.class, MetricsCmd.class, TasksCmd.class, TaskCmd.class, diff --git a/src/main/java/io/seqera/tower/cli/commands/runs/log/LogCmd.java b/src/main/java/io/seqera/tower/cli/commands/runs/log/LogCmd.java new file mode 100644 index 000000000..2dddfe29a --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/commands/runs/log/LogCmd.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.commands.runs.log; + +import io.seqera.tower.ApiException; +import io.seqera.tower.cli.commands.runs.AbstractRunsCmd; +import io.seqera.tower.cli.commands.runs.ViewCmd; +import io.seqera.tower.cli.responses.Response; +import io.seqera.tower.cli.responses.runs.RunLog; +import io.seqera.tower.model.LogPage; +import picocli.CommandLine; + +@CommandLine.Command( + name = "log", + description = "Display the execution log of a pipeline run or task" +) +public class LogCmd extends AbstractRunsCmd { + + @CommandLine.Option(names = {"-t"}, description = "Task numeric identifier. When specified, displays the task output log instead of the Nextflow head job output.") + public Long task; + + @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.ParentCommand + ViewCmd parentCommand; + + @Override + protected Response exec() throws ApiException { + Long wspId = workspaceId(parentCommand.workspace.workspace); + + // Platform serves the compute platform's live log stream when it has one, so unlike 'download' this + // also works while the run is active; otherwise it falls back to the output file. + LogPage page = task == null + ? workflowsApi().getWorkflowLog(parentCommand.id, wspId, next, null).getLog() + : workflowsApi().getWorkflowTaskLog(parentCommand.id, task, next, wspId, null).getLog(); + + // Live streams (e.g. CloudWatch) return one page per call without flagging truncation; an empty page means + // the end of what is available, even though the stream keeps returning a cursor + boolean hasEntries = page.getEntries() != null && !page.getEntries().isEmpty(); + String nextPage = hasEntries ? page.getForwardToken() : null; + return new RunLog(page.getEntries(), Boolean.TRUE.equals(page.getTruncated()), page.getMessage(), nextPage); + } +} diff --git a/src/main/java/io/seqera/tower/cli/commands/studios/AbstractStudiosCmd.java b/src/main/java/io/seqera/tower/cli/commands/studios/AbstractStudiosCmd.java index bc34037a2..093eca991 100644 --- a/src/main/java/io/seqera/tower/cli/commands/studios/AbstractStudiosCmd.java +++ b/src/main/java/io/seqera/tower/cli/commands/studios/AbstractStudiosCmd.java @@ -17,9 +17,11 @@ package io.seqera.tower.cli.commands.studios; import java.util.Collections; +import java.util.HashSet; import java.util.List; import java.util.Objects; import java.util.Optional; +import java.util.Set; import java.util.function.Supplier; import io.seqera.tower.ApiException; @@ -220,6 +222,7 @@ public class ProgressStepMessageSupplier implements Supplier { private final String sessionId; private final Long workspaceId; private DataStudioProgressStep currentProgressStep; + private final Set printedWarnings = new HashSet<>(); public ProgressStepMessageSupplier(String sessionId, Long workspaceId) { this.sessionId = sessionId; @@ -231,22 +234,35 @@ public ProgressStepMessageSupplier(String sessionId, Long workspaceId) { public String get() { try { DataStudioDto studioDto = studiosApi().describeDataStudio(sessionId, workspaceId); + StringBuilder messages = new StringBuilder(); Optional inProgressStep = studioDto.getProgress().stream() .filter(step -> step.getStatus() == IN_PROGRESS || step.getStatus() == ERRORED) .findFirst(); - if (inProgressStep.isPresent() && !inProgressStep.get().equals(currentProgressStep)) { + // Compare without warnings: a step gaining a warning is still the same step. + if (inProgressStep.isPresent() && !isSameStep(inProgressStep.get(), currentProgressStep)) { currentProgressStep = inProgressStep.get(); - return currentProgressStep.getStatus() != ERRORED + messages.append(currentProgressStep.getStatus() != ERRORED ? String.format("\n %s", currentProgressStep.getMessage()) - : String.format("\n %s - Error encountered: %s", currentProgressStep.getMessage(), studioDto.getStatusInfo().getMessage()); + : String.format("\n %s - Error encountered: %s", currentProgressStep.getMessage(), studioDto.getStatusInfo().getMessage())); } - return ""; + // Warnings can also be attached to steps that already succeeded. + studioDto.getProgress().stream() + .filter(step -> step.getWarnings() != null) + .flatMap(step -> step.getWarnings().stream()) + .filter(printedWarnings::add) + .forEach(warning -> messages.append(String.format("\n Warning: %s", warning))); + + return messages.toString(); } catch (Exception e) { return ""; } } + + private boolean isSameStep(DataStudioProgressStep a, DataStudioProgressStep b) { + return a.getStatus() == b.getStatus() && Objects.equals(a.getMessage(), b.getMessage()); + } } } diff --git a/src/main/java/io/seqera/tower/cli/commands/studios/ExtendCmd.java b/src/main/java/io/seqera/tower/cli/commands/studios/ExtendCmd.java new file mode 100644 index 000000000..a2a26d9cd --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/commands/studios/ExtendCmd.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.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.StudioLifespanExtended; +import io.seqera.tower.model.DataStudioDto; +import picocli.CommandLine; + +@CommandLine.Command( + name = "extend", + description = "Extend the lifespan of a running studio that is approaching its scheduled auto-stop." +) +public class ExtendCmd extends AbstractStudiosCmd { + + @CommandLine.Mixin + public WorkspaceOptionalOptions workspace; + + @CommandLine.Mixin + public StudioRefOptions studioRefOptions; + + @Override + protected Response exec() throws ApiException { + Long wspId = workspaceId(workspace.workspace); + String sessionId = getSessionId(studioRefOptions, wspId); + + DataStudioDto studio = studiosApi().extendDataStudioLifespan(sessionId, wspId); + + return new StudioLifespanExtended(studioRefOptions.getStudioIdentifier(), workspaceRef(wspId), studio.getEffectiveLifespanHours()); + } +} diff --git a/src/main/java/io/seqera/tower/cli/commands/studios/RenameCheckpointCmd.java b/src/main/java/io/seqera/tower/cli/commands/studios/RenameCheckpointCmd.java new file mode 100644 index 000000000..f72a088e4 --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/commands/studios/RenameCheckpointCmd.java @@ -0,0 +1,56 @@ +/* + * 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.StudioCheckpointUpdated; +import io.seqera.tower.model.DataStudioCheckpointDto; +import io.seqera.tower.model.DataStudioCheckpointUpdateRequest; +import picocli.CommandLine; + +@CommandLine.Command( + name = "rename-checkpoint", + description = "Rename a studio checkpoint." +) +public class RenameCheckpointCmd extends AbstractStudiosCmd { + + @CommandLine.Mixin + public WorkspaceOptionalOptions workspace; + + @CommandLine.Mixin + public StudioRefOptions studioRefOptions; + + @CommandLine.Option(names = {"--checkpoint-id"}, description = "Checkpoint numeric identifier, as listed by the 'checkpoints' command.", required = true) + public Long checkpointId; + + @CommandLine.Option(names = {"--new-name"}, description = "New checkpoint name. Must be unique per studio.", required = true) + public String newName; + + @Override + protected Response exec() throws ApiException { + Long wspId = workspaceId(workspace.workspace); + String sessionId = getSessionId(studioRefOptions, wspId); + + DataStudioCheckpointUpdateRequest request = new DataStudioCheckpointUpdateRequest(); + request.setName(newName); + DataStudioCheckpointDto checkpoint = studiosApi().updateDataStudioCheckpoint(sessionId, checkpointId, request, wspId); + + return new StudioCheckpointUpdated(studioRefOptions.getStudioIdentifier(), workspaceRef(wspId), checkpoint.getId(), checkpoint.getName()); + } +} diff --git a/src/main/java/io/seqera/tower/cli/commands/teams/AbstractTeamsCmd.java b/src/main/java/io/seqera/tower/cli/commands/teams/AbstractTeamsCmd.java index b736603df..973d7ef64 100644 --- a/src/main/java/io/seqera/tower/cli/commands/teams/AbstractTeamsCmd.java +++ b/src/main/java/io/seqera/tower/cli/commands/teams/AbstractTeamsCmd.java @@ -52,7 +52,7 @@ public MemberDbDto findMemberByUsername(Long orgId, Long teamId, String username } public TeamDbDto findTeamByName(Long orgId, String teamName) throws ApiException { - ListTeamResponse listTeamResponse = teamsApi().listOrganizationTeams(orgId, null, null, null); + ListTeamResponse listTeamResponse = teamsApi().listOrganizationTeams(orgId, null, null, teamName); if (listTeamResponse == null) { throw new TeamNotFoundException(orgId, teamName); @@ -66,6 +66,11 @@ public TeamDbDto findTeamByName(Long orgId, String teamName) throws ApiException .orElseThrow(() -> new TeamNotFoundException(orgId, teamName)); } + protected TeamDbDto fetchTeam(Long orgId, TeamRefOptions ref) throws ApiException { + Long teamId = ref.team.id != null ? ref.team.id : findTeamByName(orgId, ref.team.name).getTeamId(); + return teamsApi().describeOrganizationTeam(orgId, teamId).getTeam(); + } + public void deleteTeamById(Long teamId, String orgRef) throws OrganizationNotFoundException, ApiException { OrgAndWorkspaceDto orgAndWorkspaceDbDto = findOrganizationByRef(orgRef); deleteTeamById(teamId, orgAndWorkspaceDbDto.getOrgId()); diff --git a/src/main/java/io/seqera/tower/cli/commands/teams/TeamRefOptions.java b/src/main/java/io/seqera/tower/cli/commands/teams/TeamRefOptions.java new file mode 100644 index 000000000..d5ea52187 --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/commands/teams/TeamRefOptions.java @@ -0,0 +1,35 @@ +/* + * 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.teams; + +import picocli.CommandLine.ArgGroup; +import picocli.CommandLine.Option; + +public class TeamRefOptions { + + @ArgGroup(multiplicity = "1") + public TeamRef team; + + public static class TeamRef { + + @Option(names = {"-i", "--id"}, description = "Team numeric identifier. Find team IDs using 'tw teams list'.") + public Long id; + + @Option(names = {"-n", "--name"}, description = "Team name.") + public String name; + } +} diff --git a/src/main/java/io/seqera/tower/cli/commands/teams/UpdateCmd.java b/src/main/java/io/seqera/tower/cli/commands/teams/UpdateCmd.java new file mode 100644 index 000000000..22da709d9 --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/commands/teams/UpdateCmd.java @@ -0,0 +1,66 @@ +/* + * 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.teams; + +import io.seqera.tower.ApiException; +import io.seqera.tower.cli.responses.Response; +import io.seqera.tower.cli.responses.teams.TeamUpdated; +import io.seqera.tower.model.TeamDbDto; +import io.seqera.tower.model.UpdateTeamRequest; +import picocli.CommandLine.Command; +import picocli.CommandLine.Mixin; +import picocli.CommandLine.Option; + +@Command( + name = "update", + description = "Update a team" +) +public class UpdateCmd extends AbstractTeamsCmd { + + @Option(names = {"-o", "--organization"}, description = "Organization name or numeric ID. Specify either the unique organization name or the numeric organization ID returned by 'tw organizations list'.", required = true) + public String organizationRef; + + @Mixin + TeamRefOptions ref; + + @Option(names = {"--new-name"}, description = "New team name. Must be unique within the organization.") + public String newName; + + @Option(names = {"-d", "--description"}, description = "New team description.") + public String description; + + @Override + protected Response exec() throws ApiException { + Long orgId = findOrganizationByRef(organizationRef).getOrgId(); + TeamDbDto team = fetchTeam(orgId, ref); + + // The API replaces name, description and avatar, so unset options keep the current values + UpdateTeamRequest request = new UpdateTeamRequest() + .name(newName != null ? newName : team.getName()) + .description(description != null ? description : team.getDescription()) + .avatarId(avatarId(team.getAvatarUrl())); + + teamsApi().updateOrganizationTeam(orgId, team.getTeamId(), request); + + return new TeamUpdated(organizationRef, request.getName()); + } + + // The team response only exposes the avatar download URL, which ends with the avatar ID + private static String avatarId(String avatarUrl) { + return avatarUrl == null ? null : avatarUrl.substring(avatarUrl.lastIndexOf('/') + 1); + } +} diff --git a/src/main/java/io/seqera/tower/cli/commands/teams/ViewCmd.java b/src/main/java/io/seqera/tower/cli/commands/teams/ViewCmd.java new file mode 100644 index 000000000..f5664118d --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/commands/teams/ViewCmd.java @@ -0,0 +1,43 @@ +/* + * 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.teams; + +import io.seqera.tower.ApiException; +import io.seqera.tower.cli.responses.Response; +import io.seqera.tower.cli.responses.teams.TeamView; +import picocli.CommandLine.Command; +import picocli.CommandLine.Mixin; +import picocli.CommandLine.Option; + +@Command( + name = "view", + description = "View team details" +) +public class ViewCmd extends AbstractTeamsCmd { + + @Option(names = {"-o", "--organization"}, description = "Organization name or numeric ID. Specify either the unique organization name or the numeric organization ID returned by 'tw organizations list'.", required = true) + public String organizationRef; + + @Mixin + TeamRefOptions ref; + + @Override + protected Response exec() throws ApiException { + Long orgId = findOrganizationByRef(organizationRef).getOrgId(); + return new TeamView(organizationRef, fetchTeam(orgId, ref)); + } +} diff --git a/src/main/java/io/seqera/tower/cli/commands/teams/WorkspacesCmd.java b/src/main/java/io/seqera/tower/cli/commands/teams/WorkspacesCmd.java new file mode 100644 index 000000000..4d1884feb --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/commands/teams/WorkspacesCmd.java @@ -0,0 +1,57 @@ +/* + * 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.teams; + +import io.seqera.tower.ApiException; +import io.seqera.tower.cli.commands.global.PaginationOptions; +import io.seqera.tower.cli.responses.Response; +import io.seqera.tower.cli.responses.teams.TeamWorkspacesList; +import io.seqera.tower.cli.utils.PaginationInfo; +import io.seqera.tower.model.ListWorkspacesByTeamResponse; +import io.seqera.tower.model.TeamDbDto; +import picocli.CommandLine.Command; +import picocli.CommandLine.Mixin; +import picocli.CommandLine.Option; + +@Command( + name = "workspaces", + description = "List the workspaces a team participates in" +) +public class WorkspacesCmd extends AbstractTeamsCmd { + + @Option(names = {"-o", "--organization"}, description = "Organization name or numeric ID. Specify either the unique organization name or the numeric organization ID returned by 'tw organizations list'.", required = true) + public String organizationRef; + + @Mixin + TeamRefOptions ref; + + @Mixin + PaginationOptions paginationOptions; + + @Override + protected Response exec() throws ApiException { + Integer max = PaginationOptions.getMax(paginationOptions); + Integer offset = PaginationOptions.getOffset(paginationOptions, max); + + Long orgId = findOrganizationByRef(organizationRef).getOrgId(); + TeamDbDto team = fetchTeam(orgId, ref); + + ListWorkspacesByTeamResponse response = teamsApi().listWorkspacesByTeam(orgId, team.getTeamId(), max, offset, null); + + return new TeamWorkspacesList(organizationRef, team.getName(), response.getWorkspaces(), PaginationInfo.from(paginationOptions, response.getTotalSize())); + } +} diff --git a/src/main/java/io/seqera/tower/cli/exceptions/AccessTokenNotFoundException.java b/src/main/java/io/seqera/tower/cli/exceptions/AccessTokenNotFoundException.java new file mode 100644 index 000000000..f4e69fb22 --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/exceptions/AccessTokenNotFoundException.java @@ -0,0 +1,25 @@ +/* + * 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.exceptions; + +public class AccessTokenNotFoundException extends TowerException { + + public AccessTokenNotFoundException(String tokenRef) { + super(String.format("Unknown access token '%s'", tokenRef)); + } +} diff --git a/src/main/java/io/seqera/tower/cli/exceptions/ManagedIdentityNotFoundException.java b/src/main/java/io/seqera/tower/cli/exceptions/ManagedIdentityNotFoundException.java new file mode 100644 index 000000000..f0bf8beea --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/exceptions/ManagedIdentityNotFoundException.java @@ -0,0 +1,25 @@ +/* + * 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.exceptions; + +public class ManagedIdentityNotFoundException extends TowerException { + + public ManagedIdentityNotFoundException(String managedIdentityRef, Long organizationId) { + super(String.format("Managed identity '%s' not found in organization '%d'", managedIdentityRef, organizationId)); + } +} diff --git a/src/main/java/io/seqera/tower/cli/responses/accesstokens/AccessTokenAdded.java b/src/main/java/io/seqera/tower/cli/responses/accesstokens/AccessTokenAdded.java new file mode 100644 index 000000000..b75546019 --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/responses/accesstokens/AccessTokenAdded.java @@ -0,0 +1,38 @@ +/* + * 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.accesstokens; + +import io.seqera.tower.cli.responses.Response; + +public class AccessTokenAdded extends Response { + + public final Long id; + public final String name; + public final String accessToken; + + public AccessTokenAdded(Long id, String name, String accessToken) { + this.id = id; + this.name = name; + this.accessToken = accessToken; + } + + @Override + public String toString() { + return ansi(String.format("%n @|yellow New access token '%s' (%d) added.|@ Copy it now, it is not shown again:%n%n %s%n", name, id, accessToken)); + } +} diff --git a/src/main/java/io/seqera/tower/cli/responses/accesstokens/AccessTokenDeleted.java b/src/main/java/io/seqera/tower/cli/responses/accesstokens/AccessTokenDeleted.java new file mode 100644 index 000000000..b9d913914 --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/responses/accesstokens/AccessTokenDeleted.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.accesstokens; + +import io.seqera.tower.cli.responses.Response; + +public class AccessTokenDeleted extends Response { + + public final Long id; + public final String name; + + public AccessTokenDeleted(Long id, String name) { + this.id = id; + this.name = name; + } + + @Override + public String toString() { + return ansi(String.format("%n @|yellow Access token '%s' (%d) deleted|@%n", name, id)); + } +} diff --git a/src/main/java/io/seqera/tower/cli/responses/accesstokens/AccessTokensList.java b/src/main/java/io/seqera/tower/cli/responses/accesstokens/AccessTokensList.java new file mode 100644 index 000000000..ec3886bb5 --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/responses/accesstokens/AccessTokensList.java @@ -0,0 +1,56 @@ +/* + * 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.accesstokens; + +import io.seqera.tower.cli.responses.Response; +import io.seqera.tower.cli.utils.TableList; +import io.seqera.tower.model.AccessToken; + +import java.io.PrintWriter; +import java.util.List; + +import static io.seqera.tower.cli.utils.FormatHelper.formatDate; + +public class AccessTokensList extends Response { + + public final List tokens; + + public AccessTokensList(List tokens) { + this.tokens = tokens; + } + + @Override + public void toString(PrintWriter out) { + out.println(ansi(String.format("%n @|bold Personal access tokens:|@%n"))); + if (tokens == null || tokens.isEmpty()) { + out.println(ansi(" @|yellow No access tokens found|@")); + return; + } + + TableList table = new TableList(out, 4, "ID", "Name", "Created", "Last used").sortBy(0); + table.setPrefix(" "); + tokens.forEach(token -> table.addRow( + token.getId().toString(), + token.getName(), + formatDate(token.getDateCreated()), + formatDate(token.getLastUsed()) + )); + table.print(); + out.println(""); + } +} diff --git a/src/main/java/io/seqera/tower/cli/responses/computeenvs/ComputeEnvDisabled.java b/src/main/java/io/seqera/tower/cli/responses/computeenvs/ComputeEnvDisabled.java new file mode 100644 index 000000000..87e8bf3a7 --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/responses/computeenvs/ComputeEnvDisabled.java @@ -0,0 +1,35 @@ +/* + * 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.computeenvs; + +import io.seqera.tower.cli.responses.Response; + +public class ComputeEnvDisabled extends Response { + + public final String id; + public final String workspaceRef; + + public ComputeEnvDisabled(String id, String workspaceRef) { + this.id = id; + this.workspaceRef = workspaceRef; + } + + @Override + public String toString() { + return ansi(String.format("%n @|yellow Compute environment '%s' disabled at %s workspace|@%n", id, workspaceRef)); + } +} diff --git a/src/main/java/io/seqera/tower/cli/responses/computeenvs/ComputeEnvEnabled.java b/src/main/java/io/seqera/tower/cli/responses/computeenvs/ComputeEnvEnabled.java new file mode 100644 index 000000000..7bfaf7404 --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/responses/computeenvs/ComputeEnvEnabled.java @@ -0,0 +1,35 @@ +/* + * 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.computeenvs; + +import io.seqera.tower.cli.responses.Response; + +public class ComputeEnvEnabled extends Response { + + public final String id; + public final String workspaceRef; + + public ComputeEnvEnabled(String id, String workspaceRef) { + this.id = id; + this.workspaceRef = workspaceRef; + } + + @Override + public String toString() { + return ansi(String.format("%n @|yellow Compute environment '%s' enabled at %s workspace|@%n", id, workspaceRef)); + } +} diff --git a/src/main/java/io/seqera/tower/cli/responses/data/DataLinkContentDeleted.java b/src/main/java/io/seqera/tower/cli/responses/data/DataLinkContentDeleted.java new file mode 100644 index 000000000..95ca3905b --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/responses/data/DataLinkContentDeleted.java @@ -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.responses.data; + +import io.seqera.tower.cli.responses.Response; + +import java.util.List; + +public class DataLinkContentDeleted extends Response { + + public final String id; + public final Long wspId; + public final List files; + public final List dirs; + + public DataLinkContentDeleted(String id, Long wspId, List files, List dirs) { + this.id = id; + this.wspId = wspId; + this.files = files; + this.dirs = dirs; + } + + @Override + public String toString() { + int count = (files == null ? 0 : files.size()) + (dirs == null ? 0 : dirs.size()); + if (wspId != null) { + return ansi(String.format("%n @|yellow %d items deleted from data link '%s' at '%s' workspace.|@%n", count, id, wspId)); + } + return ansi(String.format("%n @|yellow %d items deleted from data link '%s' at user workspace.|@%n", count, id)); + } +} diff --git a/src/main/java/io/seqera/tower/cli/responses/data/DataLinkDownloadScript.java b/src/main/java/io/seqera/tower/cli/responses/data/DataLinkDownloadScript.java new file mode 100644 index 000000000..62838aa24 --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/responses/data/DataLinkDownloadScript.java @@ -0,0 +1,33 @@ +/* + * 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.data; + +import io.seqera.tower.cli.responses.Response; + +public class DataLinkDownloadScript extends Response { + + public final String script; + + public DataLinkDownloadScript(String script) { + this.script = script; + } + + @Override + public String toString() { + return script; + } +} diff --git a/src/main/java/io/seqera/tower/cli/responses/datasets/DatasetVersionDisabled.java b/src/main/java/io/seqera/tower/cli/responses/datasets/DatasetVersionDisabled.java new file mode 100644 index 000000000..6e7936bf4 --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/responses/datasets/DatasetVersionDisabled.java @@ -0,0 +1,37 @@ +/* + * 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.datasets; + +import io.seqera.tower.cli.responses.Response; + +public class DatasetVersionDisabled extends Response { + + public final String datasetRef; + public final Long version; + public final String workspaceRef; + + public DatasetVersionDisabled(String datasetRef, Long version, String workspaceRef) { + this.datasetRef = datasetRef; + this.version = version; + this.workspaceRef = workspaceRef; + } + + @Override + public String toString() { + return ansi(String.format("%n @|yellow Version %d of dataset '%s' disabled at %s workspace|@%n", version, datasetRef, workspaceRef)); + } +} diff --git a/src/main/java/io/seqera/tower/cli/responses/datasets/DatasetVersionsList.java b/src/main/java/io/seqera/tower/cli/responses/datasets/DatasetVersionsList.java index 2b77d8232..b978669a4 100644 --- a/src/main/java/io/seqera/tower/cli/responses/datasets/DatasetVersionsList.java +++ b/src/main/java/io/seqera/tower/cli/responses/datasets/DatasetVersionsList.java @@ -44,9 +44,9 @@ public void toString(PrintWriter out) { return; } - TableList table = new TableList(out, 5, "Version", "Has Header", "Media Type", "File Name", "URL").sortBy(0); + TableList table = new TableList(out, 6, "Version", "Has Header", "Media Type", "File Name", "URL", "Disabled").sortBy(0); table.setPrefix(" "); - versions.forEach(v -> table.addRow(v.getVersion().toString(), v.getHasHeader().toString(), v.getMediaType(), v.getFileName(), v.getUrl())); + versions.forEach(v -> table.addRow(v.getVersion().toString(), v.getHasHeader().toString(), v.getMediaType(), v.getFileName(), v.getUrl(), String.valueOf(Boolean.TRUE.equals(v.getDisabled())))); table.print(); out.println(""); diff --git a/src/main/java/io/seqera/tower/cli/responses/managedidentities/ManagedCredentialsAdded.java b/src/main/java/io/seqera/tower/cli/responses/managedidentities/ManagedCredentialsAdded.java new file mode 100644 index 000000000..ab3cdd601 --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/responses/managedidentities/ManagedCredentialsAdded.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.managedidentities; + +import io.seqera.tower.cli.responses.Response; + +public class ManagedCredentialsAdded extends Response { + + public final String managedIdentityName; + public final Long managedCredentialsId; + + public ManagedCredentialsAdded(String managedIdentityName, Long managedCredentialsId) { + this.managedIdentityName = managedIdentityName; + this.managedCredentialsId = managedCredentialsId; + } + + @Override + public String toString() { + return ansi(String.format("%n @|yellow New SSH credentials (%d) added to managed identity '%s'|@%n", managedCredentialsId, managedIdentityName)); + } +} diff --git a/src/main/java/io/seqera/tower/cli/responses/managedidentities/ManagedCredentialsDeleted.java b/src/main/java/io/seqera/tower/cli/responses/managedidentities/ManagedCredentialsDeleted.java new file mode 100644 index 000000000..ceaea9a8f --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/responses/managedidentities/ManagedCredentialsDeleted.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.managedidentities; + +import io.seqera.tower.cli.responses.Response; + +public class ManagedCredentialsDeleted extends Response { + + public final String managedIdentityName; + public final Long managedCredentialsId; + + public ManagedCredentialsDeleted(String managedIdentityName, Long managedCredentialsId) { + this.managedIdentityName = managedIdentityName; + this.managedCredentialsId = managedCredentialsId; + } + + @Override + public String toString() { + return ansi(String.format("%n @|yellow SSH credentials (%d) deleted from managed identity '%s'|@%n", managedCredentialsId, managedIdentityName)); + } +} diff --git a/src/main/java/io/seqera/tower/cli/responses/managedidentities/ManagedCredentialsList.java b/src/main/java/io/seqera/tower/cli/responses/managedidentities/ManagedCredentialsList.java new file mode 100644 index 000000000..34dde25dc --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/responses/managedidentities/ManagedCredentialsList.java @@ -0,0 +1,58 @@ +/* + * 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.managedidentities; + +import io.seqera.tower.cli.responses.Response; +import io.seqera.tower.cli.utils.TableList; +import io.seqera.tower.model.ListManagedCredentialsRespDto; + +import java.io.PrintWriter; +import java.util.List; + +public class ManagedCredentialsList extends Response { + + public final String organizationRef; + public final String managedIdentityName; + public final List managedCredentials; + + public ManagedCredentialsList(String organizationRef, String managedIdentityName, List managedCredentials) { + this.organizationRef = organizationRef; + this.managedIdentityName = managedIdentityName; + this.managedCredentials = managedCredentials; + } + + @Override + public void toString(PrintWriter out) { + out.println(ansi(String.format("%n @|bold Credentials of managed identity '%s' at %s organization:|@%n", managedIdentityName, organizationRef))); + if (managedCredentials == null || managedCredentials.isEmpty()) { + out.println(ansi(" @|yellow No credentials found|@")); + return; + } + + TableList table = new TableList(out, 4, "ID", "Member", "Name", "Status"); + table.setPrefix(" "); + managedCredentials.forEach(it -> table.addRow( + it.getManagedCredentialsId() != null ? it.getManagedCredentialsId().toString() : "", + it.getUserName(), + String.join(" ", it.getFirstName() != null ? it.getFirstName() : "", it.getLastName() != null ? it.getLastName() : "").strip(), + it.getManagedCredentialsId() != null ? "added" : "missing" + )); + table.print(); + out.println(""); + } +} diff --git a/src/main/java/io/seqera/tower/cli/responses/managedidentities/ManagedCredentialsUpdated.java b/src/main/java/io/seqera/tower/cli/responses/managedidentities/ManagedCredentialsUpdated.java new file mode 100644 index 000000000..d5a73d1c0 --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/responses/managedidentities/ManagedCredentialsUpdated.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.managedidentities; + +import io.seqera.tower.cli.responses.Response; + +public class ManagedCredentialsUpdated extends Response { + + public final String managedIdentityName; + public final Long managedCredentialsId; + + public ManagedCredentialsUpdated(String managedIdentityName, Long managedCredentialsId) { + this.managedIdentityName = managedIdentityName; + this.managedCredentialsId = managedCredentialsId; + } + + @Override + public String toString() { + return ansi(String.format("%n @|yellow SSH credentials (%d) updated in managed identity '%s'|@%n", managedCredentialsId, managedIdentityName)); + } +} diff --git a/src/main/java/io/seqera/tower/cli/responses/managedidentities/ManagedIdentitiesList.java b/src/main/java/io/seqera/tower/cli/responses/managedidentities/ManagedIdentitiesList.java new file mode 100644 index 000000000..2a528dcfb --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/responses/managedidentities/ManagedIdentitiesList.java @@ -0,0 +1,56 @@ +/* + * 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.managedidentities; + +import io.seqera.tower.cli.responses.Response; +import io.seqera.tower.cli.utils.TableList; +import io.seqera.tower.model.ManagedIdentityDbDtoAbstractGridConfig; + +import java.io.PrintWriter; +import java.util.List; + +public class ManagedIdentitiesList extends Response { + + public final String organizationRef; + public final List managedIdentities; + + public ManagedIdentitiesList(String organizationRef, List managedIdentities) { + this.organizationRef = organizationRef; + this.managedIdentities = managedIdentities; + } + + @Override + public void toString(PrintWriter out) { + out.println(ansi(String.format("%n @|bold Managed identities at %s organization:|@%n", organizationRef))); + if (managedIdentities == null || managedIdentities.isEmpty()) { + out.println(ansi(" @|yellow No managed identities found|@")); + return; + } + + TableList table = new TableList(out, 4, "ID", "Name", "Platform", "Host").sortBy(0); + table.setPrefix(" "); + managedIdentities.forEach(it -> table.addRow( + it.getId().toString(), + it.getName(), + it.getPlatform() != null ? it.getPlatform().getValue() : "", + it.getConfig() != null ? String.format("%s:%s", it.getConfig().getHostName(), it.getConfig().getPort()) : "" + )); + table.print(); + out.println(""); + } +} diff --git a/src/main/java/io/seqera/tower/cli/responses/managedidentities/ManagedIdentityAdded.java b/src/main/java/io/seqera/tower/cli/responses/managedidentities/ManagedIdentityAdded.java new file mode 100644 index 000000000..ddb92528c --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/responses/managedidentities/ManagedIdentityAdded.java @@ -0,0 +1,38 @@ +/* + * 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.managedidentities; + +import io.seqera.tower.cli.responses.Response; + +public class ManagedIdentityAdded extends Response { + + public final String organizationRef; + public final Long id; + public final String name; + + public ManagedIdentityAdded(String organizationRef, Long id, String name) { + this.organizationRef = organizationRef; + this.id = id; + this.name = name; + } + + @Override + public String toString() { + return ansi(String.format("%n @|yellow New managed identity '%s' (%d) added at %s organization|@%n", name, id, organizationRef)); + } +} diff --git a/src/main/java/io/seqera/tower/cli/responses/managedidentities/ManagedIdentityDeleted.java b/src/main/java/io/seqera/tower/cli/responses/managedidentities/ManagedIdentityDeleted.java new file mode 100644 index 000000000..14692936a --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/responses/managedidentities/ManagedIdentityDeleted.java @@ -0,0 +1,38 @@ +/* + * 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.managedidentities; + +import io.seqera.tower.cli.responses.Response; + +public class ManagedIdentityDeleted extends Response { + + public final String organizationRef; + public final Long id; + public final String name; + + public ManagedIdentityDeleted(String organizationRef, Long id, String name) { + this.organizationRef = organizationRef; + this.id = id; + this.name = name; + } + + @Override + public String toString() { + return ansi(String.format("%n @|yellow Managed identity '%s' (%d) deleted at %s organization|@%n", name, id, organizationRef)); + } +} diff --git a/src/main/java/io/seqera/tower/cli/responses/managedidentities/ManagedIdentityUpdated.java b/src/main/java/io/seqera/tower/cli/responses/managedidentities/ManagedIdentityUpdated.java new file mode 100644 index 000000000..d5a7751f6 --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/responses/managedidentities/ManagedIdentityUpdated.java @@ -0,0 +1,38 @@ +/* + * 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.managedidentities; + +import io.seqera.tower.cli.responses.Response; + +public class ManagedIdentityUpdated extends Response { + + public final String organizationRef; + public final Long id; + public final String name; + + public ManagedIdentityUpdated(String organizationRef, Long id, String name) { + this.organizationRef = organizationRef; + this.id = id; + this.name = name; + } + + @Override + public String toString() { + return ansi(String.format("%n @|yellow Managed identity '%s' (%d) updated at %s organization|@%n", name, id, organizationRef)); + } +} diff --git a/src/main/java/io/seqera/tower/cli/responses/managedidentities/ManagedIdentityView.java b/src/main/java/io/seqera/tower/cli/responses/managedidentities/ManagedIdentityView.java new file mode 100644 index 000000000..4144991f1 --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/responses/managedidentities/ManagedIdentityView.java @@ -0,0 +1,51 @@ +/* + * 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.managedidentities; + +import io.seqera.tower.cli.responses.Response; +import io.seqera.tower.cli.utils.TableList; +import io.seqera.tower.model.ManagedIdentityDbDtoAbstractGridConfig; + +import java.io.PrintWriter; + +public class ManagedIdentityView extends Response { + + public final String organizationRef; + public final ManagedIdentityDbDtoAbstractGridConfig managedIdentity; + + public ManagedIdentityView(String organizationRef, ManagedIdentityDbDtoAbstractGridConfig managedIdentity) { + this.organizationRef = organizationRef; + this.managedIdentity = managedIdentity; + } + + @Override + public void toString(PrintWriter out) { + out.println(ansi(String.format("%n @|bold Managed identity at %s organization:|@%n", organizationRef))); + TableList table = new TableList(out, 2); + table.setPrefix(" "); + table.addRow("ID", managedIdentity.getId().toString()); + table.addRow("Name", managedIdentity.getName()); + table.addRow("Platform", managedIdentity.getPlatform() != null ? managedIdentity.getPlatform().getValue() : ""); + if (managedIdentity.getConfig() != null) { + table.addRow("Host name", managedIdentity.getConfig().getHostName()); + table.addRow("Port", String.valueOf(managedIdentity.getConfig().getPort())); + } + table.print(); + out.println(""); + } +} diff --git a/src/main/java/io/seqera/tower/cli/responses/organizations/OrganizationQuotasView.java b/src/main/java/io/seqera/tower/cli/responses/organizations/OrganizationQuotasView.java new file mode 100644 index 000000000..5dfc059f2 --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/responses/organizations/OrganizationQuotasView.java @@ -0,0 +1,63 @@ +/* + * 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.organizations; + +import io.seqera.tower.cli.responses.Response; +import io.seqera.tower.cli.utils.TableList; +import io.seqera.tower.model.OrganizationQuotas; + +import java.io.PrintWriter; +import java.util.Objects; + +public class OrganizationQuotasView extends Response { + + public final String organizationName; + public final OrganizationQuotas quotas; + + public OrganizationQuotasView(String organizationName, OrganizationQuotas quotas) { + this.organizationName = organizationName; + this.quotas = quotas; + } + + @Override + public Object getJSON() { + return quotas; + } + + @Override + public void toString(PrintWriter out) { + out.println(ansi(String.format("%n @|bold Quotas for %s organization:|@%n", organizationName))); + TableList table = new TableList(out, 2); + table.setPrefix(" "); + table.addRow("Workspaces", Objects.toString(quotas.getMaxWorkspaces(), null)); + table.addRow("Members", Objects.toString(quotas.getMaxMembers(), null)); + table.addRow("Teams", Objects.toString(quotas.getMaxTeams(), null)); + table.addRow("Custom roles", Objects.toString(quotas.getMaxCustomRolesPerOrg(), null)); + table.addRow("Participants per workspace", Objects.toString(quotas.getMaxParticipantsPerWorkspace(), null)); + table.addRow("Pipelines per workspace", Objects.toString(quotas.getMaxPipelinesPerWorkspace(), null)); + table.addRow("Datasets per workspace", Objects.toString(quotas.getMaxDatasetsPerWorkspace(), null)); + table.addRow("Versions per dataset", Objects.toString(quotas.getMaxVersionsPerDataset(), null)); + table.addRow("Labels per workspace", Objects.toString(quotas.getMaxLabelsPerWorkspace(), null)); + table.addRow("Runs", Objects.toString(quotas.getMaxRuns(), null)); + table.addRow("Run history", Objects.toString(quotas.getMaxRunHistory(), null)); + table.addRow("Running Studios", Objects.toString(quotas.getMaxDataStudiosRunning(), null)); + table.addRow("Seqera Compute environments", Objects.toString(quotas.getMaxSeqeraComputeComputeEnvs(), null)); + table.addRow("Fusion throughput (bytes)", Objects.toString(quotas.getMaxFusionThroughputBytes(), null)); + table.print(); + out.println(""); + } +} diff --git a/src/main/java/io/seqera/tower/cli/responses/pipelines/PipelinesSchema.java b/src/main/java/io/seqera/tower/cli/responses/pipelines/PipelinesSchema.java new file mode 100644 index 000000000..6a8d2847c --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/responses/pipelines/PipelinesSchema.java @@ -0,0 +1,44 @@ +/* + * 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.pipelines; + +import io.seqera.tower.cli.responses.Response; +import io.seqera.tower.model.PipelineSchemaAttributes; + +import java.util.Map; + +public class PipelinesSchema extends Response { + + public final PipelineSchemaAttributes type; + public final String content; + + public PipelinesSchema(PipelineSchemaAttributes type, String content) { + this.type = type; + this.content = content; + } + + @Override + public Object getJSON() { + return Map.of(type.getValue(), content); + } + + @Override + public String toString() { + return content; + } +} diff --git a/src/main/java/io/seqera/tower/cli/responses/runs/RunLog.java b/src/main/java/io/seqera/tower/cli/responses/runs/RunLog.java new file mode 100644 index 000000000..fd4c4cce8 --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/responses/runs/RunLog.java @@ -0,0 +1,54 @@ +/* + * 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.runs; + +import io.seqera.tower.cli.responses.Response; + +import java.io.PrintWriter; +import java.util.List; + +public class RunLog extends Response { + + public final List entries; + public final boolean truncated; + public final String message; + public final String next; + + public RunLog(List entries, boolean truncated, String message, String next) { + this.entries = entries; + this.truncated = truncated; + this.message = message; + this.next = next; + } + + @Override + public void toString(PrintWriter out) { + if (entries != null) { + entries.forEach(out::println); + } + if (message != null) { + out.println(ansi(String.format("%n @|yellow %s|@", message))); + } + if (truncated) { + out.println(ansi(String.format("%n @|yellow Log truncated. Use 'tw runs view download' to get the complete file once the run has finished.|@"))); + } + if (next != null) { + out.println(ansi(String.format("%n @|bold Next page:|@ --next %s", next))); + } + } +} diff --git a/src/main/java/io/seqera/tower/cli/responses/runs/RunStarred.java b/src/main/java/io/seqera/tower/cli/responses/runs/RunStarred.java new file mode 100644 index 000000000..da3dc87e6 --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/responses/runs/RunStarred.java @@ -0,0 +1,40 @@ +/* + * 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.runs; + +import io.seqera.tower.cli.responses.Response; + +public class RunStarred extends Response { + + public final String id; + public final String workspaceRef; + public final boolean starred; + + public RunStarred(String id, String workspaceRef, boolean starred) { + this.id = id; + this.workspaceRef = workspaceRef; + this.starred = starred; + } + + @Override + public String toString() { + return starred + ? ansi(String.format("%n @|yellow Pipeline run '%s' starred at %s workspace|@%n", id, workspaceRef)) + : ansi(String.format("%n @|yellow Pipeline run '%s' unstarred at %s workspace|@%n", id, workspaceRef)); + } +} diff --git a/src/main/java/io/seqera/tower/cli/responses/runs/RunsDeleted.java b/src/main/java/io/seqera/tower/cli/responses/runs/RunsDeleted.java new file mode 100644 index 000000000..498d91438 --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/responses/runs/RunsDeleted.java @@ -0,0 +1,49 @@ +/* + * 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.runs; + +import io.seqera.tower.cli.responses.Response; +import picocli.CommandLine; + +import java.io.PrintWriter; +import java.util.List; + +public class RunsDeleted extends Response { + + public final List deleted; + public final List failed; + public final String workspaceRef; + + public RunsDeleted(List deleted, List failed, String workspaceRef) { + this.deleted = deleted; + this.failed = failed; + this.workspaceRef = workspaceRef; + } + + @Override + public void toString(PrintWriter out) { + out.println(); + deleted.forEach(id -> out.println(ansi(String.format(" @|yellow Pipeline run '%s' deleted at %s workspace|@", id, workspaceRef)))); + failed.forEach(id -> out.println(ansi(String.format(" @|bold,red Pipeline run '%s' could not be deleted at %s workspace|@", id, workspaceRef)))); + } + + @Override + public int getExitCode() { + return failed.isEmpty() ? CommandLine.ExitCode.OK : CommandLine.ExitCode.SOFTWARE; + } +} diff --git a/src/main/java/io/seqera/tower/cli/responses/studios/StudioCheckpointUpdated.java b/src/main/java/io/seqera/tower/cli/responses/studios/StudioCheckpointUpdated.java new file mode 100644 index 000000000..3f50ed035 --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/responses/studios/StudioCheckpointUpdated.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.responses.studios; + +import io.seqera.tower.cli.responses.Response; + +public class StudioCheckpointUpdated extends Response { + + public final String userSuppliedStudioIdentifier; + public final String workspaceRef; + public final Long checkpointId; + public final String name; + + public StudioCheckpointUpdated(String userSuppliedStudioIdentifier, String workspaceRef, Long checkpointId, String name) { + this.userSuppliedStudioIdentifier = userSuppliedStudioIdentifier; + this.workspaceRef = workspaceRef; + this.checkpointId = checkpointId; + this.name = name; + } + + @Override + public String toString() { + return ansi(String.format("%n @|yellow Checkpoint %d of studio %s at %s workspace renamed to '%s'.|@%n", checkpointId, userSuppliedStudioIdentifier, workspaceRef, name)); + } +} diff --git a/src/main/java/io/seqera/tower/cli/responses/studios/StudioLifespanExtended.java b/src/main/java/io/seqera/tower/cli/responses/studios/StudioLifespanExtended.java new file mode 100644 index 000000000..5bae89dac --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/responses/studios/StudioLifespanExtended.java @@ -0,0 +1,37 @@ +/* + * 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; + +public class StudioLifespanExtended extends Response { + + public final String userSuppliedStudioIdentifier; + public final String workspaceRef; + public final Integer lifespanHours; + + public StudioLifespanExtended(String userSuppliedStudioIdentifier, String workspaceRef, Integer lifespanHours) { + this.userSuppliedStudioIdentifier = userSuppliedStudioIdentifier; + this.workspaceRef = workspaceRef; + this.lifespanHours = lifespanHours; + } + + @Override + public String toString() { + return ansi(String.format("%n @|yellow Studio %s lifespan at %s workspace extended to %d hours.|@%n", userSuppliedStudioIdentifier, workspaceRef, lifespanHours)); + } +} diff --git a/src/main/java/io/seqera/tower/cli/responses/teams/TeamUpdated.java b/src/main/java/io/seqera/tower/cli/responses/teams/TeamUpdated.java new file mode 100644 index 000000000..d136a86e8 --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/responses/teams/TeamUpdated.java @@ -0,0 +1,35 @@ +/* + * 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.teams; + +import io.seqera.tower.cli.responses.Response; + +public class TeamUpdated extends Response { + + public final String organizationName; + public final String teamName; + + public TeamUpdated(String organizationName, String teamName) { + this.organizationName = organizationName; + this.teamName = teamName; + } + + @Override + public String toString() { + return ansi(String.format("%n @|yellow Team '%s' updated at %s organization|@%n", teamName, organizationName)); + } +} diff --git a/src/main/java/io/seqera/tower/cli/responses/teams/TeamView.java b/src/main/java/io/seqera/tower/cli/responses/teams/TeamView.java new file mode 100644 index 000000000..6bfb7c580 --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/responses/teams/TeamView.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.responses.teams; + +import io.seqera.tower.cli.responses.Response; +import io.seqera.tower.cli.utils.TableList; +import io.seqera.tower.model.TeamDbDto; + +import java.io.PrintWriter; + +public class TeamView extends Response { + + public final String organizationName; + public final TeamDbDto team; + + public TeamView(String organizationName, TeamDbDto team) { + this.organizationName = organizationName; + this.team = team; + } + + @Override + public void toString(PrintWriter out) { + out.println(ansi(String.format("%n @|bold Team '%s' at %s organization:|@%n", team.getName(), organizationName))); + TableList table = new TableList(out, 2); + table.setPrefix(" "); + table.addRow("ID", team.getTeamId().toString()); + table.addRow("Name", team.getName()); + table.addRow("Description", team.getDescription()); + table.addRow("Members", team.getMembersCount() == null ? null : team.getMembersCount().toString()); + table.print(); + out.println(""); + } +} diff --git a/src/main/java/io/seqera/tower/cli/responses/teams/TeamWorkspacesList.java b/src/main/java/io/seqera/tower/cli/responses/teams/TeamWorkspacesList.java new file mode 100644 index 000000000..ff2508abf --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/responses/teams/TeamWorkspacesList.java @@ -0,0 +1,64 @@ +/* + * 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.teams; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import io.seqera.tower.cli.responses.Response; +import io.seqera.tower.cli.utils.PaginationInfo; +import io.seqera.tower.cli.utils.TableList; +import io.seqera.tower.model.WorkspaceParticipantResponseDto; +import jakarta.annotation.Nullable; + +import java.io.PrintWriter; +import java.util.List; + +public class TeamWorkspacesList extends Response { + + public final String organizationName; + public final String teamName; + public final List workspaces; + + @JsonIgnore + @Nullable + private final PaginationInfo paginationInfo; + + public TeamWorkspacesList(String organizationName, String teamName, List workspaces, @Nullable PaginationInfo paginationInfo) { + this.organizationName = organizationName; + this.teamName = teamName; + this.workspaces = workspaces; + this.paginationInfo = paginationInfo; + } + + @Override + public void toString(PrintWriter out) { + out.println(ansi(String.format("%n @|bold Workspaces of team '%s' at %s organization:|@%n", teamName, organizationName))); + + if (workspaces == null || workspaces.isEmpty()) { + out.println(ansi(" @|yellow No workspaces found|@")); + return; + } + + TableList table = new TableList(out, 3, "Workspace ID", "Workspace Name", "Role"); + table.setPrefix(" "); + workspaces.forEach(ws -> table.addRow(ws.getWorkspaceId().toString(), ws.getWorkspaceName(), ws.getParticipantRole())); + table.print(); + + PaginationInfo.addFooter(out, paginationInfo); + + out.println(""); + } +} diff --git a/src/test/java/io/seqera/tower/cli/BaseCmdTest.java b/src/test/java/io/seqera/tower/cli/BaseCmdTest.java index 791bd2a7d..b6bd58ecc 100644 --- a/src/test/java/io/seqera/tower/cli/BaseCmdTest.java +++ b/src/test/java/io/seqera/tower/cli/BaseCmdTest.java @@ -32,10 +32,10 @@ import picocli.CommandLine; import picocli.CommandLine.ExitCode; -import java.io.BufferedReader; +import java.nio.charset.Charset; +import java.io.UncheckedIOException; import java.io.IOException; import java.io.InputStream; -import java.io.InputStreamReader; import java.io.PrintWriter; import java.io.StringWriter; import java.nio.file.Files; @@ -137,8 +137,9 @@ private ExecOut execBinary(OutputType format, MockServerClient mock, String comm builder.command(ArrayUtils.insert(0, buildArgs(format, mock, args), command)); Process process = builder.start(); - StreamGobbler consumeOut = new StreamGobbler(process.getInputStream(), outWriter::println); - StreamGobbler consumeErr = new StreamGobbler(process.getErrorStream(), errWriter::println); + // Copy the raw streams: re-joining lines with println turned line feeds inside printed content into CRLF on Windows + StreamGobbler consumeOut = new StreamGobbler(process.getInputStream(), outWriter::print); + StreamGobbler consumeErr = new StreamGobbler(process.getErrorStream(), errWriter::print); Future taskOut = Executors.newSingleThreadExecutor().submit(consumeOut); Future taskErr = Executors.newSingleThreadExecutor().submit(consumeErr); int exitCode = process.waitFor(); @@ -246,7 +247,11 @@ public StreamGobbler(InputStream inputStream, Consumer consumer) { @Override public void run() { - new BufferedReader(new InputStreamReader(inputStream)).lines().forEach(consumer); + try { + consumer.accept(new String(inputStream.readAllBytes(), Charset.defaultCharset())); + } catch (IOException e) { + throw new UncheckedIOException(e); + } } } diff --git a/src/test/java/io/seqera/tower/cli/LaunchCmdTest.java b/src/test/java/io/seqera/tower/cli/LaunchCmdTest.java index 11dfa2884..2cbf84a59 100644 --- a/src/test/java/io/seqera/tower/cli/LaunchCmdTest.java +++ b/src/test/java/io/seqera/tower/cli/LaunchCmdTest.java @@ -301,6 +301,7 @@ void testSubmitLaunchpadPipelineWithAdvancedOptions(MockServerClient mock) throw "revision":"develop", "configProfiles":["test","docker"], "configText":"extra_config", + "towerConfig":"reports: {}", "preRunScript":"pre_run_me", "postRunScript":"post_run_me", "mainScript":"alternate.nf", @@ -323,7 +324,8 @@ void testSubmitLaunchpadPipelineWithAdvancedOptions(MockServerClient mock) throw // Run the command ExecOut out = exec(mock, "launch", "sarek", "-p", "test,docker", "-r", "develop", "--work-dir", "/my_work_dir", - "--config", tempFile("extra_config", "nextflow", "config"), "--pull-latest", "--stub-run", + "--config", tempFile("extra_config", "nextflow", "config"), "--tower-config", tempFile("reports: {}", "tower", "yml"), + "--pull-latest", "--stub-run", "--pre-run", tempFile("pre_run_me", "pre", "sh"), "--post-run", tempFile("post_run_me", "post", "sh"), "--main-script", "alternate.nf", "--entry-name", "dsl2", "--schema-name", "my_schema.json", "--disable-optimization"); diff --git a/src/test/java/io/seqera/tower/cli/accesstokens/AccessTokensCmdTest.java b/src/test/java/io/seqera/tower/cli/accesstokens/AccessTokensCmdTest.java new file mode 100644 index 000000000..450c487d7 --- /dev/null +++ b/src/test/java/io/seqera/tower/cli/accesstokens/AccessTokensCmdTest.java @@ -0,0 +1,126 @@ +/* + * 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.accesstokens; + +import io.seqera.tower.cli.BaseCmdTest; +import io.seqera.tower.cli.commands.enums.OutputType; +import io.seqera.tower.cli.responses.accesstokens.AccessTokenAdded; +import io.seqera.tower.cli.responses.accesstokens.AccessTokenDeleted; +import io.seqera.tower.cli.responses.accesstokens.AccessTokensList; +import io.seqera.tower.model.AccessToken; +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.io.IOException; +import java.util.List; + +import static io.seqera.tower.cli.utils.JsonHelper.parseJson; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockserver.matchers.Times.exactly; +import static org.mockserver.model.HttpRequest.request; +import static org.mockserver.model.HttpResponse.response; +import static org.mockserver.model.JsonBody.json; + +class AccessTokensCmdTest extends BaseCmdTest { + + private static final String TOKEN_1 = "{\"id\":21,\"name\":\"ci\",\"dateCreated\":\"2026-09-01T10:00:00Z\",\"lastUsed\":\"2026-09-02T10:00:00Z\"}"; + private static final String LEGACY_TOKEN = "{\"id\":22,\"name\":\"legacy\",\"basicAuth\":\"dG93ZXI6c2VjcmV0\",\"dateCreated\":\"2021-01-01T10:00:00Z\",\"lastUsed\":null}"; + private static final String LIST = "{\"tokens\":[" + TOKEN_1 + "," + LEGACY_TOKEN + "]}"; + + private void mockList(MockServerClient mock) { + mock.when( + request().withMethod("GET").withPath("/tokens"), exactly(1) + ).respond( + response().withStatusCode(200).withBody(LIST).withContentType(MediaType.APPLICATION_JSON) + ); + } + + @ParameterizedTest + @EnumSource(OutputType.class) + void testList(OutputType format, MockServerClient mock) throws IOException { + mockList(mock); + + ExecOut out = exec(format, mock, "access-tokens", "list"); + + assertOutput(format, out, new AccessTokensList(List.of( + parseJson(TOKEN_1, AccessToken.class), + parseJson("{\"id\":22,\"name\":\"legacy\",\"dateCreated\":\"2021-01-01T10:00:00Z\"}", AccessToken.class) + ))); + assertFalse(out.stdOut.contains("dG93ZXI6c2VjcmV0"), "basicAuth must never be printed"); + } + + @ParameterizedTest + @EnumSource(OutputType.class) + void testAdd(OutputType format, MockServerClient mock) { + mock.when( + request().withMethod("POST").withPath("/tokens").withBody(json("{\"name\":\"ci\"}")), exactly(1) + ).respond( + response().withStatusCode(200).withBody("{\"accessKey\":\"eyJ0aWQiOjIxfQ.secret\",\"token\":" + TOKEN_1 + "}").withContentType(MediaType.APPLICATION_JSON) + ); + + ExecOut out = exec(format, mock, "access-tokens", "add", "-n", "ci"); + + assertOutput(format, out, new AccessTokenAdded(21L, "ci", "eyJ0aWQiOjIxfQ.secret")); + assertTrue(out.stdOut.contains("eyJ0aWQiOjIxfQ.secret")); + } + + @ParameterizedTest + @EnumSource(OutputType.class) + void testDeleteByName(OutputType format, MockServerClient mock) { + mockList(mock); + mock.when( + request().withMethod("DELETE").withPath("/tokens/22"), exactly(1) + ).respond( + response().withStatusCode(204) + ); + + ExecOut out = exec(format, mock, "access-tokens", "delete", "-n", "legacy"); + + assertOutput(format, out, new AccessTokenDeleted(22L, "legacy")); + } + + @ParameterizedTest + @EnumSource(OutputType.class) + void testDeleteById(OutputType format, MockServerClient mock) { + mockList(mock); + mock.when( + request().withMethod("DELETE").withPath("/tokens/21"), exactly(1) + ).respond( + response().withStatusCode(204) + ); + + ExecOut out = exec(format, mock, "access-tokens", "delete", "-i", "21"); + + assertOutput(format, out, new AccessTokenDeleted(21L, "ci")); + } + + @Test + void testDeleteUnknownId(MockServerClient mock) { + mockList(mock); + + ExecOut out = exec(mock, "access-tokens", "delete", "-i", "99"); + + assertEquals(1, out.exitCode); + assertTrue(out.stdErr.contains("Unknown access token '99'"), out.stdErr); + } +} diff --git a/src/test/java/io/seqera/tower/cli/actions/ActionsCmdTest.java b/src/test/java/io/seqera/tower/cli/actions/ActionsCmdTest.java index e5c124495..a86a8d111 100644 --- a/src/test/java/io/seqera/tower/cli/actions/ActionsCmdTest.java +++ b/src/test/java/io/seqera/tower/cli/actions/ActionsCmdTest.java @@ -374,6 +374,36 @@ void testAdd(OutputType format, MockServerClient mock) { assertOutput(format, out, new ActionAdd("new-action", USER_WORKSPACE_NAME, "2Z1g6MCWpOLgHLA65cw1qt")); } + @Test + void testAddWithTowerConfig(MockServerClient mock) throws IOException { + mock.reset(); + + mock.when( + request().withMethod("GET").withPath("/compute-envs").withQueryStringParameter("status", "AVAILABLE"), exactly(1) + ).respond( + response().withStatusCode(200).withBody("{\"computeEnvs\":[{\"id\":\"vYOK4vn7spw7bHHWBDXZ2\",\"name\":\"demo\",\"platform\":\"aws-batch\",\"status\":\"AVAILABLE\",\"primary\":true}]}").withContentType(MediaType.APPLICATION_JSON) + ); + + mock.when( + request().withMethod("GET").withPath("/compute-envs/vYOK4vn7spw7bHHWBDXZ2"), exactly(1) + ).respond( + response().withStatusCode(200).withBody(loadResource("compute_env_demo")).withContentType(MediaType.APPLICATION_JSON) + ); + + mock.when( + request().withMethod("POST").withPath("/actions") + .withBody(json("{\"launch\":{\"towerConfig\":\"reports: {}\"}}")), exactly(1) + ).respond( + response().withStatusCode(200).withBody(loadResource("/actions/action_add")).withContentType(MediaType.APPLICATION_JSON) + ); + + ExecOut out = exec(mock, "actions", "add", "github", "-n", "new-action", "--pipeline", "https://github.com/pditommaso/nf-sleep", + "--tower-config", tempFile("reports: {}", "tower", "yml")); + + assertEquals("", out.stdErr); + assertEquals(0, out.exitCode); + } + @ParameterizedTest @EnumSource(OutputType.class) void testAddWithOverwrite(OutputType format, MockServerClient mock) { diff --git a/src/test/java/io/seqera/tower/cli/computeenvs/ComputeEnvsCmdTest.java b/src/test/java/io/seqera/tower/cli/computeenvs/ComputeEnvsCmdTest.java index a5bd7af8a..acc924165 100644 --- a/src/test/java/io/seqera/tower/cli/computeenvs/ComputeEnvsCmdTest.java +++ b/src/test/java/io/seqera/tower/cli/computeenvs/ComputeEnvsCmdTest.java @@ -29,6 +29,8 @@ import io.seqera.tower.cli.exceptions.TowerException; import io.seqera.tower.cli.responses.computeenvs.ComputeEnvAdded; import io.seqera.tower.cli.responses.computeenvs.ComputeEnvDeleted; +import io.seqera.tower.cli.responses.computeenvs.ComputeEnvDisabled; +import io.seqera.tower.cli.responses.computeenvs.ComputeEnvEnabled; import io.seqera.tower.cli.responses.computeenvs.ComputeEnvExport; import io.seqera.tower.cli.responses.computeenvs.ComputeEnvList; import io.seqera.tower.cli.responses.computeenvs.ComputeEnvUpdated; @@ -808,4 +810,85 @@ void testValidateInvalid(MockServerClient mock) { assertEquals(1, out.exitCode); assertEquals(new ComputeEnvValidated("isnEDBLvHDAIteOEF44ow", "demo", USER_WORKSPACE_NAME, ComputeEnvStatus.INVALID, false, "Work directory not accessible").toString(), out.stdOut); } + + @ParameterizedTest + @EnumSource(OutputType.class) + void testEnable(OutputType format, MockServerClient mock) { + mock.when( + request().withMethod("POST").withPath("/compute-envs/vYOK4vn7spw7bHHWBDXZ2/enable"), exactly(1) + ).respond( + response().withStatusCode(204) + ); + + ExecOut out = exec(format, mock, "compute-envs", "enable", "-i", "vYOK4vn7spw7bHHWBDXZ2"); + assertOutput(format, out, new ComputeEnvEnabled("vYOK4vn7spw7bHHWBDXZ2", USER_WORKSPACE_NAME)); + } + + @ParameterizedTest + @EnumSource(OutputType.class) + void testDisableByName(OutputType format, MockServerClient mock) { + mock.when( + request().withMethod("GET").withPath("/compute-envs"), exactly(1) + ).respond( + response().withStatusCode(200).withBody(loadResource("compute_envs_list")).withContentType(MediaType.APPLICATION_JSON) + ); + + mock.when( + request().withMethod("GET").withPath("/compute-envs/3xkkzYH2nbD3nZjrzKm0oR"), exactly(1) + ).respond( + response().withStatusCode(200).withBody("{\"computeEnv\":{\"id\":\"3xkkzYH2nbD3nZjrzKm0oR\",\"name\":\"ce1\",\"platform\":\"aws-batch\",\"status\":\"AVAILABLE\"}}").withContentType(MediaType.APPLICATION_JSON) + ); + + mock.when( + request().withMethod("POST").withPath("/compute-envs/3xkkzYH2nbD3nZjrzKm0oR/disable"), exactly(1) + ).respond( + response().withStatusCode(204) + ); + + ExecOut out = exec(format, mock, "compute-envs", "disable", "-n", "ce1"); + assertOutput(format, out, new ComputeEnvDisabled("3xkkzYH2nbD3nZjrzKm0oR", USER_WORKSPACE_NAME)); + } + + @Test + void testDisableInWorkspace(MockServerClient mock) { + mock.when( + request().withMethod("POST").withPath("/compute-envs/vYOK4vn7spw7bHHWBDXZ2/disable") + .withQueryStringParameter("workspaceId", "75887156211590"), exactly(1) + ).respond( + response().withStatusCode(204) + ); + + 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) + ); + + ExecOut out = exec(mock, "compute-envs", "disable", "-i", "vYOK4vn7spw7bHHWBDXZ2", "-w", "75887156211590"); + + assertEquals("", out.stdErr); + assertEquals(0, out.exitCode); + assertEquals(new ComputeEnvDisabled("vYOK4vn7spw7bHHWBDXZ2", "[organization2 / workspace2]").toString(), out.stdOut); + } + + @Test + void testEnableRejected(MockServerClient mock) { + mock.when( + request().withMethod("POST").withPath("/compute-envs/vYOK4vn7spw7bHHWBDXZ2/enable"), exactly(1) + ).respond( + response().withStatusCode(400).withBody("{\"message\":\"Compute environment cannot be enabled - current status: AVAILABLE\"}").withContentType(MediaType.APPLICATION_JSON) + ); + + ExecOut out = exec(mock, "compute-envs", "enable", "-i", "vYOK4vn7spw7bHHWBDXZ2"); + + assertEquals(errorMessage(out.app, new ApiException(400, "", null, "{\"message\":\"Compute environment cannot be enabled - current status: AVAILABLE\"}")), out.stdErr); + assertEquals("", out.stdOut); + assertEquals(1, out.exitCode); + } } diff --git a/src/test/java/io/seqera/tower/cli/computeenvs/platforms/AltairPlatformTest.java b/src/test/java/io/seqera/tower/cli/computeenvs/platforms/AltairPlatformTest.java index cd6a15d1d..34e61f1ed 100644 --- a/src/test/java/io/seqera/tower/cli/computeenvs/platforms/AltairPlatformTest.java +++ b/src/test/java/io/seqera/tower/cli/computeenvs/platforms/AltairPlatformTest.java @@ -72,13 +72,13 @@ void testAddWithAdvancedOptions(MockServerClient mock) { mock.when( request().withMethod("POST").withPath("/compute-envs").withBody( - JsonBody.json("{\"computeEnv\":{\"credentialsId\":\"2ba2oekqeTEBzwSDgXg7xf\",\"name\":\"altair\",\"platform\":\"altair-platform\",\"config\":{\"userName\":\"jordi\",\"hostName\":\"ssh.mydomain.net\",\"maxQueueSize\":200,\"workDir\":\"/home/jordeu/nf\",\"headQueue\":\"normal\"}}}") + JsonBody.json("{\"computeEnv\":{\"credentialsId\":\"2ba2oekqeTEBzwSDgXg7xf\",\"name\":\"altair\",\"platform\":\"altair-platform\",\"config\":{\"userName\":\"jordi\",\"hostName\":\"ssh.mydomain.net\",\"maxQueueSize\":200,\"headJobOptions\":\"--mem=4G\",\"propagateHeadJobOptions\":true,\"workDir\":\"/home/jordeu/nf\",\"headQueue\":\"normal\"}}}") ), exactly(1) ).respond( response().withStatusCode(200).withBody("{\"computeEnvId\":\"isnEDBLvHDAIteOEF44ow\"}").withContentType(MediaType.APPLICATION_JSON) ); - ExecOut out = exec(mock, "compute-envs", "add", "altair", "-n", "altair", "--work-dir", "/home/jordeu/nf", "-u", "jordi", "-H", "ssh.mydomain.net", "-q", "normal", "--max-queue-size=200"); + ExecOut out = exec(mock, "compute-envs", "add", "altair", "-n", "altair", "--work-dir", "/home/jordeu/nf", "-u", "jordi", "-H", "ssh.mydomain.net", "-q", "normal", "--max-queue-size=200", "--head-job-options=--mem=4G", "--propagate-head-job-options"); assertEquals("", out.stdErr); assertEquals(new ComputeEnvAdded("altair-platform", "isnEDBLvHDAIteOEF44ow", "altair", null, USER_WORKSPACE_NAME).toString(), out.stdOut); diff --git a/src/test/java/io/seqera/tower/cli/computeenvs/platforms/AwsBatchForgePlatformTest.java b/src/test/java/io/seqera/tower/cli/computeenvs/platforms/AwsBatchForgePlatformTest.java index b5efa55eb..1b9b597bb 100644 --- a/src/test/java/io/seqera/tower/cli/computeenvs/platforms/AwsBatchForgePlatformTest.java +++ b/src/test/java/io/seqera/tower/cli/computeenvs/platforms/AwsBatchForgePlatformTest.java @@ -128,6 +128,56 @@ void testAddWithAdvanced(MockServerClient mock) { assertEquals(0, out.exitCode); } + @Test + void testAddWithDragenAndLogGroup(MockServerClient mock) { + + mock.when( + request().withMethod("GET").withPath("/credentials").withQueryStringParameter("platformId", "aws-batch"), exactly(1) + ).respond( + response().withStatusCode(200).withBody("{\"credentials\":[{\"id\":\"6g0ER59L4ZoE5zpOmUP48D\",\"name\":\"aws\",\"discriminator\":\"aws\"}]}").withContentType(MediaType.APPLICATION_JSON) + ); + + mock.when( + request().withMethod("POST").withPath("/compute-envs") + .withBody(JsonBody.json(""" + {"computeEnv":{"name":"demo","platform":"aws-batch","credentialsId":"6g0ER59L4ZoE5zpOmUP48D","config":{ + "region":"eu-west-1","workDir":"s3://nextflow-ci/jordeu","logGroup":"/my/log-group", + "forge":{"type":"SPOT","maxCpus":123,"dragenEnabled":true,"dragenAmiId":"ami-0123456789abcdef0","dragenInstanceType":"f2.6xlarge"}}}} + """)), exactly(1) + ).respond( + response().withStatusCode(200).withBody("{\"computeEnvId\":\"isnEDBLvHDAIteOEF44ow\"}").withContentType(MediaType.APPLICATION_JSON) + ); + + ExecOut out = exec(mock, "compute-envs", "add", "aws-batch", "forge", "-n", "demo", "-r", "eu-west-1", "--work-dir", "s3://nextflow-ci/jordeu", "--max-cpus=123", + "--log-group", "/my/log-group", "--dragen", "--dragen-ami-id", "ami-0123456789abcdef0", "--dragen-instance-type", "f2.6xlarge"); + + assertEquals("", out.stdErr); + assertEquals(new ComputeEnvAdded("aws-batch", "isnEDBLvHDAIteOEF44ow", "demo", null, USER_WORKSPACE_NAME).toString(), out.stdOut); + assertEquals(0, out.exitCode); + } + + @Test + void testAddWithDragenMissingAmi(MockServerClient mock) { + + ExecOut out = exec(mock, "compute-envs", "add", "aws-batch", "forge", "-n", "demo", "-r", "eu-west-1", "--work-dir", "s3://nextflow-ci/jordeu", "--max-cpus=123", + "--dragen", "--dragen-instance-type", "f2.6xlarge"); + + assertEquals(errorMessage(out.app, new TowerException("DRAGEN requires '--dragen-ami-id' and '--dragen-instance-type'")), out.stdErr); + assertEquals("", out.stdOut); + assertEquals(1, out.exitCode); + } + + @Test + void testAddWithDragenSettingsWithoutDragen(MockServerClient mock) { + + ExecOut out = exec(mock, "compute-envs", "add", "aws-batch", "forge", "-n", "demo", "-r", "eu-west-1", "--work-dir", "s3://nextflow-ci/jordeu", "--max-cpus=123", + "--dragen-ami-id", "ami-0123456789abcdef0"); + + assertEquals(errorMessage(out.app, new TowerException("DRAGEN AMI ID and instance type require '--dragen'")), out.stdErr); + assertEquals("", out.stdOut); + assertEquals(1, out.exitCode); + } + @Test void testAddWithEnvVars(MockServerClient mock) { diff --git a/src/test/java/io/seqera/tower/cli/computeenvs/platforms/AwsBatchManualPlatformTest.java b/src/test/java/io/seqera/tower/cli/computeenvs/platforms/AwsBatchManualPlatformTest.java index 33ad8d88b..ae65a0aeb 100644 --- a/src/test/java/io/seqera/tower/cli/computeenvs/platforms/AwsBatchManualPlatformTest.java +++ b/src/test/java/io/seqera/tower/cli/computeenvs/platforms/AwsBatchManualPlatformTest.java @@ -70,12 +70,12 @@ void testAddAdvanceOptions(MockServerClient mock) { mock.when( request().withMethod("POST").withPath("/compute-envs") - .withBody(JsonBody.json("{\"computeEnv\":{\"name\":\"manual\",\"platform\":\"aws-batch\",\"config\":{\"region\":\"eu-west-1\",\"fusion2Enabled\":true,\"waveEnabled\":true,\"nvnmeStorageEnabled\":true,\"computeQueue\":\"TowerForge-isnEDBLvHDAIteOEF44ow-work\",\"executionRole\":\"execution-arn\",\"headQueue\":\"TowerForge-isnEDBLvHDAIteOEF44ow-head\",\"cliPath\":\"/bin/aws\",\"workDir\":\"s3://nextflow-ci/jordeu\"},\"credentialsId\":\"6g0ER59L4ZoE5zpOmUP48D\"}}")), exactly(1) + .withBody(JsonBody.json("{\"computeEnv\":{\"name\":\"manual\",\"platform\":\"aws-batch\",\"config\":{\"region\":\"eu-west-1\",\"fusion2Enabled\":true,\"waveEnabled\":true,\"nvnmeStorageEnabled\":true,\"computeQueue\":\"TowerForge-isnEDBLvHDAIteOEF44ow-work\",\"executionRole\":\"execution-arn\",\"headQueue\":\"TowerForge-isnEDBLvHDAIteOEF44ow-head\",\"cliPath\":\"/bin/aws\",\"logGroup\":\"/my/log-group\",\"workDir\":\"s3://nextflow-ci/jordeu\"},\"credentialsId\":\"6g0ER59L4ZoE5zpOmUP48D\"}}")), exactly(1) ).respond( response().withStatusCode(200).withBody("{\"computeEnvId\":\"isnEDBLvHDAIteOEF44ow\"}").withContentType(MediaType.APPLICATION_JSON) ); - ExecOut out = exec(mock, "compute-envs", "add", "aws-batch", "manual", "-n", "manual", "-r", "eu-west-1", "--work-dir", "s3://nextflow-ci/jordeu", "--fusion-v2", "--wave", "--fast-storage", "--head-queue", "TowerForge-isnEDBLvHDAIteOEF44ow-head", "--compute-queue", "TowerForge-isnEDBLvHDAIteOEF44ow-work", "--cli-path=/bin/aws", "--batch-execution-role", "execution-arn"); + ExecOut out = exec(mock, "compute-envs", "add", "aws-batch", "manual", "-n", "manual", "-r", "eu-west-1", "--work-dir", "s3://nextflow-ci/jordeu", "--fusion-v2", "--wave", "--fast-storage", "--head-queue", "TowerForge-isnEDBLvHDAIteOEF44ow-head", "--compute-queue", "TowerForge-isnEDBLvHDAIteOEF44ow-work", "--cli-path=/bin/aws", "--batch-execution-role", "execution-arn", "--log-group", "/my/log-group"); assertEquals("", out.stdErr); assertEquals(new ComputeEnvAdded("aws-batch", "isnEDBLvHDAIteOEF44ow", "manual", null, USER_WORKSPACE_NAME).toString(), out.stdOut); diff --git a/src/test/java/io/seqera/tower/cli/computeenvs/platforms/AwsCloudPlatformTest.java b/src/test/java/io/seqera/tower/cli/computeenvs/platforms/AwsCloudPlatformTest.java index 8345474d5..b35931e29 100644 --- a/src/test/java/io/seqera/tower/cli/computeenvs/platforms/AwsCloudPlatformTest.java +++ b/src/test/java/io/seqera/tower/cli/computeenvs/platforms/AwsCloudPlatformTest.java @@ -126,6 +126,7 @@ void testAddWithAdvancedOptions(MockServerClient mock) throws IOException { "workDir": "s3://my-bucket", "region": "eu-west-1", "instanceType": "t3.medium", + "logGroup": "/my/log-group", "imageId": "ami-12345678", "fusion2Enabled": true, "waveEnabled": true, @@ -157,6 +158,7 @@ void testAddWithAdvancedOptions(MockServerClient mock) throws IOException { "-r", "eu-west-1", "--allow-buckets", "s3://bucket1,s3://bucket2", "--instance-type", "t3.medium", + "--log-group", "/my/log-group", "--image-id", "ami-12345678", "--arm64", "--ec2-key-pair", "my-key-pair", diff --git a/src/test/java/io/seqera/tower/cli/computeenvs/platforms/GoogleCloudPlatformTest.java b/src/test/java/io/seqera/tower/cli/computeenvs/platforms/GoogleCloudPlatformTest.java index 3f0ae14f1..05b347fc9 100644 --- a/src/test/java/io/seqera/tower/cli/computeenvs/platforms/GoogleCloudPlatformTest.java +++ b/src/test/java/io/seqera/tower/cli/computeenvs/platforms/GoogleCloudPlatformTest.java @@ -128,6 +128,7 @@ void testAddWithAdvancedOptions(MockServerClient mock) throws IOException { "region": "europe-west1", "zone": "europe-west1-b", "instanceType": "n2-standard-4", + "projectId": "my-project", "imageId": "projects/my-project/global/images/my-image", "fusion2Enabled": true, "waveEnabled": true, @@ -152,6 +153,7 @@ void testAddWithAdvancedOptions(MockServerClient mock) throws IOException { "-r", "europe-west1", "-z", "europe-west1-b", "--instance-type", "n2-standard-4", + "--project-id", "my-project", "--image-id", "projects/my-project/global/images/my-image", "--arm64", "--boot-disk-size", "100" diff --git a/src/test/java/io/seqera/tower/cli/computeenvs/platforms/LsfPlatformTest.java b/src/test/java/io/seqera/tower/cli/computeenvs/platforms/LsfPlatformTest.java index 81bae41ef..f72657fba 100644 --- a/src/test/java/io/seqera/tower/cli/computeenvs/platforms/LsfPlatformTest.java +++ b/src/test/java/io/seqera/tower/cli/computeenvs/platforms/LsfPlatformTest.java @@ -70,14 +70,14 @@ void testAddWithAdvancedOptions(MockServerClient mock) { mock.when( request().withMethod("POST").withPath("/compute-envs").withBody( - JsonBody.json("{\"computeEnv\":{\"credentialsId\":\"2ba2oekqeTEBzwSDgXg7xf\",\"name\":\"lsf\",\"platform\":\"lsf-platform\",\"config\":{\"userName\":\"jordi\",\"hostName\":\"ssh.mydomain.net\",\"maxQueueSize\":200,\"workDir\":\"/home/jordeu/nf\",\"headQueue\":\"normal\"}}}") + JsonBody.json("{\"computeEnv\":{\"credentialsId\":\"2ba2oekqeTEBzwSDgXg7xf\",\"name\":\"lsf\",\"platform\":\"lsf-platform\",\"config\":{\"userName\":\"jordi\",\"hostName\":\"ssh.mydomain.net\",\"maxQueueSize\":200,\"headJobOptions\":\"--mem=4G\",\"propagateHeadJobOptions\":true,\"workDir\":\"/home/jordeu/nf\",\"headQueue\":\"normal\"}}}") ), exactly(1) ).respond( response().withStatusCode(200).withBody("{\"computeEnvId\":\"isnEDBLvHDAIteOEF44ow\"}").withContentType(MediaType.APPLICATION_JSON) ); - ExecOut out = exec(mock, "compute-envs", "add", "lsf", "-n", "lsf", "--work-dir", "/home/jordeu/nf", "-u", "jordi", "-H", "ssh.mydomain.net", "-q", "normal", "--max-queue-size=200"); + ExecOut out = exec(mock, "compute-envs", "add", "lsf", "-n", "lsf", "--work-dir", "/home/jordeu/nf", "-u", "jordi", "-H", "ssh.mydomain.net", "-q", "normal", "--max-queue-size=200", "--head-job-options=--mem=4G", "--propagate-head-job-options"); assertEquals("", out.stdErr); assertEquals(new ComputeEnvAdded("lsf-platform", "isnEDBLvHDAIteOEF44ow", "lsf", null, USER_WORKSPACE_NAME).toString(), out.stdOut); diff --git a/src/test/java/io/seqera/tower/cli/computeenvs/platforms/MoabPlatformTest.java b/src/test/java/io/seqera/tower/cli/computeenvs/platforms/MoabPlatformTest.java index 496f781cb..8b39eda3b 100644 --- a/src/test/java/io/seqera/tower/cli/computeenvs/platforms/MoabPlatformTest.java +++ b/src/test/java/io/seqera/tower/cli/computeenvs/platforms/MoabPlatformTest.java @@ -68,13 +68,13 @@ void testAddWithAdvancedOptions(MockServerClient mock) { mock.when( request().withMethod("POST").withPath("/compute-envs").withBody( - JsonBody.json("{\"computeEnv\":{\"credentialsId\":\"2ba2oekqeTEBzwSDgXg7xf\",\"name\":\"moab\",\"platform\":\"moab-platform\",\"config\":{\"userName\":\"jordi\",\"hostName\":\"ssh.mydomain.net\",\"maxQueueSize\":200,\"workDir\":\"/home/jordeu/nf\",\"headQueue\":\"normal\"}}}") + JsonBody.json("{\"computeEnv\":{\"credentialsId\":\"2ba2oekqeTEBzwSDgXg7xf\",\"name\":\"moab\",\"platform\":\"moab-platform\",\"config\":{\"userName\":\"jordi\",\"hostName\":\"ssh.mydomain.net\",\"maxQueueSize\":200,\"headJobOptions\":\"--mem=4G\",\"propagateHeadJobOptions\":true,\"workDir\":\"/home/jordeu/nf\",\"headQueue\":\"normal\"}}}") ), exactly(1) ).respond( response().withStatusCode(200).withBody("{\"computeEnvId\":\"isnEDBLvHDAIteOEF44ow\"}").withContentType(MediaType.APPLICATION_JSON) ); - ExecOut out = exec(mock, "compute-envs", "add", "moab", "-n", "moab", "--work-dir", "/home/jordeu/nf", "-u", "jordi", "-H", "ssh.mydomain.net", "-q", "normal", "--max-queue-size=200"); + ExecOut out = exec(mock, "compute-envs", "add", "moab", "-n", "moab", "--work-dir", "/home/jordeu/nf", "-u", "jordi", "-H", "ssh.mydomain.net", "-q", "normal", "--max-queue-size=200", "--head-job-options=--mem=4G", "--propagate-head-job-options"); assertEquals("", out.stdErr); diff --git a/src/test/java/io/seqera/tower/cli/computeenvs/platforms/SeqeraComputePlatformTest.java b/src/test/java/io/seqera/tower/cli/computeenvs/platforms/SeqeraComputePlatformTest.java index cbf854dcf..4c45173c5 100644 --- a/src/test/java/io/seqera/tower/cli/computeenvs/platforms/SeqeraComputePlatformTest.java +++ b/src/test/java/io/seqera/tower/cli/computeenvs/platforms/SeqeraComputePlatformTest.java @@ -91,6 +91,7 @@ void testAddWithOptions(MockServerClient mock) throws IOException { "platform": "seqeracompute-platform", "config": { "region": "eu-west-2", + "defaultDataRetentionPolicy": true, "preRunScript": "pre_run_me", "postRunScript": "post_run_me", "environment": [ @@ -115,6 +116,7 @@ void testAddWithOptions(MockServerClient mock) throws IOException { "-n", "another-compute-env", "-r", "eu-west-2", "--work-dir", "my-work-dir", + "--data-retention-policy", "--nextflow-config", tempFile("nextflow_config", "nextflow", "config"), "-e", "head:KEY1=value1", "-e", "both:KEY2=value2", "--pre-run", tempFile("pre_run_me", "pre", "sh"), diff --git a/src/test/java/io/seqera/tower/cli/computeenvs/platforms/SlurmPlatformTest.java b/src/test/java/io/seqera/tower/cli/computeenvs/platforms/SlurmPlatformTest.java index 2bc20f7d0..822d84a22 100644 --- a/src/test/java/io/seqera/tower/cli/computeenvs/platforms/SlurmPlatformTest.java +++ b/src/test/java/io/seqera/tower/cli/computeenvs/platforms/SlurmPlatformTest.java @@ -18,6 +18,7 @@ import io.seqera.tower.cli.BaseCmdTest; import io.seqera.tower.cli.commands.enums.OutputType; +import io.seqera.tower.cli.exceptions.TowerException; import io.seqera.tower.cli.responses.computeenvs.ComputeEnvAdded; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; @@ -69,14 +70,14 @@ void testAddWithAdvancedOptions(MockServerClient mock) { mock.when( request().withMethod("POST").withPath("/compute-envs").withBody( - JsonBody.json("{\"computeEnv\":{\"credentialsId\":\"2ba2oekqeTEBzwSDgXg7xf\",\"name\":\"slurm\",\"platform\":\"slurm-platform\",\"config\":{\"userName\":\"jordi\",\"hostName\":\"ssh.mydomain.net\",\"maxQueueSize\":200,\"workDir\":\"/home/jordeu/nf\",\"headQueue\":\"normal\"}}}") + JsonBody.json("{\"computeEnv\":{\"credentialsId\":\"2ba2oekqeTEBzwSDgXg7xf\",\"name\":\"slurm\",\"platform\":\"slurm-platform\",\"config\":{\"userName\":\"jordi\",\"hostName\":\"ssh.mydomain.net\",\"maxQueueSize\":200,\"headJobOptions\":\"--mem=4G\",\"propagateHeadJobOptions\":true,\"workDir\":\"/home/jordeu/nf\",\"headQueue\":\"normal\"}}}") ), exactly(1) ).respond( response().withStatusCode(200).withBody("{\"computeEnvId\":\"isnEDBLvHDAIteOEF44ow\"}").withContentType(MediaType.APPLICATION_JSON) ); - ExecOut out = exec(mock, "compute-envs", "add", "slurm", "-n", "slurm", "--work-dir", "/home/jordeu/nf", "-u", "jordi", "-H", "ssh.mydomain.net", "-q", "normal", "--max-queue-size=200"); + ExecOut out = exec(mock, "compute-envs", "add", "slurm", "-n", "slurm", "--work-dir", "/home/jordeu/nf", "-u", "jordi", "-H", "ssh.mydomain.net", "-q", "normal", "--max-queue-size=200", "--head-job-options=--mem=4G", "--propagate-head-job-options"); assertEquals("", out.stdErr); assertEquals(new ComputeEnvAdded("slurm-platform", "isnEDBLvHDAIteOEF44ow", "slurm", null, USER_WORKSPACE_NAME).toString(), out.stdOut); @@ -84,4 +85,13 @@ void testAddWithAdvancedOptions(MockServerClient mock) { } + @Test + void testAddPropagateHeadJobOptionsRequiresHeadJobOptions(MockServerClient mock) { + + ExecOut out = exec(mock, "compute-envs", "add", "slurm", "-n", "slurm", "--work-dir", "/home/jordeu/nf", "-u", "jordi", "-H", "ssh.mydomain.net", "-q", "normal", "--propagate-head-job-options"); + + assertEquals(errorMessage(out.app, new TowerException("'--propagate-head-job-options' requires '--head-job-options'")), out.stdErr); + assertEquals("", out.stdOut); + assertEquals(1, out.exitCode); + } } diff --git a/src/test/java/io/seqera/tower/cli/computeenvs/platforms/UnivaPlatformTest.java b/src/test/java/io/seqera/tower/cli/computeenvs/platforms/UnivaPlatformTest.java index f8776ffd4..33ce15190 100644 --- a/src/test/java/io/seqera/tower/cli/computeenvs/platforms/UnivaPlatformTest.java +++ b/src/test/java/io/seqera/tower/cli/computeenvs/platforms/UnivaPlatformTest.java @@ -69,14 +69,14 @@ void testAddWithAdvancedOptions(MockServerClient mock) { mock.when( request().withMethod("POST").withPath("/compute-envs").withBody( - JsonBody.json("{\"computeEnv\":{\"credentialsId\":\"2ba2oekqeTEBzwSDgXg7xf\",\"name\":\"uge\",\"platform\":\"uge-platform\",\"config\":{\"userName\":\"jordi\",\"hostName\":\"ssh.mydomain.net\",\"maxQueueSize\":200,\"workDir\":\"/home/jordeu/nf\",\"headQueue\":\"normal\"}}}") + JsonBody.json("{\"computeEnv\":{\"credentialsId\":\"2ba2oekqeTEBzwSDgXg7xf\",\"name\":\"uge\",\"platform\":\"uge-platform\",\"config\":{\"userName\":\"jordi\",\"hostName\":\"ssh.mydomain.net\",\"maxQueueSize\":200,\"headJobOptions\":\"--mem=4G\",\"propagateHeadJobOptions\":true,\"workDir\":\"/home/jordeu/nf\",\"headQueue\":\"normal\"}}}") ), exactly(1) ).respond( response().withStatusCode(200).withBody("{\"computeEnvId\":\"isnEDBLvHDAIteOEF44ow\"}").withContentType(MediaType.APPLICATION_JSON) ); - ExecOut out = exec(mock, "compute-envs", "add", "uge", "-n", "uge", "--work-dir", "/home/jordeu/nf", "-u", "jordi", "-H", "ssh.mydomain.net", "-q", "normal", "--max-queue-size=200"); + ExecOut out = exec(mock, "compute-envs", "add", "uge", "-n", "uge", "--work-dir", "/home/jordeu/nf", "-u", "jordi", "-H", "ssh.mydomain.net", "-q", "normal", "--max-queue-size=200", "--head-job-options=--mem=4G", "--propagate-head-job-options"); assertEquals("", out.stdErr); assertEquals(new ComputeEnvAdded("uge-platform", "isnEDBLvHDAIteOEF44ow", "uge", null, USER_WORKSPACE_NAME).toString(), out.stdOut); diff --git a/src/test/java/io/seqera/tower/cli/data/DataLinksCmdTest.java b/src/test/java/io/seqera/tower/cli/data/DataLinksCmdTest.java index d0c20df07..d7fcf9351 100644 --- a/src/test/java/io/seqera/tower/cli/data/DataLinksCmdTest.java +++ b/src/test/java/io/seqera/tower/cli/data/DataLinksCmdTest.java @@ -25,7 +25,9 @@ import io.seqera.tower.cli.commands.data.links.upload.AbstractProviderUploader; import io.seqera.tower.cli.commands.enums.OutputType; import io.seqera.tower.cli.exceptions.TowerRuntimeException; +import io.seqera.tower.cli.responses.data.DataLinkContentDeleted; import io.seqera.tower.cli.responses.data.DataLinkDeleted; +import io.seqera.tower.cli.responses.data.DataLinkDownloadScript; import io.seqera.tower.cli.responses.data.DataLinkFileTransferResult; import io.seqera.tower.cli.responses.data.DataLinksList; import io.seqera.tower.cli.utils.PaginationInfo; @@ -1766,4 +1768,98 @@ private void deleteDirectory(Path directory) throws IOException { }); } } + + private void mockCredentials(MockServerClient mock) { + mock.when( + request().withMethod("GET").withPath("/credentials").withQueryStringParameter("workspaceId", "75887156211589"), exactly(1) + ).respond( + response().withStatusCode(200).withBody("{\"credentials\":[{\"id\":\"57Ic6reczFn78H1DTaaXkp\",\"name\":\"aws\",\"discriminator\":\"aws\"}]}").withContentType(MediaType.APPLICATION_JSON) + ); + } + + @ParameterizedTest + @EnumSource(OutputType.class) + void testDeleteContent(OutputType format, MockServerClient mock) { + mockCredentials(mock); + mock.when( + request().withMethod("DELETE").withPath("/data-links/v1-datalinkid/content") + .withQueryStringParameter("workspaceId", "75887156211589") + .withQueryStringParameter("credentialsId", "57Ic6reczFn78H1DTaaXkp") + .withBody(json("{\"files\": [\"a.csv\", \"b/c.csv\"], \"dirs\": [\"results/\"]}", MatchType.STRICT)), + exactly(1) + ).respond( + response().withStatusCode(200).withBody("{\"deletionFailures\": []}").withContentType(MediaType.APPLICATION_JSON) + ); + + ExecOut out = exec(format, mock, "data-links", "delete-content", "-w", "75887156211589", "-i", "v1-datalinkid", "-c", "aws", + "--file", "a.csv", "--file", "b/c.csv", "--dir", "results/"); + + assertOutput(format, out, new DataLinkContentDeleted("v1-datalinkid", 75887156211589L, List.of("a.csv", "b/c.csv"), List.of("results/"))); + } + + @org.junit.jupiter.api.Test + void testDeleteContentPartialFailure(MockServerClient mock) { + mockCredentials(mock); + mock.when( + request().withMethod("DELETE").withPath("/data-links/v1-datalinkid/content"), exactly(1) + ).respond( + response().withStatusCode(500).withBody("{\"deletionFailures\": [{\"dataLinkItem\": {\"name\": \"a.csv\", \"type\": \"FILE\"}, \"errorMessage\": \"Access denied\"}]}").withContentType(MediaType.APPLICATION_JSON) + ); + + ExecOut out = exec(mock, "data-links", "delete-content", "-w", "75887156211589", "-i", "v1-datalinkid", "-c", "aws", "--file", "a.csv"); + + assertEquals(1, out.exitCode); + assertTrue(out.stdErr.contains("Failed to delete some items from data link 'v1-datalinkid': 'a.csv': Access denied"), out.stdErr); + } + + @org.junit.jupiter.api.Test + void testDeleteContentRequiresPaths(MockServerClient mock) { + ExecOut out = exec(mock, "data-links", "delete-content", "-w", "75887156211589", "-i", "v1-datalinkid", "-c", "aws"); + + assertEquals(1, out.exitCode); + assertTrue(out.stdErr.contains("Provide at least one --file or --dir to delete"), out.stdErr); + mock.verify(request().withMethod("DELETE"), VerificationTimes.never()); + } + + @ParameterizedTest + @EnumSource(OutputType.class) + void testDownloadScript(OutputType format, MockServerClient mock) { + mockCredentials(mock); + String script = "aws s3 cp s3://bucket/results results --recursive &&\naws s3 cp s3://bucket/a.csv a.csv"; + mock.when( + request().withMethod("GET").withPath("/data-links/v1-datalinkid/script/download") + .withQueryStringParameter("workspaceId", "75887156211589") + .withQueryStringParameter("credentialsId", "57Ic6reczFn78H1DTaaXkp") + .withQueryStringParameter("dirs", "results") + .withQueryStringParameter("files", "a.csv"), + exactly(1) + ).respond( + response().withStatusCode(200).withBody(json(java.util.Map.of("script", script))).withContentType(MediaType.APPLICATION_JSON) + ); + + ExecOut out = exec(format, mock, "data-links", "download-script", "-w", "75887156211589", "-i", "v1-datalinkid", "-c", "aws", + "--dir", "results", "--file", "a.csv"); + + assertOutput(format, out, new DataLinkDownloadScript(script)); + if (format == OutputType.console) { + assertEquals(script, out.stdOut); + } + } + + @org.junit.jupiter.api.Test + void testDownloadScriptWholeDataLink(MockServerClient mock) { + mock.when( + request().withMethod("GET").withPath("/data-links/v1-datalinkid/script/download") + .withQueryStringParameter("workspaceId", "75887156211589"), + exactly(1) + ).respond( + response().withStatusCode(200).withBody("{\"script\": \"aws s3 cp s3://bucket bucket --recursive\"}").withContentType(MediaType.APPLICATION_JSON) + ); + + ExecOut out = exec(mock, "data-links", "download-script", "-w", "75887156211589", "-i", "v1-datalinkid"); + + assertEquals(0, out.exitCode, out.stdErr); + assertEquals("aws s3 cp s3://bucket bucket --recursive", out.stdOut); + mock.verify(request().withPath("/data-links/v1-datalinkid/script/download").withQueryStringParameter("files", ".*"), VerificationTimes.never()); + } } diff --git a/src/test/java/io/seqera/tower/cli/datasets/DatasetsCmdTest.java b/src/test/java/io/seqera/tower/cli/datasets/DatasetsCmdTest.java index 3a4f364ce..17ba47fdd 100644 --- a/src/test/java/io/seqera/tower/cli/datasets/DatasetsCmdTest.java +++ b/src/test/java/io/seqera/tower/cli/datasets/DatasetsCmdTest.java @@ -26,14 +26,17 @@ import io.seqera.tower.cli.responses.datasets.DatasetList; import io.seqera.tower.cli.responses.datasets.DatasetUpdate; import io.seqera.tower.cli.responses.datasets.DatasetUrl; +import io.seqera.tower.cli.responses.datasets.DatasetVersionDisabled; import io.seqera.tower.cli.responses.datasets.DatasetVersionsList; import io.seqera.tower.cli.responses.datasets.DatasetView; import io.seqera.tower.cli.responses.datasets.DatasetsVisibility; import io.seqera.tower.cli.responses.labels.ManageLabels; import io.seqera.tower.model.DatasetDto; import io.seqera.tower.model.DatasetVersionDto; +import io.seqera.tower.model.ListDatasetVersionsResponse; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.EnumSource; +import org.junit.jupiter.api.BeforeEach; import org.mockserver.client.MockServerClient; import org.mockserver.model.JsonBody; import org.mockserver.model.MediaType; @@ -47,6 +50,7 @@ import static io.seqera.tower.cli.utils.JsonHelper.parseJson; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockserver.matchers.Times.exactly; import static org.mockserver.model.HttpRequest.request; import static org.mockserver.model.HttpResponse.response; @@ -54,6 +58,11 @@ public class DatasetsCmdTest extends BaseCmdTest { + @BeforeEach + void init(MockServerClient mock) { + mock.reset(); + } + @ParameterizedTest @EnumSource(OutputType.class) void testList(OutputType format, MockServerClient mock) throws JsonProcessingException { @@ -579,4 +588,56 @@ void testLabelsApply(OutputType format, MockServerClient mock) { assertEquals("", out.stdErr); assertEquals(0, out.exitCode); } + + @ParameterizedTest + @EnumSource(OutputType.class) + void testDisableVersion(OutputType format, MockServerClient mock) { + mock.when( + request().withMethod("GET").withPath("/datasets/4D9TP0w2pM0qmwqVHgrgBK/metadata") + .withQueryStringParameter("workspaceId", "249664655368293"), exactly(1) + ).respond( + response().withStatusCode(200).withBody(loadResource("datasets/dataset_metadata")).withContentType(MediaType.APPLICATION_JSON) + ); + mock.when( + request().withMethod("POST").withPath("/datasets/4D9TP0w2pM0qmwqVHgrgBK/versions/2/disable") + .withQueryStringParameter("workspaceId", "249664655368293"), exactly(1) + ).respond( + response().withStatusCode(204) + ); + + ExecOut out = exec(format, mock, "datasets", "disable-version", "-w", "249664655368293", "-i", "4D9TP0w2pM0qmwqVHgrgBK", "--dataset-version", "2"); + + assertOutput(format, out, new DatasetVersionDisabled("4D9TP0w2pM0qmwqVHgrgBK", 2L, "249664655368293")); + } + + @ParameterizedTest + @EnumSource(OutputType.class) + void testVersionsShowsDisabled(OutputType format, MockServerClient mock) throws JsonProcessingException { + String versions = """ + {"versions": [ + {"datasetId": "4D9TP0w2pM0qmwqVHgrgBK", "version": 1, "hasHeader": true, "mediaType": "text/csv", "disabled": true, + "fileName": "samples.csv", + "url": "https://api/workspaces/249664655368293/datasets/4D9TP0w2pM0qmwqVHgrgBK/v/1/n/samples.csv"} + ]} + """; + mock.when( + request().withMethod("GET").withPath("/datasets/4D9TP0w2pM0qmwqVHgrgBK/metadata") + .withQueryStringParameter("workspaceId", "249664655368293"), exactly(1) + ).respond( + response().withStatusCode(200).withBody(loadResource("datasets/dataset_metadata")).withContentType(MediaType.APPLICATION_JSON) + ); + mock.when( + request().withMethod("GET").withPath("/datasets/4D9TP0w2pM0qmwqVHgrgBK/versions") + .withQueryStringParameter("workspaceId", "249664655368293"), exactly(1) + ).respond( + response().withStatusCode(200).withBody(versions).withContentType(MediaType.APPLICATION_JSON) + ); + + ExecOut out = exec(format, mock, "datasets", "view", "-w", "249664655368293", "-i", "4D9TP0w2pM0qmwqVHgrgBK", "versions"); + + assertOutput(format, out, new DatasetVersionsList(parseJson(versions, ListDatasetVersionsResponse.class).getVersions(), "4D9TP0w2pM0qmwqVHgrgBK", "249664655368293")); + if (format == OutputType.console) { + assertTrue(out.stdOut.contains("Disabled"), out.stdOut); + } + } } diff --git a/src/test/java/io/seqera/tower/cli/managedidentities/ManagedIdentitiesCmdTest.java b/src/test/java/io/seqera/tower/cli/managedidentities/ManagedIdentitiesCmdTest.java new file mode 100644 index 000000000..d3ed577d6 --- /dev/null +++ b/src/test/java/io/seqera/tower/cli/managedidentities/ManagedIdentitiesCmdTest.java @@ -0,0 +1,303 @@ +/* + * 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.managedidentities; + +import io.seqera.tower.cli.BaseCmdTest; +import io.seqera.tower.cli.commands.enums.OutputType; +import io.seqera.tower.cli.responses.managedidentities.ManagedCredentialsAdded; +import io.seqera.tower.cli.responses.managedidentities.ManagedCredentialsDeleted; +import io.seqera.tower.cli.responses.managedidentities.ManagedCredentialsList; +import io.seqera.tower.cli.responses.managedidentities.ManagedCredentialsUpdated; +import io.seqera.tower.cli.responses.managedidentities.ManagedIdentitiesList; +import io.seqera.tower.cli.responses.managedidentities.ManagedIdentityAdded; +import io.seqera.tower.cli.responses.managedidentities.ManagedIdentityDeleted; +import io.seqera.tower.cli.responses.managedidentities.ManagedIdentityUpdated; +import io.seqera.tower.cli.responses.managedidentities.ManagedIdentityView; +import io.seqera.tower.model.ListManagedCredentialsRespDto; +import io.seqera.tower.model.ManagedIdentityDbDtoAbstractGridConfig; +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.matchers.MatchType; +import org.mockserver.model.MediaType; + +import java.io.IOException; +import java.util.List; +import java.util.Collections; + +import static io.seqera.tower.cli.utils.JsonHelper.parseJson; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockserver.matchers.Times.exactly; +import static org.mockserver.model.HttpRequest.request; +import static org.mockserver.model.HttpResponse.response; +import static org.mockserver.model.JsonBody.json; + +class ManagedIdentitiesCmdTest extends BaseCmdTest { + + private static final String ORG_ID = "27736513644467"; + private static final String IDENTITY = "{\"id\":31,\"name\":\"hpc1\",\"platform\":\"slurm-platform\",\"config\":{\"discriminator\":\"slurm-platform\",\"hostName\":\"login.example.com\",\"port\":22}}"; + private static final String IDENTITIES = "{\"managedIdentities\":[" + IDENTITY + ",{\"id\":32,\"name\":\"hpc2\",\"platform\":\"lsf-platform\",\"config\":{\"discriminator\":\"lsf-platform\",\"hostName\":\"lsf.example.com\",\"port\":2222}}],\"totalSize\":2}"; + private static final String OWN_ROW = "{\"managedCredentialsId\":41,\"userId\":1264,\"userName\":\"jordi\",\"provider\":\"ssh\",\"metadata\":{\"discriminator\":\"ssh\",\"userName\":\"jordi_hpc\"}}"; + private static final String MISSING_ROW = "{\"managedCredentialsId\":null,\"userId\":77,\"userName\":\"alice\",\"firstName\":\"Alice\",\"lastName\":\"Doe\"}"; + private static final String CREDENTIALS = "{\"managedCredentials\":[" + OWN_ROW + "," + MISSING_ROW + "],\"totalSize\":2}"; + + @BeforeEach + void mockOrganization(MockServerClient mock) { + mock.when( + request().withMethod("GET").withPath("/user-info") + ).respond( + response().withStatusCode(200).withBody(loadResource("user")).withContentType(MediaType.APPLICATION_JSON) + ); + mock.when( + request().withMethod("GET").withPath("/user/1264/workspaces") + ).respond( + response().withStatusCode(200).withBody(loadResource("workspaces/workspaces_list")).withContentType(MediaType.APPLICATION_JSON) + ); + } + + private void mockIdentities(MockServerClient mock) { + mock.when( + request().withMethod("GET").withPath("/identities").withQueryStringParameter("orgId", ORG_ID), exactly(1) + ).respond( + response().withStatusCode(200).withBody(IDENTITIES).withContentType(MediaType.APPLICATION_JSON) + ); + } + + private void mockCredentials(MockServerClient mock, String search) { + mock.when( + search == null + ? request().withMethod("GET").withPath("/identities/31/credentials").withQueryStringParameter("orgId", ORG_ID) + : request().withMethod("GET").withPath("/identities/31/credentials").withQueryStringParameter("orgId", ORG_ID).withQueryStringParameter("search", search), + exactly(1) + ).respond( + response().withStatusCode(200).withBody(CREDENTIALS).withContentType(MediaType.APPLICATION_JSON) + ); + } + + @ParameterizedTest + @EnumSource(OutputType.class) + void testList(OutputType format, MockServerClient mock) throws IOException { + mockIdentities(mock); + + ExecOut out = exec(format, mock, "managed-identities", "list", "-o", "organization1"); + + assertOutput(format, out, new ManagedIdentitiesList("organization1", + parseJson(IDENTITIES, io.seqera.tower.model.ListManagedIdentitiesResponse.class).getManagedIdentities())); + } + + @ParameterizedTest + @EnumSource(OutputType.class) + void testAdd(OutputType format, MockServerClient mock) { + mock.when( + request().withMethod("POST").withPath("/identities").withQueryStringParameter("orgId", ORG_ID) + .withBody(json("{\"name\":\"hpc1\",\"platform\":\"slurm-platform\",\"config\":{\"discriminator\":\"slurm-platform\",\"hostName\":\"login.example.com\",\"port\":22}}", MatchType.ONLY_MATCHING_FIELDS)), + exactly(1) + ).respond( + response().withStatusCode(200).withBody(IDENTITY).withContentType(MediaType.APPLICATION_JSON) + ); + + ExecOut out = exec(format, mock, "managed-identities", "add", "-o", "organization1", "-n", "hpc1", "-p", "slurm", "-H", "login.example.com"); + + assertOutput(format, out, new ManagedIdentityAdded("organization1", 31L, "hpc1")); + } + + @ParameterizedTest + @EnumSource(OutputType.class) + void testViewByName(OutputType format, MockServerClient mock) throws IOException { + mockIdentities(mock); + + ExecOut out = exec(format, mock, "managed-identities", "view", "-o", "organization1", "-n", "hpc1"); + + assertOutput(format, out, new ManagedIdentityView("organization1", parseJson(IDENTITY, ManagedIdentityDbDtoAbstractGridConfig.class))); + } + + @ParameterizedTest + @EnumSource(OutputType.class) + void testUpdate(OutputType format, MockServerClient mock) { + mockIdentities(mock); + // Keeps the current name and port, sends the platform so Platform can read the config + mock.when( + request().withMethod("PUT").withPath("/identities/31").withQueryStringParameter("orgId", ORG_ID) + .withBody(json("{\"managedIdentity\":{\"name\":\"hpc1\",\"platform\":\"slurm-platform\",\"config\":{\"discriminator\":\"slurm-platform\",\"hostName\":\"new.example.com\",\"port\":22}}}", MatchType.ONLY_MATCHING_FIELDS)), + exactly(1) + ).respond( + response().withStatusCode(204) + ); + + ExecOut out = exec(format, mock, "managed-identities", "update", "-o", "organization1", "-i", "31", "-H", "new.example.com"); + + assertOutput(format, out, new ManagedIdentityUpdated("organization1", 31L, "hpc1")); + } + + @Test + void testUpdateWithoutChanges(MockServerClient mock) { + ExecOut out = exec(mock, "managed-identities", "update", "-o", "organization1", "-i", "31"); + + assertEquals(1, out.exitCode); + assertTrue(out.stdErr.contains("Nothing to update"), out.stdErr); + } + + @ParameterizedTest + @EnumSource(OutputType.class) + void testDeleteForce(OutputType format, MockServerClient mock) { + mockIdentities(mock); + mock.when( + request().withMethod("DELETE").withPath("/identities/32").withQueryStringParameter("orgId", ORG_ID).withQueryStringParameter("checked", "false"), exactly(1) + ).respond( + response().withStatusCode(204) + ); + + ExecOut out = exec(format, mock, "managed-identities", "delete", "-o", "organization1", "-n", "hpc2", "--force"); + + assertOutput(format, out, new ManagedIdentityDeleted("organization1", 32L, "hpc2")); + } + + @Test + void testDeleteInUse(MockServerClient mock) { + mockIdentities(mock); + mock.when( + request().withMethod("DELETE").withPath("/identities/31").withQueryStringParameter("checked", "true"), exactly(1) + ).respond( + response().withStatusCode(409).withBody("{\"managedCredentialsId\":\"41\",\"conflicts\":[{\"type\":\"workflow\",\"id\":\"abc\",\"name\":\"happy_turing\"}]}").withContentType(MediaType.APPLICATION_JSON) + ); + + ExecOut out = exec(mock, "managed-identities", "delete", "-o", "organization1", "-i", "31"); + + assertEquals(1, out.exitCode); + assertTrue(out.stdErr.contains("workflow 'happy_turing' (abc)"), out.stdErr); + assertTrue(out.stdErr.contains("Use --force"), out.stdErr); + } + + @ParameterizedTest + @EnumSource(OutputType.class) + void testCredentialsList(OutputType format, MockServerClient mock) throws IOException { + mockIdentities(mock); + mockCredentials(mock, null); + + ExecOut out = exec(format, mock, "managed-identities", "credentials", "-o", "organization1", "-n", "hpc1"); + + assertOutput(format, out, new ManagedCredentialsList("organization1", "hpc1", List.of( + parseJson(OWN_ROW, ListManagedCredentialsRespDto.class), + parseJson(MISSING_ROW, ListManagedCredentialsRespDto.class) + ))); + } + + @Test + void testCredentialsListFetchesEveryPage(MockServerClient mock) { + mockIdentities(mock); + String fullPage = "{\"managedCredentials\":[" + String.join(",", Collections.nCopies(100, MISSING_ROW)) + "],\"totalSize\":101}"; + mock.when( + request().withMethod("GET").withPath("/identities/31/credentials").withQueryStringParameter("offset", "0").withQueryStringParameter("max", "100"), exactly(1) + ).respond(response().withStatusCode(200).withBody(fullPage).withContentType(MediaType.APPLICATION_JSON)); + mock.when( + request().withMethod("GET").withPath("/identities/31/credentials").withQueryStringParameter("offset", "100").withQueryStringParameter("max", "100"), exactly(1) + ).respond(response().withStatusCode(200).withBody("{\"managedCredentials\":[" + OWN_ROW + "],\"totalSize\":101}").withContentType(MediaType.APPLICATION_JSON)); + + ExecOut out = exec(OutputType.json, mock, "managed-identities", "credentials", "-o", "organization1", "-n", "hpc1"); + + assertEquals(0, out.exitCode, out.stdErr); + assertEquals(101, out.stdOut.split("\"userName\"", -1).length - 1, out.stdOut); + } + + @ParameterizedTest + @EnumSource(OutputType.class) + void testCredentialsAddForSelf(OutputType format, MockServerClient mock) throws IOException { + mockIdentities(mock); + mock.when( + request().withMethod("POST").withPath("/identities/31/credentials").withQueryStringParameter("orgId", ORG_ID) + .withBody(json("{\"provider\":\"ssh\",\"credentials\":{\"provider\":\"ssh\",\"keys\":{\"privateKey\":\"private_key\",\"passphrase\":\"secret\"}},\"metadata\":{\"userName\":\"jordi_hpc\"}}", MatchType.ONLY_MATCHING_FIELDS)), + exactly(1) + ).respond( + response().withStatusCode(200).withBody("{\"managedCredentials\":{\"id\":41}}").withContentType(MediaType.APPLICATION_JSON) + ); + + ExecOut out = exec(format, mock, "managed-identities", "credentials", "-o", "organization1", "-n", "hpc1", + "add", "-l", "jordi_hpc", "-k", tempFile("private_key", "id_rsa", ""), "-p", "secret"); + + assertOutput(format, out, new ManagedCredentialsAdded("hpc1", 41L)); + mock.verify(request().withMethod("POST").withPath("/identities/31/credentials").withQueryStringParameter("userId", ".*"), org.mockserver.verify.VerificationTimes.exactly(0)); + } + + @ParameterizedTest + @EnumSource(OutputType.class) + void testCredentialsAddForMember(OutputType format, MockServerClient mock) throws IOException { + mockIdentities(mock); + mockCredentials(mock, "alice"); + mock.when( + request().withMethod("POST").withPath("/identities/31/credentials").withQueryStringParameter("orgId", ORG_ID).withQueryStringParameter("userId", "77") + .withBody(json("{\"metadata\":{\"userName\":\"alice_hpc\"}}", MatchType.ONLY_MATCHING_FIELDS)), + exactly(1) + ).respond( + response().withStatusCode(200).withBody("{\"managedCredentials\":{\"id\":42}}").withContentType(MediaType.APPLICATION_JSON) + ); + + ExecOut out = exec(format, mock, "managed-identities", "credentials", "-o", "organization1", "-i", "31", + "add", "-m", "alice", "-l", "alice_hpc", "-k", tempFile("private_key", "id_rsa", "")); + + assertOutput(format, out, new ManagedCredentialsAdded("hpc1", 42L)); + } + + @ParameterizedTest + @EnumSource(OutputType.class) + void testCredentialsUpdateForSelf(OutputType format, MockServerClient mock) throws IOException { + mockIdentities(mock); + mockCredentials(mock, "jordi"); + mock.when( + request().withMethod("PUT").withPath("/identities/31/credentials/41").withQueryStringParameter("orgId", ORG_ID) + .withBody(json("{\"provider\":\"ssh\",\"credentials\":{\"provider\":\"ssh\",\"keys\":{\"privateKey\":\"new_key\"}},\"metadata\":{\"userName\":\"jordi2\"}}", MatchType.ONLY_MATCHING_FIELDS)), + exactly(1) + ).respond( + response().withStatusCode(204) + ); + + ExecOut out = exec(format, mock, "managed-identities", "credentials", "-o", "organization1", "-n", "hpc1", + "update", "-l", "jordi2", "-k", tempFile("new_key", "id_rsa", "")); + + assertOutput(format, out, new ManagedCredentialsUpdated("hpc1", 41L)); + } + + @ParameterizedTest + @EnumSource(OutputType.class) + void testCredentialsDeleteForce(OutputType format, MockServerClient mock) { + mockIdentities(mock); + mockCredentials(mock, "jordi"); + mock.when( + request().withMethod("DELETE").withPath("/identities/31/credentials/41").withQueryStringParameter("orgId", ORG_ID).withQueryStringParameter("checked", "false"), exactly(1) + ).respond( + response().withStatusCode(204) + ); + + ExecOut out = exec(format, mock, "managed-identities", "credentials", "-o", "organization1", "-n", "hpc1", "delete", "--force"); + + assertOutput(format, out, new ManagedCredentialsDeleted("hpc1", 41L)); + } + + @Test + void testCredentialsDeleteMissing(MockServerClient mock) { + mockIdentities(mock); + mockCredentials(mock, "alice"); + + ExecOut out = exec(mock, "managed-identities", "credentials", "-o", "organization1", "-n", "hpc1", "delete", "-m", "alice"); + + assertEquals(1, out.exitCode); + assertTrue(out.stdErr.contains("Member 'alice' has no credentials for managed identity 'hpc1'"), out.stdErr); + } +} diff --git a/src/test/java/io/seqera/tower/cli/organizations/OrganizationsCmdTest.java b/src/test/java/io/seqera/tower/cli/organizations/OrganizationsCmdTest.java index e2838d569..12579cb84 100644 --- a/src/test/java/io/seqera/tower/cli/organizations/OrganizationsCmdTest.java +++ b/src/test/java/io/seqera/tower/cli/organizations/OrganizationsCmdTest.java @@ -16,6 +16,8 @@ package io.seqera.tower.cli.organizations; +import io.seqera.tower.model.DescribeOrganizationQuotasResponse; +import io.seqera.tower.cli.responses.organizations.OrganizationQuotasView; import com.fasterxml.jackson.core.JsonProcessingException; import io.seqera.tower.cli.BaseCmdTest; import io.seqera.tower.cli.commands.enums.OutputType; @@ -405,4 +407,24 @@ void testUpdateError(MockServerClient mock) { assertEquals("", out.stdOut); assertEquals(1, out.exitCode); } + + @ParameterizedTest + @EnumSource(OutputType.class) + void testQuotas(OutputType format, MockServerClient mock) throws JsonProcessingException { + mock.when(request().withMethod("GET").withPath("/user-info")) + .respond(response().withStatusCode(200).withBody(loadResource("user")).withContentType(MediaType.APPLICATION_JSON)); + mock.when(request().withMethod("GET").withPath("/user/1264/workspaces")) + .respond(response().withStatusCode(200).withBody(loadResource("workspaces/workspaces_list")).withContentType(MediaType.APPLICATION_JSON)); + mock.when(request().withMethod("GET").withPath("/orgs/27736513644467"), exactly(1)) + .respond(response().withStatusCode(200).withBody(loadResource("organizations/organizations_view")).withContentType(MediaType.APPLICATION_JSON)); + String body = """ + {"quotas": {"maxWorkspaces": 10, "maxMembers": 50, "maxTeams": 5, "maxCustomRolesPerOrg": 20, "maxRuns": 1000}} + """; + mock.when(request().withMethod("GET").withPath("/orgs/27736513644467/quotas"), exactly(1)) + .respond(response().withStatusCode(200).withBody(body).withContentType(MediaType.APPLICATION_JSON)); + + ExecOut out = exec(format, mock, "organizations", "quotas", "-n", "organization1"); + + assertOutput(format, out, new OrganizationQuotasView("organization1", parseJson(body, DescribeOrganizationQuotasResponse.class).getQuotas())); + } } diff --git a/src/test/java/io/seqera/tower/cli/pipelines/PipelinesCmdTest.java b/src/test/java/io/seqera/tower/cli/pipelines/PipelinesCmdTest.java index 92595831e..b3f8577e2 100644 --- a/src/test/java/io/seqera/tower/cli/pipelines/PipelinesCmdTest.java +++ b/src/test/java/io/seqera/tower/cli/pipelines/PipelinesCmdTest.java @@ -35,6 +35,7 @@ import io.seqera.tower.cli.responses.pipelines.PipelinesExport; import io.seqera.tower.cli.responses.pipelines.PipelinesList; import io.seqera.tower.cli.responses.pipelines.PipelinesUpdated; +import io.seqera.tower.cli.responses.pipelines.PipelinesSchema; import io.seqera.tower.cli.responses.pipelines.PipelinesView; import io.seqera.tower.cli.utils.ModelHelper; import io.seqera.tower.cli.utils.PaginationInfo; @@ -42,6 +43,7 @@ import io.seqera.tower.model.CreatePipelineRequest; import io.seqera.tower.model.LaunchDbDto; import io.seqera.tower.model.PipelineDbDto; +import io.seqera.tower.model.PipelineSchemaAttributes; import io.seqera.tower.model.WorkflowLaunchRequest; import org.apache.commons.lang3.StringUtils; import org.junit.jupiter.api.Test; @@ -1185,7 +1187,7 @@ void testAddWithSyntaxParser(MockServerClient mock) { } @Test - void testAddWithNextflowVersionAndOutputDir(MockServerClient mock) { + void testAddWithNextflowVersionAndOutputDir(MockServerClient mock) throws IOException { mock.reset(); @@ -1208,6 +1210,7 @@ void testAddWithNextflowVersionAndOutputDir(MockServerClient mock) { "launch":{ "pipeline":"https://github.com/pditommaso/nf-sleep", "nextflowVersion":"26.04.6", + "towerConfig":"reports: {}", "outputDir":"s3://nextflow-ci/outputs" } }""" @@ -1218,6 +1221,7 @@ void testAddWithNextflowVersionAndOutputDir(MockServerClient mock) { ExecOut out = exec(mock, "pipelines", "add", "-n", "sleep_one_minute", "--nextflow-version", "26.04.6", "--output-dir", "s3://nextflow-ci/outputs", + "--tower-config", tempFile("reports: {}", "tower", "yml"), "https://github.com/pditommaso/nf-sleep"); assertEquals("", out.stdErr); @@ -1238,7 +1242,7 @@ void testAddWithInvalidSyntaxParser(MockServerClient mock) { } @Test - void testUpdateKeepsStoredSyntaxParser(MockServerClient mock) { + void testUpdateKeepsStoredConfigurationLabelsAndIcon(MockServerClient mock) { mock.reset(); @@ -1249,7 +1253,8 @@ void testUpdateKeepsStoredSyntaxParser(MockServerClient mock) { ); mock.when( - request().withMethod("GET").withPath("/pipelines/217997727159863"), exactly(1) + request().withMethod("GET").withPath("/pipelines/217997727159863") + .withQueryStringParameter("attributes", "labels"), exactly(1) ).respond( response().withStatusCode(200).withBody(""" { @@ -1257,6 +1262,8 @@ void testUpdateKeepsStoredSyntaxParser(MockServerClient mock) { "pipelineId": 217997727159863, "name": "sleep_one_minute", "repository": "https://github.com/pditommaso/nf-sleep", + "icon": "https://avatars.example.com/custom.png", + "labels": [{"id": 11, "name": "team", "value": "rnd", "resource": true}, {"id": 12, "name": "prod", "resource": false}], "version": {"id": "default-ver", "name": "sleep_one_minute-1", "isDefault": true} } }""").withContentType(MediaType.APPLICATION_JSON) @@ -1267,19 +1274,22 @@ void testUpdateKeepsStoredSyntaxParser(MockServerClient mock) { .withQueryStringParameter("versionId", "default-ver"), exactly(1) ).respond( response().withStatusCode(200).withContentType(MediaType.APPLICATION_JSON) - .withBody("{\"launch\":{\"id\":\"5nmCvXcarkvv8tELMF4KyY\",\"computeEnvId\":null,\"computeEnv\":{\"id\":\"vYOK4vn7spw7bHHWBDXZ2\",\"name\":\"demo\",\"platform\":\"aws-batch\",\"status\":\"AVAILABLE\"},\"pipeline\":\"https://github.com/pditommaso/nf-sleep\",\"workDir\":\"s3://nextflow-ci/jordeu\",\"revision\":\"main\",\"syntaxParser\":\"v2\",\"nextflowVersion\":\"26.04.6\"}}") + .withBody("{\"launch\":{\"id\":\"5nmCvXcarkvv8tELMF4KyY\",\"computeEnvId\":null,\"computeEnv\":{\"id\":\"vYOK4vn7spw7bHHWBDXZ2\",\"name\":\"demo\",\"platform\":\"aws-batch\",\"status\":\"AVAILABLE\"},\"pipeline\":\"https://github.com/pditommaso/nf-sleep\",\"workDir\":\"s3://nextflow-ci/jordeu\",\"revision\":\"main\",\"syntaxParser\":\"v2\",\"nextflowVersion\":\"26.04.6\",\"towerConfig\":\"reports: {}\"}}") ); mock.when( request().withMethod("POST").withPath("/pipelines/217997727159863/versions/default-ver") .withBody(json(""" { + "icon":"https://avatars.example.com/custom.png", + "labelIds":[11,12], "launch":{ "computeEnvId":"vYOK4vn7spw7bHHWBDXZ2", "pipeline":"https://github.com/pditommaso/nf-sleep", "revision":"main", "syntaxParser":"v2", - "nextflowVersion":"26.04.6" + "nextflowVersion":"26.04.6", + "towerConfig":"reports: {}" } }""")), exactly(1) ).respond( @@ -1988,6 +1998,54 @@ void testRemoveLabels(OutputType format, MockServerClient mock) { // --- Version ID / Version Name wiring tests --- + @ParameterizedTest + @EnumSource(OutputType.class) + void testSchema(OutputType format, MockServerClient mock) { + mock.reset(); + + mock.when( + request().withMethod("GET").withPath("/pipelines/217997727159863"), exactly(1) + ).respond( + response().withStatusCode(200).withBody("{\"pipeline\":{\"pipelineId\":217997727159863,\"name\":\"sleep_one_minute\"}}").withContentType(MediaType.APPLICATION_JSON) + ); + + mock.when( + request().withMethod("GET").withPath("/pipelines/217997727159863/schema") + .withQueryStringParameter("attributes", "schema"), exactly(1) + ).respond( + response().withStatusCode(200).withBody("{\"schema\":\"{\\\"title\\\":\\\"nf-sleep\\\"}\"}").withContentType(MediaType.APPLICATION_JSON) + ); + + ExecOut out = exec(format, mock, "pipelines", "schema", "-i", "217997727159863"); + assertOutput(format, out, new PipelinesSchema(PipelineSchemaAttributes.schema, "{\"title\":\"nf-sleep\"}")); + if (format == OutputType.console) { + assertEquals("{\"title\":\"nf-sleep\"}", out.stdOut); + } + } + + @Test + void testSchemaParamsNotFound(MockServerClient mock) { + mock.reset(); + + mock.when( + request().withMethod("GET").withPath("/pipelines/217997727159863"), exactly(1) + ).respond( + response().withStatusCode(200).withBody("{\"pipeline\":{\"pipelineId\":217997727159863,\"name\":\"sleep_one_minute\"}}").withContentType(MediaType.APPLICATION_JSON) + ); + + mock.when( + request().withMethod("GET").withPath("/pipelines/217997727159863/schema") + .withQueryStringParameter("attributes", "params"), exactly(1) + ).respond( + response().withStatusCode(204) + ); + + ExecOut out = exec(mock, "pipelines", "schema", "-i", "217997727159863", "--params"); + + assertEquals(errorMessage(out.app, new TowerException("No params found for pipeline 'sleep_one_minute'")), out.stdErr); + assertEquals(1, out.exitCode); + } + @Test void testViewWithVersionId(MockServerClient mock) throws JsonProcessingException { diff --git a/src/test/java/io/seqera/tower/cli/runs/RunsCmdTest.java b/src/test/java/io/seqera/tower/cli/runs/RunsCmdTest.java index d4288ddd6..efe938f42 100644 --- a/src/test/java/io/seqera/tower/cli/runs/RunsCmdTest.java +++ b/src/test/java/io/seqera/tower/cli/runs/RunsCmdTest.java @@ -28,6 +28,9 @@ import io.seqera.tower.cli.exceptions.TowerException; import io.seqera.tower.cli.responses.runs.RunCanceled; import io.seqera.tower.cli.responses.runs.RunDeleted; +import io.seqera.tower.cli.responses.runs.RunLog; +import io.seqera.tower.cli.responses.runs.RunStarred; +import io.seqera.tower.cli.responses.runs.RunsDeleted; import io.seqera.tower.cli.responses.runs.RunDump; import io.seqera.tower.cli.responses.runs.RunFileDownloaded; import io.seqera.tower.cli.responses.runs.RunList; @@ -56,6 +59,7 @@ import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.EnumSource; import org.mockserver.client.MockServerClient; +import org.mockserver.matchers.MatchType; import org.mockserver.model.MediaType; import org.mockserver.verify.VerificationTimes; @@ -95,6 +99,37 @@ void testDelete(OutputType format, MockServerClient mock) { assertOutput(format, out, new RunDeleted("5dAZoXrcmZXRO4", USER_WORKSPACE_NAME)); } + @ParameterizedTest + @EnumSource(OutputType.class) + void testDeleteMany(OutputType format, MockServerClient mock) { + mock.when( + request().withMethod("POST").withPath("/workflow/delete") + .withQueryStringParameter("force", "true") + .withBody(json("{\"workflowIds\":[\"5dAZoXrcmZXRO4\",\"3xFx5yTHcIWQl\",\"1Gm7JBQAcU6pVO\"]}", MatchType.STRICT)), exactly(1) + ).respond( + response().withStatusCode(200).withBody("{\"failedWorkflowIds\":[]}").withContentType(MediaType.APPLICATION_JSON) + ); + + ExecOut out = exec(format, mock, "runs", "delete", "-i", "5dAZoXrcmZXRO4,3xFx5yTHcIWQl", "-i", "1Gm7JBQAcU6pVO", "--force"); + assertOutput(format, out, new RunsDeleted(List.of("5dAZoXrcmZXRO4", "3xFx5yTHcIWQl", "1Gm7JBQAcU6pVO"), List.of(), USER_WORKSPACE_NAME)); + } + + @Test + void testDeleteManyPartialFailure(MockServerClient mock) { + mock.when( + request().withMethod("POST").withPath("/workflow/delete"), exactly(1) + ).respond( + response().withStatusCode(200).withBody("{\"failedWorkflowIds\":[\"3xFx5yTHcIWQl\"]}").withContentType(MediaType.APPLICATION_JSON) + ); + + ExecOut out = exec(mock, "runs", "delete", "-i", "5dAZoXrcmZXRO4,3xFx5yTHcIWQl"); + + assertEquals("", out.stdErr); + assertEquals(chop(new RunsDeleted(List.of("5dAZoXrcmZXRO4"), List.of("3xFx5yTHcIWQl"), USER_WORKSPACE_NAME).toString()), out.stdOut); + assertTrue(out.stdOut.contains("'3xFx5yTHcIWQl' could not be deleted"), out.stdOut); + assertEquals(1, out.exitCode); + } + @Test void testDeleteForbidden(MockServerClient mock) { mock.when( @@ -123,6 +158,74 @@ void testCancel(OutputType format, MockServerClient mock) { assertOutput(format, out, new RunCanceled("5dAZoXrcmZXRO4", USER_WORKSPACE_NAME)); } + @ParameterizedTest + @EnumSource(OutputType.class) + void testStar(OutputType format, MockServerClient mock) { + mock.when( + request().withMethod("POST").withPath("/workflow/5dAZoXrcmZXRO4/star"), exactly(1) + ).respond( + response().withStatusCode(200).withBody("{\"workflowId\":\"5dAZoXrcmZXRO4\"}").withContentType(MediaType.APPLICATION_JSON) + ); + + ExecOut out = exec(format, mock, "runs", "star", "-i", "5dAZoXrcmZXRO4"); + assertOutput(format, out, new RunStarred("5dAZoXrcmZXRO4", USER_WORKSPACE_NAME, true)); + } + + @ParameterizedTest + @EnumSource(OutputType.class) + void testUnstar(OutputType format, MockServerClient mock) { + mock.when( + request().withMethod("DELETE").withPath("/workflow/5dAZoXrcmZXRO4/star"), exactly(1) + ).respond( + response().withStatusCode(204) + ); + + ExecOut out = exec(format, mock, "runs", "unstar", "-i", "5dAZoXrcmZXRO4"); + assertOutput(format, out, new RunStarred("5dAZoXrcmZXRO4", USER_WORKSPACE_NAME, false)); + } + + @Test + void testUnstarNotStarred(MockServerClient mock) { + mock.when( + request().withMethod("DELETE").withPath("/workflow/5dAZoXrcmZXRO4/star"), exactly(1) + ).respond( + response().withStatusCode(404).withBody("{\"message\":\"Workflow '5dAZoXrcmZXRO4' is not starred\"}").withContentType(MediaType.APPLICATION_JSON) + ); + + ExecOut out = exec(mock, "runs", "unstar", "-i", "5dAZoXrcmZXRO4"); + + assertEquals(errorMessage(out.app, new ApiException(404, "", null, "{\"message\":\"Workflow '5dAZoXrcmZXRO4' is not starred\"}")), out.stdErr); + assertEquals(1, out.exitCode); + } + + @Test + void testStarSendsAuthorization(MockServerClient mock) { + mock.when( + request().withMethod("POST").withPath("/workflow/5dAZoXrcmZXRO4/star"), exactly(1) + ).respond( + response().withStatusCode(200).withBody("{\"workflowId\":\"5dAZoXrcmZXRO4\"}").withContentType(MediaType.APPLICATION_JSON) + ); + + exec(mock, "runs", "star", "-i", "5dAZoXrcmZXRO4"); + + mock.verify(request().withMethod("POST").withPath("/workflow/5dAZoXrcmZXRO4/star") + .withHeader("Authorization", "Bearer " + token()), VerificationTimes.atLeast(1)); + } + + @Test + void testUnstarSendsAuthorization(MockServerClient mock) { + mock.when( + request().withMethod("DELETE").withPath("/workflow/5dAZoXrcmZXRO4/star"), exactly(1) + ).respond( + response().withStatusCode(204) + ); + + exec(mock, "runs", "unstar", "-i", "5dAZoXrcmZXRO4"); + + mock.verify(request().withMethod("DELETE").withPath("/workflow/5dAZoXrcmZXRO4/star") + .withHeader("Authorization", "Bearer " + token()), VerificationTimes.atLeast(1)); + } + @Test void testCancelForbidden(MockServerClient mock) { mock.when( @@ -573,6 +676,7 @@ void testRelaunchKeepsOriginalLaunchConfiguration(MockServerClient mock) { "pipelineSchemaId":42, "resume":true, "nextflowVersion":"26.04.6", + "towerConfig":"reports: {}", "outputDir":"/outputs", "syntaxParser":"v2" } @@ -618,6 +722,43 @@ void testRelaunchOverridingSyntaxParser(MockServerClient mock) { .withBody(json("{\"launch\":{\"syntaxParser\":\"v1\"}}")), VerificationTimes.exactly(1)); } + @Test + void testRelaunchOverridingTowerConfig(MockServerClient mock) throws IOException { + mock.reset(); + + mock.when( + request().withMethod("POST").withPath("/workflow/launch"), exactly(1) + ).respond( + response().withStatusCode(200).withBody(loadResource("workflow_launch")).withContentType(MediaType.APPLICATION_JSON) + ); + + mock.when( + request().withMethod("GET").withPath("/workflow/5mDfiUtqyptDib"), exactly(1) + ).respond( + response().withStatusCode(200).withBody(loadResource("workflow_view")).withContentType(MediaType.APPLICATION_JSON) + ); + + mock.when( + request().withMethod("GET").withPath("/workflow/5mDfiUtqyptDib/launch"), exactly(1) + ).respond( + response().withStatusCode(200).withBody(loadResource("runs/workflow_launch_v2")).withContentType(MediaType.APPLICATION_JSON) + ); + + mock.when( + request().withMethod("GET").withPath("/user-info"), exactly(1) + ).respond( + response().withStatusCode(200).withBody(loadResource("user")).withContentType(MediaType.APPLICATION_JSON) + ); + + ExecOut out = exec(mock, "runs", "relaunch", "-i", "5mDfiUtqyptDib", + "--tower-config", tempFile("reports: []", "tower", "yml")); + + assertEquals("", out.stdErr); + assertEquals(0, out.exitCode); + mock.verify(request().withMethod("POST").withPath("/workflow/launch") + .withBody(json("{\"launch\":{\"towerConfig\":\"reports: []\"}}")), VerificationTimes.exactly(1)); + } + @Test void testInvalidAuth(MockServerClient mock) { mock.when( @@ -678,6 +819,63 @@ void testDownloadTaskLog(MockServerClient mock) throws IOException { assertEquals(0, out.exitCode); } + @ParameterizedTest + @EnumSource(OutputType.class) + void testLog(OutputType format, MockServerClient mock) { + mock.when( + request().withMethod("GET").withPath("/workflow/5dAZoXrcmZXRO4/log"), exactly(1) + ).respond( + response().withStatusCode(200).withBody(""" + {"log": {"entries": ["N E X T F L O W ~ version 26.04.6", "Launching `main.nf`"], "pending": true, "truncated": false, "downloads": []}}""").withContentType(MediaType.APPLICATION_JSON) + ); + + ExecOut out = exec(format, mock, "runs", "view", "-i", "5dAZoXrcmZXRO4", "log"); + assertOutput(format, out, new RunLog(List.of("N E X T F L O W ~ version 26.04.6", "Launching `main.nf`"), false, null, null)); + } + + @Test + void testLogPagesWithForwardToken(MockServerClient mock) { + mock.when( + request().withMethod("GET").withPath("/workflow/5dAZoXrcmZXRO4/log").withQueryStringParameter("next", "f/123"), exactly(1) + ).respond( + response().withStatusCode(200).withBody(""" + {"log": {"entries": ["second page"], "truncated": false, "forwardToken": "f/456"}}""").withContentType(MediaType.APPLICATION_JSON) + ); + mock.when( + request().withMethod("GET").withPath("/workflow/5dAZoXrcmZXRO4/log").withQueryStringParameter("next", "f/456"), exactly(1) + ).respond( + response().withStatusCode(200).withBody(""" + {"log": {"entries": [], "truncated": false, "forwardToken": "f/456"}}""").withContentType(MediaType.APPLICATION_JSON) + ); + + ExecOut page = exec(mock, "runs", "view", "-i", "5dAZoXrcmZXRO4", "log", "--next", "f/123"); + assertTrue(page.stdOut.startsWith("second page"), page.stdOut); + assertTrue(page.stdOut.contains("--next f/456"), page.stdOut); + + // An empty page is the end of the available log: no cursor hint even though the stream returns one + ExecOut end = exec(mock, "runs", "view", "-i", "5dAZoXrcmZXRO4", "log", "--next", "f/456"); + assertEquals(0, end.exitCode); + assertTrue(!end.stdOut.contains("--next"), end.stdOut); + } + + @Test + void testTaskLogTruncatedWithMessage(MockServerClient mock) { + mock.when( + request().withMethod("GET").withPath("/workflow/5dAZoXrcmZXRO4/log/5"), exactly(1) + ).respond( + response().withStatusCode(200).withBody(""" + {"log": {"entries": ["Hello"], "pending": false, "truncated": true, "message": "Unable to retrieve output log - Cause: gone"}}""").withContentType(MediaType.APPLICATION_JSON) + ); + + ExecOut out = exec(mock, "runs", "view", "-i", "5dAZoXrcmZXRO4", "log", "-t", "5"); + + assertEquals("", out.stdErr); + assertEquals(0, out.exitCode); + assertEquals(chop(new RunLog(List.of("Hello"), true, "Unable to retrieve output log - Cause: gone", null).toString()), out.stdOut); + assertTrue(out.stdOut.startsWith("Hello"), out.stdOut); + assertTrue(out.stdOut.contains("Log truncated"), out.stdOut); + } + @Test void testDumpRuns(MockServerClient mock) throws IOException { 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 12704ca5d..b1e79c0c0 100644 --- a/src/test/java/io/seqera/tower/cli/studios/StudiosCmdTest.java +++ b/src/test/java/io/seqera/tower/cli/studios/StudiosCmdTest.java @@ -29,6 +29,8 @@ import io.seqera.tower.cli.responses.studios.StudiosList; import io.seqera.tower.cli.responses.studios.StudioCheckpointsList; 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.StudioStartSubmitted; import io.seqera.tower.cli.responses.studios.StudioStopSubmitted; import io.seqera.tower.cli.responses.studios.StudioUpdated; @@ -2851,4 +2853,102 @@ void testAddWithoutTemplateOrUrlFails(MockServerClient mock) { assertTrue(out.stdErr.contains("A studio template is required"), out.stdErr); } + 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) + ); + } + + @ParameterizedTest + @EnumSource(OutputType.class) + void testExtend(OutputType format, MockServerClient mock) { + mockWorkspace(mock); + mock.when( + request().withMethod("POST").withPath("/studios/3e8370e7/lifespan").withQueryStringParameter("workspaceId", "75887156211589"), exactly(1) + ).respond( + response().withStatusCode(200).withBody("{\"sessionId\": \"3e8370e7\", \"effectiveLifespanHours\": 16}").withContentType(MediaType.APPLICATION_JSON) + ); + + ExecOut out = exec(format, mock, "studios", "extend", "-w", "75887156211589", "-i", "3e8370e7"); + + assertOutput(format, out, new StudioLifespanExtended("3e8370e7", "[organization1 / workspace1]", 16)); + mock.verify(request().withMethod("POST").withPath("/studios/3e8370e7/lifespan"), VerificationTimes.once()); + } + + @Test + void testExtendNotApproachingExpiration(MockServerClient mock) { + mockWorkspace(mock); + mock.when( + request().withMethod("POST").withPath("/studios/3e8370e7/lifespan").withQueryStringParameter("workspaceId", "75887156211589"), exactly(1) + ).respond( + response().withStatusCode(400).withBody("{\"message\": \"Studio lifespan can only be extended once it is approaching its scheduled auto-stop.\"}").withContentType(MediaType.APPLICATION_JSON) + ); + + ExecOut out = exec(mock, "studios", "extend", "-w", "75887156211589", "-i", "3e8370e7"); + + assertEquals(1, out.exitCode); + assertTrue(out.stdErr.contains("approaching its scheduled auto-stop"), out.stdErr); + } + + @ParameterizedTest + @EnumSource(OutputType.class) + void testRenameCheckpoint(OutputType format, MockServerClient mock) { + mockWorkspace(mock); + mock.when( + request().withMethod("PUT").withPath("/studios/3e8370e7/checkpoints/1").withQueryStringParameter("workspaceId", "75887156211589") + .withBody(json("{\"name\": \"before-upgrade\"}", MatchType.STRICT)), exactly(1) + ).respond( + response().withStatusCode(200).withBody("{\"id\": 1, \"name\": \"before-upgrade\"}").withContentType(MediaType.APPLICATION_JSON) + ); + + ExecOut out = exec(format, mock, "studios", "rename-checkpoint", "-w", "75887156211589", "-i", "3e8370e7", "--checkpoint-id", "1", "--new-name", "before-upgrade"); + + assertOutput(format, out, new StudioCheckpointUpdated("3e8370e7", "[organization1 / workspace1]", 1L, "before-upgrade")); + mock.verify(request().withMethod("PUT").withPath("/studios/3e8370e7/checkpoints/1"), VerificationTimes.once()); + } + + @Test + void testStartWithWaitShowsProgressWarnings(MockServerClient mock) { + mockWorkspace(mock); + String regionWarning = "Mounting data from regions that differ from the compute environment may result in increased costs."; + String checkpointWarning = "Checkpoint 2 could not be mounted."; + mock.when( + request().withMethod("GET").withPath("/studios/3e8370e7").withQueryStringParameter("workspaceId", "75887156211589"), exactly(1) + ).respond( + response().withStatusCode(200).withBody(loadResource("studios/studios_view_response_studio_stopped")).withContentType(MediaType.APPLICATION_JSON) + ); + mock.when( + request().withMethod("PUT").withPath("/studios/3e8370e7/start").withQueryStringParameter("workspaceId", "75887156211589"), exactly(1) + ).respond( + response().withStatusCode(200).withBody(loadResource("studios/studios_start_response")).withContentType(MediaType.APPLICATION_JSON) + ); + mock.when( + request().withMethod("GET").withPath("/studios/3e8370e7").withQueryStringParameter("workspaceId", "75887156211589"), exactly(2) + ).respond( + response().withStatusCode(200).withBody(new String(loadResource("studios/studios_view_response_studio_starting")) + .replaceFirst("\"warnings\": null", "\"warnings\": [\"" + regionWarning + "\"]")).withContentType(MediaType.APPLICATION_JSON) + ); + // the region warning is still reported once the step succeeds, it must not be printed twice + mock.when( + request().withMethod("GET").withPath("/studios/3e8370e7").withQueryStringParameter("workspaceId", "75887156211589") + ).respond( + response().withStatusCode(200).withBody(new String(loadResource("studios/studios_view_response")) + .replaceFirst("\"warnings\": null", "\"warnings\": [\"" + regionWarning + "\"]") + .replaceFirst("\"warnings\": null", "\"warnings\": [\"" + checkpointWarning + "\"]")).withContentType(MediaType.APPLICATION_JSON) + ); + + ExecOut out = exec(mock, "studios", "start", "-w", "75887156211589", "-i", "3e8370e7", "--wait", "RUNNING"); + + assertEquals(0, out.exitCode, out.stdErr); + assertEquals(1, out.stdOut.split(java.util.regex.Pattern.quote("Warning: " + regionWarning), -1).length - 1, out.stdOut); + assertTrue(out.stdOut.contains("Warning: " + checkpointWarning), out.stdOut); + } + } diff --git a/src/test/java/io/seqera/tower/cli/teams/TeamsCmdTest.java b/src/test/java/io/seqera/tower/cli/teams/TeamsCmdTest.java index 2667d650e..28116771a 100644 --- a/src/test/java/io/seqera/tower/cli/teams/TeamsCmdTest.java +++ b/src/test/java/io/seqera/tower/cli/teams/TeamsCmdTest.java @@ -23,13 +23,19 @@ import io.seqera.tower.cli.exceptions.TowerException; import io.seqera.tower.cli.responses.teams.TeamAdded; import io.seqera.tower.cli.responses.teams.TeamDeleted; +import io.seqera.tower.cli.responses.teams.TeamUpdated; +import io.seqera.tower.cli.responses.teams.TeamView; +import io.seqera.tower.cli.responses.teams.TeamWorkspacesList; import io.seqera.tower.cli.responses.teams.TeamsList; import io.seqera.tower.cli.utils.PaginationInfo; +import io.seqera.tower.model.DescribeTeamResponse; +import io.seqera.tower.model.ListWorkspacesByTeamResponse; import io.seqera.tower.model.TeamDbDto; 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.matchers.MatchType; import org.mockserver.model.MediaType; import java.util.Arrays; @@ -41,6 +47,7 @@ import static org.mockserver.matchers.Times.exactly; import static org.mockserver.model.HttpRequest.request; import static org.mockserver.model.HttpResponse.response; +import static org.mockserver.model.JsonBody.json; class TeamsCmdTest extends BaseCmdTest { @@ -363,4 +370,73 @@ void testDeleteTeam(OutputType format, MockServerClient mock) { ExecOut out = exec(format, mock, "teams", "delete", "-o", "organization1", "-i", "69076469523589"); assertOutput(format, out, new TeamDeleted("organization1", "69076469523589")); } + + private static final String TEAM_PATH = "/orgs/27736513644467/teams/69076469523589"; + + private static final String TEAM = """ + {"team": {"teamId": 69076469523589, "name": "team-test-1", "description": "a new team", "membersCount": 2, + "avatarUrl": "https://tower.example/api/avatars/av123"}} + """; + + private void mockUserWorkspaces(MockServerClient mock) { + mock.when(request().withMethod("GET").withPath("/user-info")) + .respond(response().withStatusCode(200).withBody(loadResource("user")).withContentType(MediaType.APPLICATION_JSON)); + mock.when(request().withMethod("GET").withPath("/user/1264/workspaces")) + .respond(response().withStatusCode(200).withBody(loadResource("workspaces/workspaces_list")).withContentType(MediaType.APPLICATION_JSON)); + } + + private void mockDescribeTeam(MockServerClient mock) { + mock.when(request().withMethod("GET").withPath(TEAM_PATH), exactly(1)) + .respond(response().withStatusCode(200).withBody(TEAM).withContentType(MediaType.APPLICATION_JSON)); + } + + @ParameterizedTest + @EnumSource(OutputType.class) + void testViewByName(OutputType format, MockServerClient mock) throws JsonProcessingException { + mockUserWorkspaces(mock); + mock.when(request().withMethod("GET").withPath("/orgs/27736513644467/teams"), exactly(1)) + .respond(response().withStatusCode(200).withBody(loadResource("teams/teams_list")).withContentType(MediaType.APPLICATION_JSON)); + mockDescribeTeam(mock); + + ExecOut out = exec(format, mock, "teams", "view", "-o", "organization1", "-n", "team-test-1"); + + assertOutput(format, out, new TeamView("organization1", parseJson(TEAM, DescribeTeamResponse.class).getTeam())); + } + + @ParameterizedTest + @EnumSource(OutputType.class) + void testUpdateKeepsUnsetFieldsAndAvatar(OutputType format, MockServerClient mock) { + mockUserWorkspaces(mock); + mockDescribeTeam(mock); + mock.when( + request().withMethod("PUT").withPath(TEAM_PATH) + .withBody(json(""" + {"name": "team-renamed", "description": "a new team", "avatarId": "av123"} + """, MatchType.STRICT)), exactly(1) + ).respond(response().withStatusCode(204)); + + ExecOut out = exec(format, mock, "teams", "update", "-o", "organization1", "-i", "69076469523589", "--new-name", "team-renamed"); + + assertOutput(format, out, new TeamUpdated("organization1", "team-renamed")); + } + + @ParameterizedTest + @EnumSource(OutputType.class) + void testWorkspaces(OutputType format, MockServerClient mock) throws JsonProcessingException { + mockUserWorkspaces(mock); + mockDescribeTeam(mock); + String body = """ + {"workspaces": [{"orgId": 27736513644467, "orgName": "organization1", "workspaceId": 75887156211589, "workspaceName": "workspace1", + "workspaceFullName": "Workspace 1", "participantId": 9, "participantRole": "launch"}], "totalSize": 1} + """; + mock.when( + request().withMethod("GET").withPath(TEAM_PATH + "/workspaces") + .withQueryStringParameter("max", "100") + .withQueryStringParameter("offset", "0"), exactly(1) + ).respond(response().withStatusCode(200).withBody(body).withContentType(MediaType.APPLICATION_JSON)); + + ExecOut out = exec(format, mock, "teams", "workspaces", "-o", "organization1", "-i", "69076469523589"); + + assertOutput(format, out, new TeamWorkspacesList("organization1", "team-test-1", parseJson(body, ListWorkspacesByTeamResponse.class).getWorkspaces(), null)); + } } diff --git a/src/test/resources/runcmd/runs/workflow_launch_v2.json b/src/test/resources/runcmd/runs/workflow_launch_v2.json index caac10040..65bd06705 100644 --- a/src/test/resources/runcmd/runs/workflow_launch_v2.json +++ b/src/test/resources/runcmd/runs/workflow_launch_v2.json @@ -14,7 +14,7 @@ "sessionId": "d77a5ac4-1a25-45d6-bab0-358605abc332", "configProfiles": null, "configText": null, - "towerConfig": null, + "towerConfig": "reports: {}", "paramsText": null, "preRunScript": null, "postRunScript": null,