diff --git a/USAGE.md b/USAGE.md index fd2b9673..ebf6f523 100644 --- a/USAGE.md +++ b/USAGE.md @@ -709,6 +709,26 @@ $ tw data-links browse -w seqeralabs/showcase -i v1-user-6d8f44c239e2a098b3e02e9 FOLDER | technical/ | 0 ``` +## Agents + +Run `tw agents -h` to view the supported operations: `list`, `view`, `add`, `update`, `delete`, `enable`, `disable`, `launch`, and `runs list|view`. Agents must be enabled for the organization. + +```bash +tw agents add -w my-org/my-workspace -n triage --instructions-file triage.md +tw agents launch -w my-org/my-workspace -n triage +tw agents runs list -w my-org/my-workspace +``` + +## Service accounts + +Run `tw service-accounts -h` to view the supported operations: `list`, `add`, `view`, `update`, and `delete`. Service accounts are non-human organization members. Add one to a workspace with `tw participants add -t MEMBER -n `, or set one as an agent's identity with `tw agents add|update --service-account-id`. + +```bash +tw service-accounts add -o my-org -n ci-bot -d "CI pipeline launcher" +tw service-accounts list -o my-org +tw service-accounts list -w my-org/my-workspace +``` + [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/build.gradle b/build.gradle index 9bd3b130..f235f77d 100644 --- a/build.gradle +++ b/build.gradle @@ -82,8 +82,13 @@ license { exclude('conf/**') } +// Jersey's HttpURLConnection connector can only send PATCH by reflecting into these JDK internals +// (HttpUrlConnectorProvider.SET_METHOD_WORKAROUND, enabled by the SDK's ApiClient) +def jerseyPatchOpens = ['java.base/java.net', 'java.base/sun.net.www.protocol.https'] + application { mainClass.set('io.seqera.tower.cli.Tower') + applicationDefaultJvmArgs = jerseyPatchOpens.collect { "--add-opens=${it}=ALL-UNNAMED" } } java { @@ -108,6 +113,8 @@ graalvmNative { buildArgs.add('-H:+AddAllCharsets') buildArgs.add('-H:EnableURLProtocols=https,http') buildArgs.add('-H:+ReportExceptionStackTraces') + // Native images enforce module encapsulation for setAccessible too, so the reflection entries alone are not enough + buildArgs.addAll(jerseyPatchOpens.collect { "--add-opens=${it}=ALL-UNNAMED" }) javaLauncher = javaToolchains.launcherFor { languageVersion = JavaLanguageVersion.of(25) @@ -158,7 +165,7 @@ tasks.named('run') { // Run with GraalVM Tracing Agent enabled if (System.getProperty('tracing-agent')?.toBoolean()) { - jvmArgs = ["-agentlib:native-image-agent=access-filter-file=conf/access-filter-file.json,config-merge-dir=conf/"] + jvmArgs += ["-agentlib:native-image-agent=access-filter-file=conf/access-filter-file.json,config-merge-dir=conf/"] } } @@ -171,6 +178,9 @@ tasks.register('runReflectionConfigGenerator', JavaExec) { } tasks.named('shadowJar') { + manifest { + attributes 'Add-Opens': jerseyPatchOpens.join(' ') + } archiveBaseName.set('tw') archiveClassifier.set('') archiveVersion.set('') @@ -180,9 +190,10 @@ tasks.named('test') { // Use junit platform for unit tests useJUnitPlatform() dependsOn tasks.named('checkLicenses') + jvmArgs jerseyPatchOpens.collect { "--add-opens=${it}=ALL-UNNAMED" } // Run tests with GraalVM Tracing Agent enabled if (System.getProperty('tracing-agent')?.toBoolean()) { - jvmArgs = ["-agentlib:native-image-agent=access-filter-file=conf/access-filter-file.json,config-merge-dir=conf/"] + jvmArgs += ["-agentlib:native-image-agent=access-filter-file=conf/access-filter-file.json,config-merge-dir=conf/"] } } diff --git a/conf/reflect-config.json b/conf/reflect-config.json index c144d657..cd3e429b 100644 --- a/conf/reflect-config.json +++ b/conf/reflect-config.json @@ -160,6 +160,12 @@ "allDeclaredMethods":true, "methods":[{"name":"","parameterTypes":[] }] }, +{ + "name":"io.seqera.tower.cli.commands.AgentsCmd", + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true, + "methods":[{"name":"","parameterTypes":[] }] +}, { "name":"io.seqera.tower.cli.commands.CollaboratorsCmd", "allDeclaredFields":true, @@ -250,6 +256,12 @@ "queryAllDeclaredMethods":true, "methods":[{"name":"","parameterTypes":[] }] }, +{ + "name":"io.seqera.tower.cli.commands.ServiceAccountsCmd", + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true, + "methods":[{"name":"","parameterTypes":[] }] +}, { "name":"io.seqera.tower.cli.commands.StudiosCmd", "allDeclaredFields":true, @@ -338,6 +350,108 @@ "allDeclaredMethods":true, "methods":[{"name":"","parameterTypes":[] }] }, +{ + "name":"io.seqera.tower.cli.commands.agents.AbstractAgentsCmd", + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true +}, +{ + "name":"io.seqera.tower.cli.commands.agents.AddCmd", + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true, + "fields":[{"name":"description"}, {"name":"githubAppCredentialsId"}, {"name":"instructions"}, {"name":"name"}, {"name":"serviceAccountId"}, {"name":"workspace"}], + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.seqera.tower.cli.commands.agents.AgentInstructionsOptions", + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true, + "fields":[{"name":"file"}, {"name":"text"}], + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.seqera.tower.cli.commands.agents.AgentRefOptions", + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true, + "fields":[{"name":"agent"}], + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.seqera.tower.cli.commands.agents.AgentRefOptions$AgentRef", + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true, + "fields":[{"name":"id"}, {"name":"name"}], + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.seqera.tower.cli.commands.agents.DeleteCmd", + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true, + "fields":[{"name":"ref"}, {"name":"workspace"}], + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.seqera.tower.cli.commands.agents.DisableCmd", + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true, + "fields":[{"name":"ref"}, {"name":"workspace"}], + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.seqera.tower.cli.commands.agents.EnableCmd", + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true, + "fields":[{"name":"ref"}, {"name":"workspace"}], + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.seqera.tower.cli.commands.agents.LaunchCmd", + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true, + "fields":[{"name":"agentRef"}, {"name":"instructions"}, {"name":"workspace"}], + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.seqera.tower.cli.commands.agents.ListCmd", + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true, + "fields":[{"name":"filter"}, {"name":"paginationOptions"}, {"name":"workspace"}], + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.seqera.tower.cli.commands.agents.UpdateCmd", + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true, + "fields":[{"name":"description"}, {"name":"githubAppCredentialsId"}, {"name":"instructions"}, {"name":"newName"}, {"name":"ref"}, {"name":"serviceAccountId"}, {"name":"workspace"}], + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.seqera.tower.cli.commands.agents.ViewCmd", + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true, + "fields":[{"name":"ref"}, {"name":"workspace"}], + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.seqera.tower.cli.commands.agents.runs.ListCmd", + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true, + "fields":[{"name":"filter"}, {"name":"paginationOptions"}, {"name":"workspace"}], + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.seqera.tower.cli.commands.agents.runs.RunsCmd", + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true, + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.seqera.tower.cli.commands.agents.runs.ViewCmd", + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true, + "fields":[{"name":"id"}, {"name":"workspace"}], + "methods":[{"name":"","parameterTypes":[] }] +}, { "name":"io.seqera.tower.cli.commands.collaborators.AbstractCollaboratorsCmd", "allDeclaredFields":true, @@ -408,6 +522,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 +793,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 +836,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 +967,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, @@ -1546,6 +1688,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, @@ -1764,6 +1913,67 @@ "queryAllDeclaredMethods":true, "methods":[{"name":"","parameterTypes":[] }] }, +{ + "name":"io.seqera.tower.cli.commands.serviceaccounts.AbstractServiceAccountsCmd", + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true +}, +{ + "name":"io.seqera.tower.cli.commands.serviceaccounts.AddCmd", + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true, + "fields":[{"name":"description"}, {"name":"name"}, {"name":"organizationRef"}], + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.seqera.tower.cli.commands.serviceaccounts.DeleteCmd", + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true, + "fields":[{"name":"organizationRef"}, {"name":"ref"}], + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.seqera.tower.cli.commands.serviceaccounts.ListCmd", + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true, + "fields":[{"name":"paginationOptions"}, {"name":"scope"}], + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.seqera.tower.cli.commands.serviceaccounts.ListCmd$Scope", + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true, + "fields":[{"name":"organizationRef"}, {"name":"workspaceRef"}], + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.seqera.tower.cli.commands.serviceaccounts.ServiceAccountRefOptions", + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true, + "fields":[{"name":"serviceAccount"}], + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.seqera.tower.cli.commands.serviceaccounts.ServiceAccountRefOptions$ServiceAccountRef", + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true, + "fields":[{"name":"id"}, {"name":"name"}], + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.seqera.tower.cli.commands.serviceaccounts.UpdateCmd", + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true, + "fields":[{"name":"description"}, {"name":"newName"}, {"name":"organizationRef"}, {"name":"ref"}], + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.seqera.tower.cli.commands.serviceaccounts.ViewCmd", + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true, + "fields":[{"name":"organizationRef"}, {"name":"ref"}], + "methods":[{"name":"","parameterTypes":[] }] +}, { "name":"io.seqera.tower.cli.commands.studios.AbstractStudiosCmd", "allDeclaredFields":true, @@ -1865,6 +2075,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, @@ -2048,6 +2265,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, @@ -2089,6 +2314,54 @@ "allDeclaredMethods":true, "allDeclaredConstructors":true }, +{ + "name":"io.seqera.tower.cli.responses.agents.AgentAdded", + "allDeclaredFields":true, + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true +}, +{ + "name":"io.seqera.tower.cli.responses.agents.AgentDeleted", + "allDeclaredFields":true, + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true +}, +{ + "name":"io.seqera.tower.cli.responses.agents.AgentLaunched", + "allDeclaredFields":true, + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true +}, +{ + "name":"io.seqera.tower.cli.responses.agents.AgentRunView", + "allDeclaredFields":true, + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true +}, +{ + "name":"io.seqera.tower.cli.responses.agents.AgentRunsList", + "allDeclaredFields":true, + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true +}, +{ + "name":"io.seqera.tower.cli.responses.agents.AgentUpdated", + "allDeclaredFields":true, + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true +}, +{ + "name":"io.seqera.tower.cli.responses.agents.AgentView", + "allDeclaredFields":true, + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true +}, +{ + "name":"io.seqera.tower.cli.responses.agents.AgentsList", + "allDeclaredFields":true, + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true +}, { "name":"io.seqera.tower.cli.responses.collaborators.CollaboratorsList", "allDeclaredFields":true, @@ -2125,6 +2398,38 @@ "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.DataLinkContentList", "allDeclaredFields":true, @@ -2178,6 +2483,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, @@ -2274,6 +2587,14 @@ "allDeclaredMethods":true, "allDeclaredConstructors":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 +2613,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 +2657,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, @@ -2406,12 +2743,44 @@ "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.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 +2811,36 @@ "queryAllDeclaredMethods":true, "queryAllDeclaredConstructors":true }, +{ + "name":"io.seqera.tower.cli.responses.serviceaccounts.ServiceAccountAdded", + "allDeclaredFields":true, + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true +}, +{ + "name":"io.seqera.tower.cli.responses.serviceaccounts.ServiceAccountDeleted", + "allDeclaredFields":true, + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true +}, +{ + "name":"io.seqera.tower.cli.responses.serviceaccounts.ServiceAccountUpdated", + "allDeclaredFields":true, + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true +}, +{ + "name":"io.seqera.tower.cli.responses.serviceaccounts.ServiceAccountView", + "allDeclaredFields":true, + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true +}, +{ + "name":"io.seqera.tower.cli.responses.serviceaccounts.ServiceAccountsList", + "allDeclaredFields":true, + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true +}, { "name":"io.seqera.tower.cli.responses.studios.StudioCheckpointsList", "allDeclaredFields":true, @@ -6507,6 +6906,10 @@ "queryAllDeclaredMethods":true, "queryAllDeclaredConstructors":true }, +{ + "name":"java.net.HttpURLConnection", + "fields":[{"name":"method"}] +}, { "name":"java.net.UnknownHostException", "methods":[{"name":"","parameterTypes":["java.lang.String"] }] @@ -7095,6 +7498,10 @@ "name":"sun.net.www.protocol.http.ntlm.NTLMAuthentication", "methods":[{"name":"","parameterTypes":["boolean","java.lang.String","int","java.net.PasswordAuthentication"] }, {"name":"","parameterTypes":["boolean","java.net.URL","java.net.PasswordAuthentication"] }, {"name":"isTrustedSite","parameterTypes":["java.net.URL"] }, {"name":"supportsTransparentAuth","parameterTypes":[] }] }, +{ + "name":"sun.net.www.protocol.https.HttpsURLConnectionImpl", + "fields":[{"name":"delegate"}] +}, { "name":"sun.security.pkcs12.PKCS12KeyStore", "methods":[{"name":"","parameterTypes":[] }] diff --git a/src/main/java/io/seqera/tower/cli/Tower.java b/src/main/java/io/seqera/tower/cli/Tower.java index 4cf2f248..e7bbbce1 100644 --- a/src/main/java/io/seqera/tower/cli/Tower.java +++ b/src/main/java/io/seqera/tower/cli/Tower.java @@ -22,6 +22,7 @@ import io.seqera.tower.cli.commands.AbstractCmd; import io.seqera.tower.cli.commands.ActionsCmd; +import io.seqera.tower.cli.commands.AgentsCmd; import io.seqera.tower.cli.commands.CollaboratorsCmd; import io.seqera.tower.cli.commands.ComputeEnvsCmd; import io.seqera.tower.cli.commands.CredentialsCmd; @@ -37,6 +38,7 @@ import io.seqera.tower.cli.commands.PipelineSchemasCmd; import io.seqera.tower.cli.commands.RunsCmd; import io.seqera.tower.cli.commands.SecretsCmd; +import io.seqera.tower.cli.commands.ServiceAccountsCmd; import io.seqera.tower.cli.commands.TeamsCmd; import io.seqera.tower.cli.commands.WorkspacesCmd; import io.seqera.tower.cli.commands.enums.OutputType; @@ -57,6 +59,7 @@ description = "Seqera Platform CLI", subcommands = { ActionsCmd.class, + AgentsCmd.class, CollaboratorsCmd.class, ComputeEnvsCmd.class, CredentialsCmd.class, @@ -76,6 +79,7 @@ TeamsCmd.class, WorkspacesCmd.class, SecretsCmd.class, + ServiceAccountsCmd.class, } ) public class Tower extends AbstractCmd { 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 20d15b2d..84538916 100644 --- a/src/main/java/io/seqera/tower/cli/commands/AbstractApiCmd.java +++ b/src/main/java/io/seqera/tower/cli/commands/AbstractApiCmd.java @@ -19,6 +19,7 @@ import io.seqera.tower.ApiClient; import io.seqera.tower.ApiException; import io.seqera.tower.api.ActionsApi; +import io.seqera.tower.api.AgentsApi; import io.seqera.tower.api.AvatarsApi; import io.seqera.tower.api.ComputeEnvsApi; import io.seqera.tower.api.CredentialsApi; @@ -33,6 +34,7 @@ import io.seqera.tower.api.PipelineVersionsApi; import io.seqera.tower.api.PipelinesApi; import io.seqera.tower.api.PlatformsApi; +import io.seqera.tower.api.ServiceAccountsApi; import io.seqera.tower.api.ServiceInfoApi; import io.seqera.tower.api.StudiosApi; import io.seqera.tower.api.TeamsApi; @@ -122,6 +124,7 @@ public abstract class AbstractApiCmd extends AbstractCmd { private PipelineSecretsApi pipelineSecretsApi; private PipelineVersionsApi pipelineVersionsApi; private PlatformsApi platformsApi; + private ServiceAccountsApi serviceAccountsApi; private ServiceInfoApi serviceInfoApi; private StudiosApi studiosApi; private TeamsApi teamsApi; @@ -192,6 +195,10 @@ protected ActionsApi actionsApi() throws ApiException { return actionsApi == null ? new ActionsApi(apiClient()) : actionsApi; } + protected AgentsApi agentsApi() throws ApiException { + return new AgentsApi(apiClient()); + } + protected AvatarsApi avatarsApi() throws ApiException { return avatarsApi == null ? new AvatarsApi(apiClient()) : avatarsApi; } @@ -248,6 +255,10 @@ protected PlatformsApi platformsApi() throws ApiException { return platformsApi == null ? new PlatformsApi(apiClient()) : platformsApi; } + protected ServiceAccountsApi serviceAccountsApi() throws ApiException { + return serviceAccountsApi == null ? new ServiceAccountsApi(apiClient()) : serviceAccountsApi; + } + protected ServiceInfoApi serviceInfoApi() throws ApiException { return serviceInfoApi == null ? new ServiceInfoApi(apiClient()) : serviceInfoApi; } diff --git a/src/main/java/io/seqera/tower/cli/commands/AgentsCmd.java b/src/main/java/io/seqera/tower/cli/commands/AgentsCmd.java new file mode 100644 index 00000000..6958244c --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/commands/AgentsCmd.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; + +import io.seqera.tower.cli.commands.agents.AddCmd; +import io.seqera.tower.cli.commands.agents.DeleteCmd; +import io.seqera.tower.cli.commands.agents.DisableCmd; +import io.seqera.tower.cli.commands.agents.EnableCmd; +import io.seqera.tower.cli.commands.agents.LaunchCmd; +import io.seqera.tower.cli.commands.agents.ListCmd; +import io.seqera.tower.cli.commands.agents.UpdateCmd; +import io.seqera.tower.cli.commands.agents.ViewCmd; +import io.seqera.tower.cli.commands.agents.runs.RunsCmd; +import picocli.CommandLine.Command; + +@Command( + name = "agents", + description = "Manage AI agents. Agents must be enabled for the workspace organization.", + subcommands = { + ListCmd.class, + ViewCmd.class, + AddCmd.class, + UpdateCmd.class, + DeleteCmd.class, + EnableCmd.class, + DisableCmd.class, + LaunchCmd.class, + RunsCmd.class, + } +) +public class AgentsCmd extends AbstractRootCmd { +} diff --git a/src/main/java/io/seqera/tower/cli/commands/ServiceAccountsCmd.java b/src/main/java/io/seqera/tower/cli/commands/ServiceAccountsCmd.java new file mode 100644 index 00000000..ac973bab --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/commands/ServiceAccountsCmd.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.commands; + +import io.seqera.tower.cli.commands.serviceaccounts.AddCmd; +import io.seqera.tower.cli.commands.serviceaccounts.DeleteCmd; +import io.seqera.tower.cli.commands.serviceaccounts.ListCmd; +import io.seqera.tower.cli.commands.serviceaccounts.UpdateCmd; +import io.seqera.tower.cli.commands.serviceaccounts.ViewCmd; +import picocli.CommandLine.Command; + +@Command( + name = "service-accounts", + description = "Manage organization service accounts", + subcommands = { + ListCmd.class, + AddCmd.class, + ViewCmd.class, + UpdateCmd.class, + DeleteCmd.class, + } +) +public class ServiceAccountsCmd extends AbstractRootCmd { +} diff --git a/src/main/java/io/seqera/tower/cli/commands/agents/AbstractAgentsCmd.java b/src/main/java/io/seqera/tower/cli/commands/agents/AbstractAgentsCmd.java new file mode 100644 index 00000000..9b536d4e --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/commands/agents/AbstractAgentsCmd.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.agents; + +import io.seqera.tower.ApiException; +import io.seqera.tower.cli.commands.AbstractApiCmd; +import io.seqera.tower.cli.exceptions.TowerException; +import io.seqera.tower.model.AgentDbDto; +import picocli.CommandLine.Command; + +@Command +public abstract class AbstractAgentsCmd extends AbstractApiCmd { + + // The API caps agent pages at 100 entries + private static final int MAX_PAGE_SIZE = 100; + + protected AgentDbDto fetchAgent(AgentRefOptions.AgentRef ref, Long wspId) throws ApiException { + String agentId = ref.id != null ? ref.id : agentByName(ref.name, wspId).getId(); + return agentsApi().describeAgent(agentId, wspId).getAgent(); + } + + private AgentDbDto agentByName(String name, Long wspId) throws ApiException { + for (AgentDbDto agent : agentsApi().listAgents(wspId, name, MAX_PAGE_SIZE, 0).getAgents()) { + if (name.equals(agent.getName())) { + return agent; + } + } + throw new TowerException(String.format("Unknown agent '%s' at %s workspace", name, workspaceRef(wspId))); + } +} diff --git a/src/main/java/io/seqera/tower/cli/commands/agents/AddCmd.java b/src/main/java/io/seqera/tower/cli/commands/agents/AddCmd.java new file mode 100644 index 00000000..2e4977a9 --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/commands/agents/AddCmd.java @@ -0,0 +1,70 @@ +/* + * 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.agents; + +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.agents.AgentAdded; +import io.seqera.tower.model.AgentDbDto; +import io.seqera.tower.model.CreateAgentRequest; +import picocli.CommandLine.ArgGroup; +import picocli.CommandLine.Command; +import picocli.CommandLine.Mixin; +import picocli.CommandLine.Option; + +import java.io.IOException; + +@Command( + name = "add", + description = "Add an agent" +) +public class AddCmd extends AbstractAgentsCmd { + + @Mixin + public WorkspaceRequiredOptions workspace; + + @Option(names = {"-n", "--name"}, description = "Agent name. Must be unique per workspace. Names consist of alphanumeric, hyphen, and underscore characters.", required = true) + public String name; + + @Option(names = {"-d", "--description"}, description = "Agent description (max 120 characters).") + public String description; + + @ArgGroup(multiplicity = "1") + public AgentInstructionsOptions instructions; + + @Option(names = {"--service-account-id"}, description = "Service account user ID. The agent runs with the permissions of this service account in the workspace.") + public Long serviceAccountId; + + @Option(names = {"--github-app-credentials-id"}, description = "GitHub App credentials identifier. Lets the agent clone, commit, and push using these credentials.") + public String githubAppCredentialsId; + + @Override + protected Response exec() throws ApiException, IOException { + Long wspId = workspaceId(workspace.workspace); + CreateAgentRequest request = new CreateAgentRequest() + .name(name) + .description(description) + .agentInstructions(instructions.read()) + .serviceAccountId(serviceAccountId) + .githubAppCredentialId(githubAppCredentialsId); + + AgentDbDto agent = agentsApi().createAgent(request, wspId).getAgent(); + return new AgentAdded(workspaceRef(wspId), agent); + } +} diff --git a/src/main/java/io/seqera/tower/cli/commands/agents/AgentInstructionsOptions.java b/src/main/java/io/seqera/tower/cli/commands/agents/AgentInstructionsOptions.java new file mode 100644 index 00000000..6fe195dd --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/commands/agents/AgentInstructionsOptions.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.commands.agents; + +import picocli.CommandLine.Option; + +import java.io.IOException; +import java.nio.file.Path; + +import static io.seqera.tower.cli.utils.FilesHelper.readStringOrStdin; + +public class AgentInstructionsOptions { + + @Option(names = {"--instructions"}, description = "Agent instructions: the role, behavior, and constraints for the agent.") + public String text; + + @Option(names = {"--instructions-file"}, description = "File containing the agent instructions. Use '-' to read from stdin.") + public Path file; + + public String read() throws IOException { + return text != null ? text : readStringOrStdin(file); + } +} diff --git a/src/main/java/io/seqera/tower/cli/commands/agents/AgentRefOptions.java b/src/main/java/io/seqera/tower/cli/commands/agents/AgentRefOptions.java new file mode 100644 index 00000000..9e3db312 --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/commands/agents/AgentRefOptions.java @@ -0,0 +1,36 @@ +/* + * Copyright 2021-2026, Seqera. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + +package io.seqera.tower.cli.commands.agents; + +import picocli.CommandLine.ArgGroup; +import picocli.CommandLine.Option; + +public class AgentRefOptions { + + @ArgGroup(multiplicity = "1") + public AgentRef agent; + + public static class AgentRef { + + @Option(names = {"-i", "--id"}, description = "Agent identifier") + public String id; + + @Option(names = {"-n", "--name"}, description = "Agent name") + public String name; + } +} diff --git a/src/main/java/io/seqera/tower/cli/commands/agents/DeleteCmd.java b/src/main/java/io/seqera/tower/cli/commands/agents/DeleteCmd.java new file mode 100644 index 00000000..10795eee --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/commands/agents/DeleteCmd.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.agents; + +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.agents.AgentDeleted; +import io.seqera.tower.model.AgentDbDto; +import picocli.CommandLine.Command; +import picocli.CommandLine.Mixin; + +@Command( + name = "delete", + description = "Delete an agent" +) +public class DeleteCmd extends AbstractAgentsCmd { + + @Mixin + public WorkspaceRequiredOptions workspace; + + @Mixin + AgentRefOptions ref; + + @Override + protected Response exec() throws ApiException { + Long wspId = workspaceId(workspace.workspace); + AgentDbDto agent = fetchAgent(ref.agent, wspId); + agentsApi().deleteAgent(agent.getId(), wspId); + return new AgentDeleted(workspaceRef(wspId), agent.getName()); + } +} diff --git a/src/main/java/io/seqera/tower/cli/commands/agents/DisableCmd.java b/src/main/java/io/seqera/tower/cli/commands/agents/DisableCmd.java new file mode 100644 index 00000000..20c1a1be --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/commands/agents/DisableCmd.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.agents; + +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.agents.AgentUpdated; +import io.seqera.tower.model.AgentDbDto; +import picocli.CommandLine.Command; +import picocli.CommandLine.Mixin; + +@Command( + name = "disable", + description = "Disable an active agent. A disabled agent cannot be launched." +) +public class DisableCmd extends AbstractAgentsCmd { + + @Mixin + public WorkspaceRequiredOptions workspace; + + @Mixin + AgentRefOptions ref; + + @Override + protected Response exec() throws ApiException { + Long wspId = workspaceId(workspace.workspace); + AgentDbDto agent = fetchAgent(ref.agent, wspId); + agentsApi().disableAgent(agent.getId(), wspId); + return new AgentUpdated(workspaceRef(wspId), agent.getName(), "disabled"); + } +} diff --git a/src/main/java/io/seqera/tower/cli/commands/agents/EnableCmd.java b/src/main/java/io/seqera/tower/cli/commands/agents/EnableCmd.java new file mode 100644 index 00000000..4c96068c --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/commands/agents/EnableCmd.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.agents; + +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.agents.AgentUpdated; +import io.seqera.tower.model.AgentDbDto; +import picocli.CommandLine.Command; +import picocli.CommandLine.Mixin; + +@Command( + name = "enable", + description = "Enable an inactive agent" +) +public class EnableCmd extends AbstractAgentsCmd { + + @Mixin + public WorkspaceRequiredOptions workspace; + + @Mixin + AgentRefOptions ref; + + @Override + protected Response exec() throws ApiException { + Long wspId = workspaceId(workspace.workspace); + AgentDbDto agent = fetchAgent(ref.agent, wspId); + agentsApi().enableAgent(agent.getId(), wspId); + return new AgentUpdated(workspaceRef(wspId), agent.getName(), "enabled"); + } +} diff --git a/src/main/java/io/seqera/tower/cli/commands/agents/LaunchCmd.java b/src/main/java/io/seqera/tower/cli/commands/agents/LaunchCmd.java new file mode 100644 index 00000000..6d29bac8 --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/commands/agents/LaunchCmd.java @@ -0,0 +1,68 @@ +/* + * 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.agents; + +import io.seqera.tower.ApiException; +import io.seqera.tower.cli.commands.global.WorkspaceRequiredOptions; +import io.seqera.tower.cli.exceptions.TowerException; +import io.seqera.tower.cli.responses.Response; +import io.seqera.tower.cli.responses.agents.AgentLaunched; +import io.seqera.tower.model.AgentDbDto; +import io.seqera.tower.model.LaunchAgentRequest; +import io.seqera.tower.model.LaunchAgentResponse; +import picocli.CommandLine.ArgGroup; +import picocli.CommandLine.Command; +import picocli.CommandLine.Mixin; + +import java.io.IOException; + +@Command( + name = "launch", + description = "Launch a background agent run. Specify a configured agent (--id or --name), instructions, or both. Given instructions override the agent's own; without an agent, the run acts as the current user." +) +public class LaunchCmd extends AbstractAgentsCmd { + + @Mixin + public WorkspaceRequiredOptions workspace; + + @ArgGroup + public AgentRefOptions.AgentRef agentRef; + + @ArgGroup + public AgentInstructionsOptions instructions; + + @Override + protected Response exec() throws ApiException, IOException { + Long wspId = workspaceId(workspace.workspace); + LaunchAgentRequest request = new LaunchAgentRequest(); + + if (agentRef != null) { + AgentDbDto agent = fetchAgent(agentRef, wspId); + request.agentConfigId(agent.getId()).instructions(agent.getAgentInstructions()); + } + if (instructions != null) { + request.instructions(instructions.read()); + } + if (request.getInstructions() == null) { + throw new TowerException("Specify an agent to launch (--id or --name) or the instructions to run (--instructions or --instructions-file)"); + } + + LaunchAgentResponse response = agentsApi().launchAgent(request, wspId); + return new AgentLaunched(workspaceRef(wspId), response.getAgentRunId(), response.getStatus()); + } +} diff --git a/src/main/java/io/seqera/tower/cli/commands/agents/ListCmd.java b/src/main/java/io/seqera/tower/cli/commands/agents/ListCmd.java new file mode 100644 index 00000000..f8c7416a --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/commands/agents/ListCmd.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.agents; + +import io.seqera.tower.ApiException; +import io.seqera.tower.cli.commands.global.PaginationOptions; +import io.seqera.tower.cli.commands.global.WorkspaceRequiredOptions; +import io.seqera.tower.cli.responses.Response; +import io.seqera.tower.cli.responses.agents.AgentsList; +import io.seqera.tower.cli.utils.PaginationInfo; +import io.seqera.tower.model.ListAgentsResponse; +import picocli.CommandLine.Command; +import picocli.CommandLine.Mixin; +import picocli.CommandLine.Option; + +@Command( + name = "list", + description = "List agents" +) +public class ListCmd extends AbstractAgentsCmd { + + @Mixin + public WorkspaceRequiredOptions workspace; + + @Option(names = {"-f", "--filter"}, description = "Filter agents by name") + public String filter; + + @Mixin + PaginationOptions paginationOptions; + + @Override + protected Response exec() throws ApiException { + Long wspId = workspaceId(workspace.workspace); + Integer max = PaginationOptions.getMax(paginationOptions); + Integer offset = PaginationOptions.getOffset(paginationOptions, max); + + ListAgentsResponse response = agentsApi().listAgents(wspId, filter, max, offset); + + return new AgentsList(workspaceRef(wspId), response.getAgents(), PaginationInfo.from(paginationOptions, response.getTotalSize())); + } +} diff --git a/src/main/java/io/seqera/tower/cli/commands/agents/UpdateCmd.java b/src/main/java/io/seqera/tower/cli/commands/agents/UpdateCmd.java new file mode 100644 index 00000000..28584ec2 --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/commands/agents/UpdateCmd.java @@ -0,0 +1,79 @@ +/* + * 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.agents; + +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.agents.AgentUpdated; +import io.seqera.tower.model.AgentDbDto; +import io.seqera.tower.model.UpdateAgentRequest; +import picocli.CommandLine.ArgGroup; +import picocli.CommandLine.Command; +import picocli.CommandLine.Mixin; +import picocli.CommandLine.Option; + +import java.io.IOException; + +import static io.seqera.tower.cli.utils.ModelHelper.coalesce; + +@Command( + name = "update", + description = "Update an agent" +) +public class UpdateCmd extends AbstractAgentsCmd { + + @Mixin + public WorkspaceRequiredOptions workspace; + + @Mixin + AgentRefOptions ref; + + @Option(names = {"--new-name"}, description = "New agent name. Names consist of alphanumeric, hyphen, and underscore characters.") + public String newName; + + @Option(names = {"-d", "--description"}, description = "Agent description (max 120 characters).") + public String description; + + @ArgGroup + public AgentInstructionsOptions instructions; + + @Option(names = {"--service-account-id"}, description = "Service account user ID. The agent runs with the permissions of this service account in the workspace.") + public Long serviceAccountId; + + @Option(names = {"--github-app-credentials-id"}, description = "GitHub App credentials identifier. Lets the agent clone, commit, and push using these credentials.") + public String githubAppCredentialsId; + + @Override + protected Response exec() throws ApiException, IOException { + Long wspId = workspaceId(workspace.workspace); + AgentDbDto agent = fetchAgent(ref.agent, wspId); + + // The API replaces the whole agent, so unchanged fields are sent with their current value + UpdateAgentRequest request = new UpdateAgentRequest() + .name(coalesce(newName, agent.getName())) + .description(coalesce(description, agent.getDescription())) + .agentInstructions(instructions != null ? instructions.read() : agent.getAgentInstructions()) + .agentInstructionsTemplateId(agent.getAgentInstructionsTemplateId()) + .serviceAccountId(coalesce(serviceAccountId, agent.getServiceAccountId())) + .githubAppCredentialId(coalesce(githubAppCredentialsId, agent.getGithubAppCredentialId())); + + AgentDbDto updated = agentsApi().updateAgent(agent.getId(), request, wspId).getAgent(); + return new AgentUpdated(workspaceRef(wspId), updated.getName(), "updated"); + } +} diff --git a/src/main/java/io/seqera/tower/cli/commands/agents/ViewCmd.java b/src/main/java/io/seqera/tower/cli/commands/agents/ViewCmd.java new file mode 100644 index 00000000..b1605e6e --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/commands/agents/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.agents; + +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.agents.AgentView; +import picocli.CommandLine.Command; +import picocli.CommandLine.Mixin; + +@Command( + name = "view", + description = "View agent details" +) +public class ViewCmd extends AbstractAgentsCmd { + + @Mixin + public WorkspaceRequiredOptions workspace; + + @Mixin + AgentRefOptions ref; + + @Override + protected Response exec() throws ApiException { + Long wspId = workspaceId(workspace.workspace); + return new AgentView(workspaceRef(wspId), fetchAgent(ref.agent, wspId)); + } +} diff --git a/src/main/java/io/seqera/tower/cli/commands/agents/runs/ListCmd.java b/src/main/java/io/seqera/tower/cli/commands/agents/runs/ListCmd.java new file mode 100644 index 00000000..ddeb1c40 --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/commands/agents/runs/ListCmd.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.agents.runs; + +import io.seqera.tower.ApiException; +import io.seqera.tower.cli.commands.AbstractApiCmd; +import io.seqera.tower.cli.commands.global.PaginationOptions; +import io.seqera.tower.cli.commands.global.WorkspaceRequiredOptions; +import io.seqera.tower.cli.responses.Response; +import io.seqera.tower.cli.responses.agents.AgentRunsList; +import io.seqera.tower.cli.utils.PaginationInfo; +import io.seqera.tower.model.ListAgentRunsResponse; +import picocli.CommandLine.Command; +import picocli.CommandLine.Mixin; +import picocli.CommandLine.Option; + +@Command( + name = "list", + description = "List agent runs, newest first" +) +public class ListCmd extends AbstractApiCmd { + + @Mixin + public WorkspaceRequiredOptions workspace; + + @Option(names = {"-f", "--filter"}, description = "Optional filter criteria, allowing the keywords: `status` (pending, running, completed, failed), `agentConfigId`, `workflowId`, `sourcePipelineId`, `serviceAccountId` and `serviceAccountName`. Free text is not supported. Example keyword usage: -f status:failed.") + public String filter; + + @Mixin + PaginationOptions paginationOptions; + + @Override + protected Response exec() throws ApiException { + Long wspId = workspaceId(workspace.workspace); + Integer max = PaginationOptions.getMax(paginationOptions); + Integer offset = PaginationOptions.getOffset(paginationOptions, max); + + ListAgentRunsResponse response = agentsApi().listAgentRuns(wspId, filter, max, offset); + + return new AgentRunsList(workspaceRef(wspId), response.getAgentRuns(), PaginationInfo.from(paginationOptions, response.getTotalSize())); + } +} diff --git a/src/main/java/io/seqera/tower/cli/commands/agents/runs/RunsCmd.java b/src/main/java/io/seqera/tower/cli/commands/agents/runs/RunsCmd.java new file mode 100644 index 00000000..fd7d6228 --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/commands/agents/runs/RunsCmd.java @@ -0,0 +1,32 @@ +/* + * 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.agents.runs; + +import io.seqera.tower.cli.commands.AbstractRootCmd; +import picocli.CommandLine.Command; + +@Command( + name = "runs", + description = "Manage agent runs", + subcommands = { + ListCmd.class, + ViewCmd.class, + } +) +public class RunsCmd extends AbstractRootCmd { +} diff --git a/src/main/java/io/seqera/tower/cli/commands/agents/runs/ViewCmd.java b/src/main/java/io/seqera/tower/cli/commands/agents/runs/ViewCmd.java new file mode 100644 index 00000000..0a3d2570 --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/commands/agents/runs/ViewCmd.java @@ -0,0 +1,46 @@ +/* + * 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.agents.runs; + +import io.seqera.tower.ApiException; +import io.seqera.tower.cli.commands.AbstractApiCmd; +import io.seqera.tower.cli.commands.global.WorkspaceRequiredOptions; +import io.seqera.tower.cli.responses.Response; +import io.seqera.tower.cli.responses.agents.AgentRunView; +import picocli.CommandLine.Command; +import picocli.CommandLine.Mixin; +import picocli.CommandLine.Option; + +@Command( + name = "view", + description = "View the status of an agent run" +) +public class ViewCmd extends AbstractApiCmd { + + @Mixin + public WorkspaceRequiredOptions workspace; + + @Option(names = {"-i", "--id"}, description = "Agent run identifier", required = true) + public String id; + + @Override + protected Response exec() throws ApiException { + Long wspId = workspaceId(workspace.workspace); + return new AgentRunView(workspaceRef(wspId), agentsApi().getAgentRunStatus(id, wspId)); + } +} diff --git a/src/main/java/io/seqera/tower/cli/commands/serviceaccounts/AbstractServiceAccountsCmd.java b/src/main/java/io/seqera/tower/cli/commands/serviceaccounts/AbstractServiceAccountsCmd.java new file mode 100644 index 00000000..6ed238ad --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/commands/serviceaccounts/AbstractServiceAccountsCmd.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.serviceaccounts; + +import io.seqera.tower.ApiException; +import io.seqera.tower.cli.commands.AbstractApiCmd; +import io.seqera.tower.cli.commands.global.PaginationOptions; +import io.seqera.tower.cli.exceptions.TowerException; +import io.seqera.tower.model.ListServiceAccountsResponse; +import io.seqera.tower.model.ServiceAccountDto; +import picocli.CommandLine.Command; + +import java.util.List; + +@Command +public abstract class AbstractServiceAccountsCmd extends AbstractApiCmd { + + public 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 ServiceAccountDto fetchServiceAccount(Long orgId, ServiceAccountRefOptions ref) throws ApiException { + if (ref.serviceAccount.id != null) { + return serviceAccountsApi().describeServiceAccount(orgId, ref.serviceAccount.id).getServiceAccount(); + } + return serviceAccountByName(orgId, ref.serviceAccount.name); + } + + private ServiceAccountDto serviceAccountByName(Long orgId, String name) throws ApiException { + int offset = 0; + while (true) { + ListServiceAccountsResponse page = serviceAccountsApi().listServiceAccounts(orgId, offset, PaginationOptions.MAX); + List items = page.getServiceAccounts(); + for (ServiceAccountDto sa : items) { + if (name.equals(sa.getName())) { + return sa; + } + } + offset += items.size(); + if (items.isEmpty() || offset >= page.getTotalSize()) { + throw new TowerException(String.format("Service account '%s' not found in organization '%d'", name, orgId)); + } + } + } +} diff --git a/src/main/java/io/seqera/tower/cli/commands/serviceaccounts/AddCmd.java b/src/main/java/io/seqera/tower/cli/commands/serviceaccounts/AddCmd.java new file mode 100644 index 00000000..9075d855 --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/commands/serviceaccounts/AddCmd.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.commands.serviceaccounts; + +import io.seqera.tower.ApiException; +import io.seqera.tower.cli.responses.Response; +import io.seqera.tower.cli.responses.serviceaccounts.ServiceAccountAdded; +import io.seqera.tower.model.CreateServiceAccountRequest; +import io.seqera.tower.model.ServiceAccountDto; +import picocli.CommandLine.Command; +import picocli.CommandLine.Option; + +@Command( + name = "add", + description = "Add a service account" +) +public class AddCmd extends AbstractServiceAccountsCmd { + + @Option(names = {"-o", "--organization"}, description = ORGANIZATION_DESCRIPTION, required = true) + public String organizationRef; + + @Option(names = {"-n", "--name"}, description = "Service account name. Must be unique across all Platform users. Use only lowercase letters, numbers, and dashes (max 40 characters).", required = true) + public String name; + + @Option(names = {"-d", "--description"}, description = "Service account description (max 1000 characters).") + public String description; + + @Override + protected Response exec() throws ApiException { + Long orgId = findOrganizationByRef(organizationRef).getOrgId(); + + CreateServiceAccountRequest request = new CreateServiceAccountRequest() + .name(name) + .description(description); + + ServiceAccountDto sa = serviceAccountsApi().createServiceAccount(orgId, request).getServiceAccount(); + + return new ServiceAccountAdded(organizationRef, sa); + } +} diff --git a/src/main/java/io/seqera/tower/cli/commands/serviceaccounts/DeleteCmd.java b/src/main/java/io/seqera/tower/cli/commands/serviceaccounts/DeleteCmd.java new file mode 100644 index 00000000..8f59930e --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/commands/serviceaccounts/DeleteCmd.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.serviceaccounts; + +import io.seqera.tower.ApiException; +import io.seqera.tower.cli.responses.Response; +import io.seqera.tower.cli.responses.serviceaccounts.ServiceAccountDeleted; +import io.seqera.tower.model.ServiceAccountDto; +import picocli.CommandLine.Command; +import picocli.CommandLine.Mixin; +import picocli.CommandLine.Option; + +@Command( + name = "delete", + description = "Delete a service account" +) +public class DeleteCmd extends AbstractServiceAccountsCmd { + + @Option(names = {"-o", "--organization"}, description = ORGANIZATION_DESCRIPTION, required = true) + public String organizationRef; + + @Mixin + ServiceAccountRefOptions ref; + + @Override + protected Response exec() throws ApiException { + Long orgId = findOrganizationByRef(organizationRef).getOrgId(); + ServiceAccountDto sa = fetchServiceAccount(orgId, ref); + + serviceAccountsApi().deleteServiceAccount(orgId, sa.getId()); + + return new ServiceAccountDeleted(organizationRef, sa); + } +} diff --git a/src/main/java/io/seqera/tower/cli/commands/serviceaccounts/ListCmd.java b/src/main/java/io/seqera/tower/cli/commands/serviceaccounts/ListCmd.java new file mode 100644 index 00000000..c9b6d488 --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/commands/serviceaccounts/ListCmd.java @@ -0,0 +1,88 @@ +/* + * 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.serviceaccounts; + +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.serviceaccounts.ServiceAccountsList; +import io.seqera.tower.cli.utils.PaginationInfo; +import io.seqera.tower.model.ListServiceAccountsResponse; +import io.seqera.tower.model.ListWorkspaceServiceAccountsResponse; +import io.seqera.tower.model.ServiceAccountDto; +import picocli.CommandLine.ArgGroup; +import picocli.CommandLine.Command; +import picocli.CommandLine.Mixin; +import picocli.CommandLine.Option; + +import java.util.ArrayList; +import java.util.List; + +@Command( + name = "list", + description = "List service accounts" +) +public class ListCmd extends AbstractServiceAccountsCmd { + + @ArgGroup(multiplicity = "1") + Scope scope; + + static class Scope { + @Option(names = {"-o", "--organization"}, description = ORGANIZATION_DESCRIPTION) + String organizationRef; + + @Option(names = {"-w", "--workspace"}, description = "Workspace numeric identifier or reference in OrganizationName/WorkspaceName format. Lists only the service accounts assigned to this workspace.") + String workspaceRef; + } + + @Mixin + PaginationOptions paginationOptions; + + @Override + protected Response exec() throws ApiException { + Integer max = PaginationOptions.getMax(paginationOptions); + Integer offset = PaginationOptions.getOffset(paginationOptions, max); + + if (scope.organizationRef != null) { + Long orgId = findOrganizationByRef(scope.organizationRef).getOrgId(); + ListServiceAccountsResponse response = serviceAccountsApi().listServiceAccounts(orgId, offset, max); + return new ServiceAccountsList(scope.organizationRef, null, response.getServiceAccounts(), PaginationInfo.from(paginationOptions, response.getTotalSize())); + } + + Long wspId = workspaceId(scope.workspaceRef); + List all = listWorkspaceServiceAccounts(orgId(wspId), wspId); + // The workspace endpoint pages with an opaque token, so offset and max are applied to the full list + int from = Math.min(offset, all.size()); + int to = Math.min(from + max, all.size()); + return new ServiceAccountsList(orgName(wspId), workspaceName(wspId), all.subList(from, to), PaginationInfo.from(paginationOptions, (long) all.size())); + } + + private List listWorkspaceServiceAccounts(Long orgId, Long wspId) throws ApiException { + List result = new ArrayList<>(); + String pageToken = null; + do { + ListWorkspaceServiceAccountsResponse page = serviceAccountsApi().listWorkspaceServiceAccounts(orgId, wspId, pageToken, PaginationOptions.MAX); + page.getItems().forEach(it -> result.add(new ServiceAccountDto() + .id(it.getId()) + .name(it.getName()) + .description(it.getDescription()) + .createdAt(it.getCreatedAt()))); + pageToken = page.getNextPageToken(); + } while (pageToken != null); + return result; + } +} diff --git a/src/main/java/io/seqera/tower/cli/commands/serviceaccounts/ServiceAccountRefOptions.java b/src/main/java/io/seqera/tower/cli/commands/serviceaccounts/ServiceAccountRefOptions.java new file mode 100644 index 00000000..244dd229 --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/commands/serviceaccounts/ServiceAccountRefOptions.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.serviceaccounts; + +import picocli.CommandLine.ArgGroup; +import picocli.CommandLine.Option; + +public class ServiceAccountRefOptions { + + @ArgGroup(multiplicity = "1") + public ServiceAccountRef serviceAccount; + + public static class ServiceAccountRef { + + @Option(names = {"-i", "--id"}, description = "Service account numeric identifier.") + public Long id; + + @Option(names = {"-n", "--name"}, description = "Service account name.") + public String name; + } +} diff --git a/src/main/java/io/seqera/tower/cli/commands/serviceaccounts/UpdateCmd.java b/src/main/java/io/seqera/tower/cli/commands/serviceaccounts/UpdateCmd.java new file mode 100644 index 00000000..7e251c63 --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/commands/serviceaccounts/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.serviceaccounts; + +import io.seqera.tower.ApiException; +import io.seqera.tower.cli.responses.Response; +import io.seqera.tower.cli.responses.serviceaccounts.ServiceAccountUpdated; +import io.seqera.tower.model.ServiceAccountDto; +import io.seqera.tower.model.UpdateServiceAccountRequest; +import picocli.CommandLine.Command; +import picocli.CommandLine.Mixin; +import picocli.CommandLine.Option; + +@Command( + name = "update", + description = "Update a service account" +) +public class UpdateCmd extends AbstractServiceAccountsCmd { + + @Option(names = {"-o", "--organization"}, description = ORGANIZATION_DESCRIPTION, required = true) + public String organizationRef; + + @Mixin + ServiceAccountRefOptions ref; + + @Option(names = {"--new-name"}, description = "New service account name. Must be unique across all Platform users.") + public String newName; + + @Option(names = {"-d", "--description"}, description = "New service account description (max 1000 characters).") + public String description; + + @Override + protected Response exec() throws ApiException { + Long orgId = findOrganizationByRef(organizationRef).getOrgId(); + ServiceAccountDto sa = fetchServiceAccount(orgId, ref); + + // PATCH semantics: fields left null are not changed + UpdateServiceAccountRequest request = new UpdateServiceAccountRequest() + .name(newName) + .description(description); + + ServiceAccountDto updated = serviceAccountsApi().updateServiceAccount(orgId, sa.getId(), request).getServiceAccount(); + + return new ServiceAccountUpdated(organizationRef, updated); + } +} diff --git a/src/main/java/io/seqera/tower/cli/commands/serviceaccounts/ViewCmd.java b/src/main/java/io/seqera/tower/cli/commands/serviceaccounts/ViewCmd.java new file mode 100644 index 00000000..3c359fa2 --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/commands/serviceaccounts/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.serviceaccounts; + +import io.seqera.tower.ApiException; +import io.seqera.tower.cli.responses.Response; +import io.seqera.tower.cli.responses.serviceaccounts.ServiceAccountView; +import picocli.CommandLine.Command; +import picocli.CommandLine.Mixin; +import picocli.CommandLine.Option; + +@Command( + name = "view", + description = "View service account details" +) +public class ViewCmd extends AbstractServiceAccountsCmd { + + @Option(names = {"-o", "--organization"}, description = ORGANIZATION_DESCRIPTION, required = true) + public String organizationRef; + + @Mixin + ServiceAccountRefOptions ref; + + @Override + protected Response exec() throws ApiException { + Long orgId = findOrganizationByRef(organizationRef).getOrgId(); + return new ServiceAccountView(organizationRef, fetchServiceAccount(orgId, ref)); + } +} diff --git a/src/main/java/io/seqera/tower/cli/responses/agents/AgentAdded.java b/src/main/java/io/seqera/tower/cli/responses/agents/AgentAdded.java new file mode 100644 index 00000000..4e9d309f --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/responses/agents/AgentAdded.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.agents; + +import io.seqera.tower.cli.responses.Response; +import io.seqera.tower.model.AgentDbDto; + +public class AgentAdded extends Response { + + public final String workspaceRef; + public final AgentDbDto agent; + + public AgentAdded(String workspaceRef, AgentDbDto agent) { + this.workspaceRef = workspaceRef; + this.agent = agent; + } + + @Override + public String toString() { + return ansi(String.format("%n @|yellow New agent '%s' (%s) added at %s workspace|@%n", agent.getName(), agent.getId(), workspaceRef)); + } +} diff --git a/src/main/java/io/seqera/tower/cli/responses/agents/AgentDeleted.java b/src/main/java/io/seqera/tower/cli/responses/agents/AgentDeleted.java new file mode 100644 index 00000000..3d912b89 --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/responses/agents/AgentDeleted.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.agents; + +import io.seqera.tower.cli.responses.Response; + +public class AgentDeleted extends Response { + + public final String workspaceRef; + public final String agentName; + + public AgentDeleted(String workspaceRef, String agentName) { + this.workspaceRef = workspaceRef; + this.agentName = agentName; + } + + @Override + public String toString() { + return ansi(String.format("%n @|yellow Agent '%s' deleted at %s workspace|@%n", agentName, workspaceRef)); + } +} diff --git a/src/main/java/io/seqera/tower/cli/responses/agents/AgentLaunched.java b/src/main/java/io/seqera/tower/cli/responses/agents/AgentLaunched.java new file mode 100644 index 00000000..f28eb6bb --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/responses/agents/AgentLaunched.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.agents; + +import io.seqera.tower.cli.responses.Response; + +public class AgentLaunched extends Response { + + public final String workspaceRef; + public final String agentRunId; + public final String status; + + public AgentLaunched(String workspaceRef, String agentRunId, String status) { + this.workspaceRef = workspaceRef; + this.agentRunId = agentRunId; + this.status = status; + } + + @Override + public String toString() { + return ansi(String.format("%n @|yellow Agent run '%s' submitted at %s workspace (status: %s)|@%n", agentRunId, workspaceRef, status)); + } +} diff --git a/src/main/java/io/seqera/tower/cli/responses/agents/AgentRunView.java b/src/main/java/io/seqera/tower/cli/responses/agents/AgentRunView.java new file mode 100644 index 00000000..ac9415ab --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/responses/agents/AgentRunView.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.agents; + +import io.seqera.tower.cli.responses.Response; +import io.seqera.tower.cli.utils.TableList; +import io.seqera.tower.model.AgentRunStatusResponse; + +import java.io.PrintWriter; +import java.util.Objects; + +public class AgentRunView extends Response { + + public final String workspaceRef; + public final AgentRunStatusResponse agentRun; + + public AgentRunView(String workspaceRef, AgentRunStatusResponse agentRun) { + this.workspaceRef = workspaceRef; + this.agentRun = agentRun; + } + + @Override + public void toString(PrintWriter out) { + out.println(ansi(String.format("%n @|bold Agent run at %s workspace:|@%n", workspaceRef))); + TableList table = new TableList(out, 2); + table.setPrefix(" "); + table.addRow("ID", agentRun.getAgentRunId()); + table.addRow("Status", agentRun.getStatus()); + table.addRow("Thread ID", Objects.toString(agentRun.getThreadId(), "")); + table.addRow("Session ID", Objects.toString(agentRun.getSessionId(), "")); + table.print(); + out.println(""); + } +} diff --git a/src/main/java/io/seqera/tower/cli/responses/agents/AgentRunsList.java b/src/main/java/io/seqera/tower/cli/responses/agents/AgentRunsList.java new file mode 100644 index 00000000..0e4fcc1a --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/responses/agents/AgentRunsList.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.responses.agents; + +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.AgentRunDbDto; +import jakarta.annotation.Nullable; + +import java.io.PrintWriter; +import java.util.List; +import java.util.Objects; + +import static io.seqera.tower.cli.utils.FormatHelper.formatDate; +import static io.seqera.tower.cli.utils.FormatHelper.formatDescription; + +public class AgentRunsList extends Response { + + public final String workspaceRef; + public final List agentRuns; + + @JsonIgnore + @Nullable + private final PaginationInfo paginationInfo; + + public AgentRunsList(String workspaceRef, List agentRuns, @Nullable PaginationInfo paginationInfo) { + this.workspaceRef = workspaceRef; + this.agentRuns = agentRuns; + this.paginationInfo = paginationInfo; + } + + @Override + public void toString(PrintWriter out) { + out.println(ansi(String.format("%n @|bold Agent runs at %s workspace:|@%n", workspaceRef))); + if (agentRuns == null || agentRuns.isEmpty()) { + out.println(ansi(" @|yellow No agent runs found|@")); + return; + } + + TableList table = new TableList(out, 6, "ID", "Title", "Status", "Trigger", "Run ID", "Created"); + table.setPrefix(" "); + agentRuns.forEach(run -> table.addRow( + run.getId(), + formatDescription(run.getTitle(), 60), + run.getStatus() == null ? "NA" : run.getStatus().getValue(), + run.getTrigger() == null ? "" : Objects.toString(run.getTrigger().getType(), ""), + Objects.toString(run.getWorkflowId(), ""), + formatDate(run.getDateCreated()) + )); + table.print(); + PaginationInfo.addFooter(out, paginationInfo); + out.println(""); + } +} diff --git a/src/main/java/io/seqera/tower/cli/responses/agents/AgentUpdated.java b/src/main/java/io/seqera/tower/cli/responses/agents/AgentUpdated.java new file mode 100644 index 00000000..c2cb9d7c --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/responses/agents/AgentUpdated.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.agents; + +import io.seqera.tower.cli.responses.Response; + +public class AgentUpdated extends Response { + + public final String workspaceRef; + public final String agentName; + public final String action; + + public AgentUpdated(String workspaceRef, String agentName, String action) { + this.workspaceRef = workspaceRef; + this.agentName = agentName; + this.action = action; + } + + @Override + public String toString() { + return ansi(String.format("%n @|yellow Agent '%s' %s at %s workspace|@%n", agentName, action, workspaceRef)); + } +} diff --git a/src/main/java/io/seqera/tower/cli/responses/agents/AgentView.java b/src/main/java/io/seqera/tower/cli/responses/agents/AgentView.java new file mode 100644 index 00000000..6ed371f3 --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/responses/agents/AgentView.java @@ -0,0 +1,59 @@ +/* + * Copyright 2021-2026, Seqera. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + +package io.seqera.tower.cli.responses.agents; + +import io.seqera.tower.cli.responses.Response; +import io.seqera.tower.cli.utils.TableList; +import io.seqera.tower.model.AgentDbDto; + +import java.io.PrintWriter; +import java.util.Objects; + +import static io.seqera.tower.cli.utils.FormatHelper.formatDate; + +public class AgentView extends Response { + + public final String workspaceRef; + public final AgentDbDto agent; + + public AgentView(String workspaceRef, AgentDbDto agent) { + this.workspaceRef = workspaceRef; + this.agent = agent; + } + + @Override + public void toString(PrintWriter out) { + out.println(ansi(String.format("%n @|bold Agent at %s workspace:|@%n", workspaceRef))); + TableList table = new TableList(out, 2); + table.setPrefix(" "); + table.addRow("ID", agent.getId()); + table.addRow("Name", agent.getName()); + table.addRow("Description", Objects.toString(agent.getDescription(), "")); + table.addRow("Status", agent.getStatus() == null ? "NA" : agent.getStatus().getValue()); + table.addRow("Service account", Objects.toString(agent.getServiceAccountName(), "")); + table.addRow("GitHub App credentials", Objects.toString(agent.getGithubAppCredentialId(), "")); + table.addRow("Created by", Objects.toString(agent.getCreatedByUserName(), "")); + table.addRow("Created", formatDate(agent.getDateCreated())); + table.addRow("Updated", formatDate(agent.getLastUpdated())); + table.print(); + + out.println(ansi(String.format("%n @|bold Instructions:|@%n"))); + out.println(agent.getAgentInstructions()); + out.println(""); + } +} diff --git a/src/main/java/io/seqera/tower/cli/responses/agents/AgentsList.java b/src/main/java/io/seqera/tower/cli/responses/agents/AgentsList.java new file mode 100644 index 00000000..1a5082b4 --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/responses/agents/AgentsList.java @@ -0,0 +1,69 @@ +/* + * Copyright 2021-2026, Seqera. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + +package io.seqera.tower.cli.responses.agents; + +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.AgentDbDto; +import jakarta.annotation.Nullable; + +import java.io.PrintWriter; +import java.util.List; + +import static io.seqera.tower.cli.utils.FormatHelper.formatDate; +import static io.seqera.tower.cli.utils.FormatHelper.formatDescription; + +public class AgentsList extends Response { + + public final String workspaceRef; + public final List agents; + + @JsonIgnore + @Nullable + private final PaginationInfo paginationInfo; + + public AgentsList(String workspaceRef, List agents, @Nullable PaginationInfo paginationInfo) { + this.workspaceRef = workspaceRef; + this.agents = agents; + this.paginationInfo = paginationInfo; + } + + @Override + public void toString(PrintWriter out) { + out.println(ansi(String.format("%n @|bold Agents at %s workspace:|@%n", workspaceRef))); + if (agents == null || agents.isEmpty()) { + out.println(ansi(" @|yellow No agents found|@")); + return; + } + + TableList table = new TableList(out, 5, "ID", "Name", "Description", "Status", "Updated"); + table.setPrefix(" "); + agents.forEach(agent -> table.addRow( + agent.getId(), + agent.getName(), + formatDescription(agent.getDescription(), 100), + agent.getStatus() == null ? "NA" : agent.getStatus().getValue(), + formatDate(agent.getLastUpdated()) + )); + table.print(); + PaginationInfo.addFooter(out, paginationInfo); + out.println(""); + } +} diff --git a/src/main/java/io/seqera/tower/cli/responses/serviceaccounts/ServiceAccountAdded.java b/src/main/java/io/seqera/tower/cli/responses/serviceaccounts/ServiceAccountAdded.java new file mode 100644 index 00000000..75542d5d --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/responses/serviceaccounts/ServiceAccountAdded.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.serviceaccounts; + +import io.seqera.tower.cli.responses.Response; +import io.seqera.tower.model.ServiceAccountDto; + +public class ServiceAccountAdded extends Response { + + public final String organizationName; + public final ServiceAccountDto serviceAccount; + + public ServiceAccountAdded(String organizationName, ServiceAccountDto serviceAccount) { + this.organizationName = organizationName; + this.serviceAccount = serviceAccount; + } + + @Override + public String toString() { + return ansi(String.format("%n @|yellow Service account '%s' (%d) added to %s organization|@%n", serviceAccount.getName(), serviceAccount.getId(), organizationName)); + } +} diff --git a/src/main/java/io/seqera/tower/cli/responses/serviceaccounts/ServiceAccountDeleted.java b/src/main/java/io/seqera/tower/cli/responses/serviceaccounts/ServiceAccountDeleted.java new file mode 100644 index 00000000..683ee14f --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/responses/serviceaccounts/ServiceAccountDeleted.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.serviceaccounts; + +import io.seqera.tower.cli.responses.Response; +import io.seqera.tower.model.ServiceAccountDto; + +public class ServiceAccountDeleted extends Response { + + public final String organizationName; + public final ServiceAccountDto serviceAccount; + + public ServiceAccountDeleted(String organizationName, ServiceAccountDto serviceAccount) { + this.organizationName = organizationName; + this.serviceAccount = serviceAccount; + } + + @Override + public String toString() { + return ansi(String.format("%n @|yellow Service account '%s' deleted at %s organization|@%n", serviceAccount.getName(), organizationName)); + } +} diff --git a/src/main/java/io/seqera/tower/cli/responses/serviceaccounts/ServiceAccountUpdated.java b/src/main/java/io/seqera/tower/cli/responses/serviceaccounts/ServiceAccountUpdated.java new file mode 100644 index 00000000..0a6764bb --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/responses/serviceaccounts/ServiceAccountUpdated.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.serviceaccounts; + +import io.seqera.tower.cli.responses.Response; +import io.seqera.tower.model.ServiceAccountDto; + +public class ServiceAccountUpdated extends Response { + + public final String organizationName; + public final ServiceAccountDto serviceAccount; + + public ServiceAccountUpdated(String organizationName, ServiceAccountDto serviceAccount) { + this.organizationName = organizationName; + this.serviceAccount = serviceAccount; + } + + @Override + public String toString() { + return ansi(String.format("%n @|yellow Service account '%s' updated at %s organization|@%n", serviceAccount.getName(), organizationName)); + } +} diff --git a/src/main/java/io/seqera/tower/cli/responses/serviceaccounts/ServiceAccountView.java b/src/main/java/io/seqera/tower/cli/responses/serviceaccounts/ServiceAccountView.java new file mode 100644 index 00000000..42690ca1 --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/responses/serviceaccounts/ServiceAccountView.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.responses.serviceaccounts; + +import io.seqera.tower.cli.responses.Response; +import io.seqera.tower.cli.utils.TableList; +import io.seqera.tower.model.ServiceAccountDto; + +import java.io.PrintWriter; + +import static io.seqera.tower.cli.utils.FormatHelper.formatDate; + +public class ServiceAccountView extends Response { + + public final String organizationName; + public final ServiceAccountDto serviceAccount; + + public ServiceAccountView(String organizationName, ServiceAccountDto serviceAccount) { + this.organizationName = organizationName; + this.serviceAccount = serviceAccount; + } + + @Override + public void toString(PrintWriter out) { + out.println(ansi(String.format("%n @|bold Service account at %s organization:|@%n", organizationName))); + TableList table = new TableList(out, 2); + table.setPrefix(" "); + table.addRow("ID", serviceAccount.getId().toString()); + table.addRow("Name", serviceAccount.getName()); + table.addRow("Description", serviceAccount.getDescription()); + table.addRow("Member ID", serviceAccount.getMemberId() == null ? null : serviceAccount.getMemberId().toString()); + table.addRow("Created", formatDate(serviceAccount.getCreatedAt())); + table.print(); + out.println(""); + } +} diff --git a/src/main/java/io/seqera/tower/cli/responses/serviceaccounts/ServiceAccountsList.java b/src/main/java/io/seqera/tower/cli/responses/serviceaccounts/ServiceAccountsList.java new file mode 100644 index 00000000..ff637936 --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/responses/serviceaccounts/ServiceAccountsList.java @@ -0,0 +1,74 @@ +/* + * 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.serviceaccounts; + +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.ServiceAccountDto; +import jakarta.annotation.Nullable; + +import java.io.PrintWriter; +import java.util.List; + +import static io.seqera.tower.cli.utils.FormatHelper.formatDate; + +public class ServiceAccountsList extends Response { + + public final String organizationName; + @Nullable + public final String workspaceName; + public final List serviceAccounts; + + @JsonIgnore + @Nullable + private final PaginationInfo paginationInfo; + + public ServiceAccountsList(String organizationName, @Nullable String workspaceName, List serviceAccounts, @Nullable PaginationInfo paginationInfo) { + this.organizationName = organizationName; + this.workspaceName = workspaceName; + this.serviceAccounts = serviceAccounts; + this.paginationInfo = paginationInfo; + } + + @Override + public void toString(PrintWriter out) { + out.println(ansi(workspaceName == null + ? String.format("%n @|bold Service accounts for %s organization:|@%n", organizationName) + : String.format("%n @|bold Service accounts for '%s/%s' workspace:|@%n", organizationName, workspaceName))); + + if (serviceAccounts == null || serviceAccounts.isEmpty()) { + out.println(ansi(" @|yellow No service accounts found|@")); + return; + } + + TableList table = new TableList(out, 4, "ID", "Name", "Description", "Created"); + table.setPrefix(" "); + serviceAccounts.forEach(sa -> table.addRow( + sa.getId().toString(), + sa.getName(), + sa.getDescription(), + formatDate(sa.getCreatedAt()) + )); + table.print(); + + PaginationInfo.addFooter(out, paginationInfo); + + out.println(""); + } +} diff --git a/src/main/java/io/seqera/tower/cli/utils/FormatHelper.java b/src/main/java/io/seqera/tower/cli/utils/FormatHelper.java index e9e1a85b..77cb50e6 100644 --- a/src/main/java/io/seqera/tower/cli/utils/FormatHelper.java +++ b/src/main/java/io/seqera/tower/cli/utils/FormatHelper.java @@ -331,6 +331,8 @@ public static String formatOrgRole(OrgRole role) { return ansi("@|fg(green) MEMBER|@"); case owner: return ansi("@|fg(magenta) OWNER|@"); + case service_account: + return ansi("@|fg(cyan) SERVICE_ACCOUNT|@"); default: return role.toString(); } diff --git a/src/test/java/io/seqera/tower/cli/agents/AgentsCmdTest.java b/src/test/java/io/seqera/tower/cli/agents/AgentsCmdTest.java new file mode 100644 index 00000000..725be386 --- /dev/null +++ b/src/test/java/io/seqera/tower/cli/agents/AgentsCmdTest.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.agents; + +import com.fasterxml.jackson.core.JsonProcessingException; +import io.seqera.tower.cli.BaseCmdTest; +import io.seqera.tower.cli.commands.enums.OutputType; +import io.seqera.tower.cli.commands.global.PaginationOptions; +import io.seqera.tower.cli.exceptions.TowerException; +import io.seqera.tower.cli.responses.agents.AgentAdded; +import io.seqera.tower.cli.responses.agents.AgentDeleted; +import io.seqera.tower.cli.responses.agents.AgentLaunched; +import io.seqera.tower.cli.responses.agents.AgentRunView; +import io.seqera.tower.cli.responses.agents.AgentRunsList; +import io.seqera.tower.cli.responses.agents.AgentUpdated; +import io.seqera.tower.cli.responses.agents.AgentView; +import io.seqera.tower.cli.responses.agents.AgentsList; +import io.seqera.tower.cli.utils.PaginationInfo; +import io.seqera.tower.model.AgentDbDto; +import io.seqera.tower.model.AgentRunDbDto; +import io.seqera.tower.model.AgentRunStatusResponse; +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 static io.seqera.tower.cli.utils.JsonHelper.parseJson; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockserver.matchers.Times.exactly; +import static org.mockserver.model.HttpRequest.request; +import static org.mockserver.model.HttpResponse.response; +import static org.mockserver.model.JsonBody.json; + +class AgentsCmdTest extends BaseCmdTest { + + private static final String WSP_ID = "75887156211589"; + private static final String WSP_REF = "[organization1 / workspace1]"; + + private static final String AGENT = """ + { + "id": "agt_1", + "name": "fix-failed-runs", + "description": "Debug failed runs", + "agentInstructions": "Find the root cause", + "agentInstructionsTemplateId": "debug-template", + "serviceAccountId": 42, + "serviceAccountName": "sa-agent", + "githubAppCredentialId": "cred1", + "status": "active", + "workspaceId": 75887156211589, + "createdBy": 1264, + "createdByUserName": "jordi", + "dateCreated": "2026-09-01T10:00:00Z", + "lastUpdated": "2026-09-02T10:00:00Z" + } + """; + + @BeforeEach + void init(MockServerClient mock) { + mock.reset(); + mock.when(request().withMethod("GET").withPath("/user-info"), exactly(1)) + .respond(response().withStatusCode(200).withBody(loadResource("user")).withContentType(MediaType.APPLICATION_JSON)); + mock.when(request().withMethod("GET").withPath("/user/1264/workspaces"), exactly(1)) + .respond(response().withStatusCode(200).withBody(loadResource("workspaces/workspaces_list")).withContentType(MediaType.APPLICATION_JSON)); + } + + private void mockDescribe(MockServerClient mock) { + mock.when(request().withMethod("GET").withPath("/agents/agt_1").withQueryStringParameter("workspaceId", WSP_ID), exactly(1)) + .respond(response().withStatusCode(200).withBody("{\"agent\":" + AGENT + "}").withContentType(MediaType.APPLICATION_JSON)); + } + + private void mockFindByName(MockServerClient mock) { + mock.when(request().withMethod("GET").withPath("/agents") + .withQueryStringParameter("workspaceId", WSP_ID) + .withQueryStringParameter("search", "fix-failed-runs") + .withQueryStringParameter("max", "100") + .withQueryStringParameter("offset", "0"), exactly(1)) + .respond(response().withStatusCode(200).withBody("{\"agents\":[" + AGENT.replace("fix-failed-runs", "fix-failed-runs-v2").replace("agt_1", "agt_2") + "," + AGENT + "],\"totalSize\":2}").withContentType(MediaType.APPLICATION_JSON)); + } + + private static AgentDbDto agent() throws JsonProcessingException { + return parseJson(AGENT, AgentDbDto.class); + } + + @ParameterizedTest + @EnumSource(OutputType.class) + void testList(OutputType format, MockServerClient mock) throws JsonProcessingException { + mock.when(request().withMethod("GET").withPath("/agents") + .withQueryStringParameter("workspaceId", WSP_ID) + .withQueryStringParameter("search", "fix") + .withQueryStringParameter("max", "10") + .withQueryStringParameter("offset", "10"), exactly(1)) + .respond(response().withStatusCode(200).withBody("{\"agents\":[" + AGENT + "],\"totalSize\":11}").withContentType(MediaType.APPLICATION_JSON)); + + ExecOut out = exec(format, mock, "agents", "list", "-w", WSP_ID, "-f", "fix", "--max", "10", "--page", "2"); + + assertOutput(format, out, new AgentsList(WSP_REF, List.of(agent()), PaginationInfo.from(null, 10, 2, 11L))); + } + + @ParameterizedTest + @EnumSource(OutputType.class) + void testViewByName(OutputType format, MockServerClient mock) throws JsonProcessingException { + mockFindByName(mock); + mockDescribe(mock); + + ExecOut out = exec(format, mock, "agents", "view", "-w", WSP_ID, "-n", "fix-failed-runs"); + + assertOutput(format, out, new AgentView(WSP_REF, agent())); + } + + @Test + void testViewUnknownName(MockServerClient mock) { + mock.when(request().withMethod("GET").withPath("/agents").withQueryStringParameter("search", "missing"), exactly(1)) + .respond(response().withStatusCode(200).withBody("{\"agents\":[],\"totalSize\":0}").withContentType(MediaType.APPLICATION_JSON)); + + ExecOut out = exec(mock, "agents", "view", "-w", WSP_ID, "-n", "missing"); + + assertEquals(errorMessage(out.app, new TowerException("Unknown agent 'missing' at " + WSP_REF + " workspace")), out.stdErr); + assertEquals(1, out.exitCode); + } + + @ParameterizedTest + @EnumSource(OutputType.class) + void testAdd(OutputType format, MockServerClient mock) throws IOException { + mock.when(request().withMethod("POST").withPath("/agents").withQueryStringParameter("workspaceId", WSP_ID) + .withBody(json(""" + { + "name": "fix-failed-runs", + "description": "Debug failed runs", + "agentInstructions": "Find the root cause", + "serviceAccountId": 42, + "githubAppCredentialId": "cred1" + } + """)), exactly(1)) + .respond(response().withStatusCode(200).withBody("{\"agent\":" + AGENT + "}").withContentType(MediaType.APPLICATION_JSON)); + + String instructions = tempFile("Find the root cause", "instructions", ".md"); + ExecOut out = exec(format, mock, "agents", "add", "-w", WSP_ID, "-n", "fix-failed-runs", "-d", "Debug failed runs", + "--instructions-file", instructions, "--service-account-id", "42", "--github-app-credentials-id", "cred1"); + + assertOutput(format, out, new AgentAdded(WSP_REF, agent())); + } + + @Test + void testAddRequiresInstructions(MockServerClient mock) { + ExecOut out = exec(mock, "agents", "add", "-w", WSP_ID, "-n", "fix-failed-runs"); + + assertEquals(2, out.exitCode); + } + + @ParameterizedTest + @EnumSource(OutputType.class) + void testUpdate(OutputType format, MockServerClient mock) throws JsonProcessingException { + mockDescribe(mock); + // Fields not given on the command line keep their current value, as the API replaces the agent + mock.when(request().withMethod("PUT").withPath("/agents/agt_1").withQueryStringParameter("workspaceId", WSP_ID) + .withBody(json(""" + { + "name": "new-name", + "description": "Debug failed runs", + "agentInstructions": "New instructions", + "agentInstructionsTemplateId": "debug-template", + "serviceAccountId": 42, + "githubAppCredentialId": "cred1" + } + """)), exactly(1)) + .respond(response().withStatusCode(200).withBody("{\"agent\":" + AGENT.replace("\"fix-failed-runs\"", "\"new-name\"") + "}").withContentType(MediaType.APPLICATION_JSON)); + + ExecOut out = exec(format, mock, "agents", "update", "-w", WSP_ID, "-i", "agt_1", "--new-name", "new-name", "--instructions", "New instructions"); + + assertOutput(format, out, new AgentUpdated(WSP_REF, "new-name", "updated")); + } + + @ParameterizedTest + @EnumSource(OutputType.class) + void testDelete(OutputType format, MockServerClient mock) { + mockDescribe(mock); + mock.when(request().withMethod("DELETE").withPath("/agents/agt_1").withQueryStringParameter("workspaceId", WSP_ID), exactly(1)) + .respond(response().withStatusCode(204)); + + ExecOut out = exec(format, mock, "agents", "delete", "-w", WSP_ID, "-i", "agt_1"); + + assertOutput(format, out, new AgentDeleted(WSP_REF, "fix-failed-runs")); + } + + @ParameterizedTest + @EnumSource(OutputType.class) + void testEnable(OutputType format, MockServerClient mock) { + mockDescribe(mock); + mock.when(request().withMethod("POST").withPath("/agents/agt_1/enable").withQueryStringParameter("workspaceId", WSP_ID), exactly(1)) + .respond(response().withStatusCode(200).withBody("{\"agent\":" + AGENT + "}").withContentType(MediaType.APPLICATION_JSON)); + + ExecOut out = exec(format, mock, "agents", "enable", "-w", WSP_ID, "-i", "agt_1"); + + assertOutput(format, out, new AgentUpdated(WSP_REF, "fix-failed-runs", "enabled")); + } + + @ParameterizedTest + @EnumSource(OutputType.class) + void testDisable(OutputType format, MockServerClient mock) { + mockFindByName(mock); + mockDescribe(mock); + mock.when(request().withMethod("POST").withPath("/agents/agt_1/disable").withQueryStringParameter("workspaceId", WSP_ID), exactly(1)) + .respond(response().withStatusCode(200).withBody("{\"agent\":" + AGENT.replace("active", "inactive") + "}").withContentType(MediaType.APPLICATION_JSON)); + + ExecOut out = exec(format, mock, "agents", "disable", "-w", WSP_ID, "-n", "fix-failed-runs"); + + assertOutput(format, out, new AgentUpdated(WSP_REF, "fix-failed-runs", "disabled")); + } + + @ParameterizedTest + @EnumSource(OutputType.class) + void testLaunchConfiguredAgent(OutputType format, MockServerClient mock) { + mockDescribe(mock); + mock.when(request().withMethod("POST").withPath("/agents/launch").withQueryStringParameter("workspaceId", WSP_ID) + .withBody(json("{\"agentConfigId\":\"agt_1\",\"instructions\":\"Find the root cause\"}")), exactly(1)) + .respond(response().withStatusCode(200).withBody("{\"agentRunId\":\"run_1\",\"status\":\"pending\"}").withContentType(MediaType.APPLICATION_JSON)); + + ExecOut out = exec(format, mock, "agents", "launch", "-w", WSP_ID, "-i", "agt_1"); + + assertOutput(format, out, new AgentLaunched(WSP_REF, "run_1", "pending")); + } + + @ParameterizedTest + @EnumSource(OutputType.class) + void testLaunchAdHocInstructions(OutputType format, MockServerClient mock) { + mock.when(request().withMethod("POST").withPath("/agents/launch").withQueryStringParameter("workspaceId", WSP_ID) + .withBody(json("{\"instructions\":\"Summarize last week runs\"}", MatchType.STRICT)), exactly(1)) + .respond(response().withStatusCode(200).withBody("{\"agentRunId\":\"run_2\",\"status\":\"pending\"}").withContentType(MediaType.APPLICATION_JSON)); + + ExecOut out = exec(format, mock, "agents", "launch", "-w", WSP_ID, "--instructions", "Summarize last week runs"); + + assertOutput(format, out, new AgentLaunched(WSP_REF, "run_2", "pending")); + } + + @Test + void testLaunchWithoutAgentOrInstructions(MockServerClient mock) { + ExecOut out = exec(mock, "agents", "launch", "-w", WSP_ID); + + assertEquals(errorMessage(out.app, new TowerException("Specify an agent to launch (--id or --name) or the instructions to run (--instructions or --instructions-file)")), out.stdErr); + assertEquals(1, out.exitCode); + } + + @ParameterizedTest + @EnumSource(OutputType.class) + void testRunsList(OutputType format, MockServerClient mock) throws JsonProcessingException { + String run = """ + { + "id": "run_1", + "title": "Debug run 4abc", + "status": "completed", + "trigger": {"type": "manual", "entityId": "agt_1"}, + "workflowId": "4abc", + "workspaceId": 75887156211589, + "dateCreated": "2026-09-03T10:00:00Z", + "lastUpdated": "2026-09-03T10:05:00Z" + } + """; + mock.when(request().withMethod("GET").withPath("/agents/runs") + .withQueryStringParameter("workspaceId", WSP_ID) + .withQueryStringParameter("search", "status:completed") + .withQueryStringParameter("max", "100") + .withQueryStringParameter("offset", "0"), exactly(1)) + .respond(response().withStatusCode(200).withBody("{\"agentRuns\":[" + run + "],\"totalSize\":1}").withContentType(MediaType.APPLICATION_JSON)); + + ExecOut out = exec(format, mock, "agents", "runs", "list", "-w", WSP_ID, "-f", "status:completed"); + + assertOutput(format, out, new AgentRunsList(WSP_REF, List.of(parseJson(run, AgentRunDbDto.class)), PaginationInfo.from(new PaginationOptions(), 1L))); + } + + @ParameterizedTest + @EnumSource(OutputType.class) + void testRunsView(OutputType format, MockServerClient mock) throws JsonProcessingException { + String status = "{\"agentRunId\":\"run_1\",\"status\":\"running\",\"threadId\":\"thr_1\",\"sessionId\":\"ses_1\"}"; + mock.when(request().withMethod("GET").withPath("/agents/runs/run_1/status").withQueryStringParameter("workspaceId", WSP_ID), exactly(1)) + .respond(response().withStatusCode(200).withBody(status).withContentType(MediaType.APPLICATION_JSON)); + + ExecOut out = exec(format, mock, "agents", "runs", "view", "-w", WSP_ID, "-i", "run_1"); + + assertOutput(format, out, new AgentRunView(WSP_REF, parseJson(status, AgentRunStatusResponse.class))); + } +} diff --git a/src/test/java/io/seqera/tower/cli/serviceaccounts/ServiceAccountsCmdTest.java b/src/test/java/io/seqera/tower/cli/serviceaccounts/ServiceAccountsCmdTest.java new file mode 100644 index 00000000..2ab1a03a --- /dev/null +++ b/src/test/java/io/seqera/tower/cli/serviceaccounts/ServiceAccountsCmdTest.java @@ -0,0 +1,219 @@ +/* + * 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.serviceaccounts; + +import com.fasterxml.jackson.core.JsonProcessingException; +import io.seqera.tower.cli.BaseCmdTest; +import io.seqera.tower.cli.commands.enums.OutputType; +import io.seqera.tower.cli.exceptions.TowerException; +import io.seqera.tower.cli.responses.serviceaccounts.ServiceAccountAdded; +import io.seqera.tower.cli.responses.serviceaccounts.ServiceAccountDeleted; +import io.seqera.tower.cli.responses.serviceaccounts.ServiceAccountUpdated; +import io.seqera.tower.cli.responses.serviceaccounts.ServiceAccountView; +import io.seqera.tower.cli.responses.serviceaccounts.ServiceAccountsList; +import io.seqera.tower.model.ServiceAccountDto; +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.util.List; + +import static io.seqera.tower.cli.utils.JsonHelper.parseJson; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockserver.matchers.Times.exactly; +import static org.mockserver.model.HttpRequest.request; +import static org.mockserver.model.HttpResponse.response; +import static org.mockserver.model.JsonBody.json; + +class ServiceAccountsCmdTest extends BaseCmdTest { + + private static final String ORG_PATH = "/orgs/27736513644467/service-accounts"; + + private static final String SA_LIST = """ + { + "serviceAccounts": [ + {"id": 11, "name": "ci-bot", "description": "CI pipelines", "createdAt": "2026-09-01T10:00:00Z", "memberId": 21}, + {"id": 12, "name": "nightly", "description": null, "createdAt": "2026-09-02T10:00:00Z", "memberId": 22} + ], + "totalSize": 2 + } + """; + + private static final String CI_BOT = """ + {"id": 11, "name": "ci-bot", "description": "CI pipelines", "createdAt": "2026-09-01T10:00:00Z", "memberId": 21} + """; + + @BeforeEach + 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 static ServiceAccountDto ciBot() throws JsonProcessingException { + return parseJson(CI_BOT, ServiceAccountDto.class); + } + + @ParameterizedTest + @EnumSource(OutputType.class) + void testList(OutputType format, MockServerClient mock) throws JsonProcessingException { + mock.when( + request().withMethod("GET").withPath(ORG_PATH) + .withQueryStringParameter("offset", "0") + .withQueryStringParameter("max", "100"), exactly(1) + ).respond( + response().withStatusCode(200).withBody(SA_LIST).withContentType(MediaType.APPLICATION_JSON) + ); + + ExecOut out = exec(format, mock, "service-accounts", "list", "-o", "organization1"); + + assertOutput(format, out, new ServiceAccountsList("organization1", null, + parseJson(SA_LIST, io.seqera.tower.model.ListServiceAccountsResponse.class).getServiceAccounts(), + null)); + } + + @Test + void testListWorkspaceFollowsPageToken(MockServerClient mock) { + mock.when( + request().withMethod("GET").withPath("/orgs/27736513644467/workspaces/75887156211589/service-accounts") + .withQueryStringParameter("pageToken", "next-1"), exactly(1) + ).respond( + response().withStatusCode(200).withBody(""" + {"items": [{"id": 12, "name": "nightly", "createdAt": "2026-09-02T10:00:00Z"}], "nextPageToken": null} + """).withContentType(MediaType.APPLICATION_JSON) + ); + mock.when( + request().withMethod("GET").withPath("/orgs/27736513644467/workspaces/75887156211589/service-accounts") + .withQueryStringParameter("pageSize", "100"), exactly(1) + ).respond( + response().withStatusCode(200).withBody(""" + {"items": [{"id": 11, "name": "ci-bot", "description": "CI pipelines", "createdAt": "2026-09-01T10:00:00Z"}], "nextPageToken": "next-1"} + """).withContentType(MediaType.APPLICATION_JSON) + ); + + ExecOut out = exec(OutputType.json, mock, "service-accounts", "list", "-w", "75887156211589"); + + assertEquals("", out.stdErr); + assertEquals(0, out.exitCode); + assertEquals(true, out.stdOut.contains("\"workspaceName\" : \"workspace1\""), out.stdOut); + assertEquals(true, out.stdOut.contains("\"name\" : \"ci-bot\""), out.stdOut); + assertEquals(true, out.stdOut.contains("\"name\" : \"nightly\""), out.stdOut); + } + + @ParameterizedTest + @EnumSource(OutputType.class) + void testAdd(OutputType format, MockServerClient mock) throws JsonProcessingException { + mock.when( + request().withMethod("POST").withPath(ORG_PATH) + .withBody(json(""" + {"name": "ci-bot", "description": "CI pipelines"} + """, MatchType.STRICT)), exactly(1) + ).respond( + response().withStatusCode(201).withBody("{\"serviceAccount\": " + CI_BOT + "}").withContentType(MediaType.APPLICATION_JSON) + ); + + ExecOut out = exec(format, mock, "service-accounts", "add", "-o", "organization1", "-n", "ci-bot", "-d", "CI pipelines"); + + assertOutput(format, out, new ServiceAccountAdded("organization1", ciBot())); + } + + @ParameterizedTest + @EnumSource(OutputType.class) + void testViewById(OutputType format, MockServerClient mock) throws JsonProcessingException { + mock.when( + request().withMethod("GET").withPath(ORG_PATH + "/11"), exactly(1) + ).respond( + response().withStatusCode(200).withBody("{\"serviceAccount\": " + CI_BOT + "}").withContentType(MediaType.APPLICATION_JSON) + ); + + ExecOut out = exec(format, mock, "service-accounts", "view", "-o", "organization1", "-i", "11"); + + assertOutput(format, out, new ServiceAccountView("organization1", ciBot())); + } + + @Test + void testViewByName(MockServerClient mock) throws JsonProcessingException { + mock.when( + request().withMethod("GET").withPath(ORG_PATH), exactly(1) + ).respond( + response().withStatusCode(200).withBody(SA_LIST).withContentType(MediaType.APPLICATION_JSON) + ); + + ExecOut out = exec(OutputType.console, mock, "service-accounts", "view", "-o", "organization1", "-n", "ci-bot"); + + assertOutput(OutputType.console, out, new ServiceAccountView("organization1", ciBot())); + } + + @Test + void testViewByNameNotFound(MockServerClient mock) { + mock.when( + request().withMethod("GET").withPath(ORG_PATH), exactly(1) + ).respond( + response().withStatusCode(200).withBody(SA_LIST).withContentType(MediaType.APPLICATION_JSON) + ); + + ExecOut out = exec(mock, "service-accounts", "view", "-o", "organization1", "-n", "missing"); + + assertEquals(errorMessage(out.app, new TowerException("Service account 'missing' not found in organization '27736513644467'")), out.stdErr); + assertEquals(1, out.exitCode); + } + + @ParameterizedTest + @EnumSource(OutputType.class) + void testUpdateSendsOnlyChangedFields(OutputType format, MockServerClient mock) throws JsonProcessingException { + mock.when( + request().withMethod("GET").withPath(ORG_PATH + "/11"), exactly(1) + ).respond( + response().withStatusCode(200).withBody("{\"serviceAccount\": " + CI_BOT + "}").withContentType(MediaType.APPLICATION_JSON) + ); + String updated = CI_BOT.replace("ci-bot", "ci-runner"); + mock.when( + request().withMethod("PATCH").withPath(ORG_PATH + "/11") + .withBody(json("{\"name\": \"ci-runner\"}", MatchType.STRICT)), exactly(1) + ).respond( + response().withStatusCode(200).withBody("{\"serviceAccount\": " + updated + "}").withContentType(MediaType.APPLICATION_JSON) + ); + + ExecOut out = exec(format, mock, "service-accounts", "update", "-o", "organization1", "-i", "11", "--new-name", "ci-runner"); + + assertOutput(format, out, new ServiceAccountUpdated("organization1", parseJson(updated, ServiceAccountDto.class))); + } + + @ParameterizedTest + @EnumSource(OutputType.class) + void testDeleteByName(OutputType format, MockServerClient mock) throws JsonProcessingException { + mock.when( + request().withMethod("GET").withPath(ORG_PATH), exactly(1) + ).respond( + response().withStatusCode(200).withBody(SA_LIST).withContentType(MediaType.APPLICATION_JSON) + ); + mock.when( + request().withMethod("DELETE").withPath(ORG_PATH + "/11"), exactly(1) + ).respond( + response().withStatusCode(204) + ); + + ExecOut out = exec(format, mock, "service-accounts", "delete", "-o", "organization1", "-n", "ci-bot"); + + assertOutput(format, out, new ServiceAccountDeleted("organization1", ciBot())); + } +}