diff --git a/USAGE.md b/USAGE.md index 153a0c546..34af519e6 100644 --- a/USAGE.md +++ b/USAGE.md @@ -709,6 +709,28 @@ $ 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 ` (it gets the `VIEW` role; change it with `tw participants update -t MEMBER -n -r `), or set one as an agent's identity with `tw agents add|update --service-account ` or `--service-account-id `. + +> **Note**: All `tw service-accounts` operations except `list -w` require the organization owner role. So does looking up a service account by name in `tw participants add` and `tw agents add|update --service-account`. A service account still bound to active agents can't be deleted: bind those agents to another service account or disable them first. + +```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 +``` + ## Organization administration These commands act on an organization (`-o`/`--organization`). Most require the organization `OWNER` role. diff --git a/build.gradle b/build.gradle index a4d39eb6f..837cb850b 100644 --- a/build.gradle +++ b/build.gradle @@ -61,8 +61,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 { @@ -87,6 +92,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) @@ -137,7 +144,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/"] } } @@ -150,6 +157,9 @@ tasks.register('runReflectionConfigGenerator', JavaExec) { } tasks.named('shadowJar') { + manifest { + attributes 'Add-Opens': jerseyPatchOpens.join(' ') + } archiveBaseName.set('tw') archiveClassifier.set('') archiveVersion.set('') @@ -164,9 +174,10 @@ tasks.named('test') { events 'failed' exceptionFormat = 'full' } + 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/reachability-metadata.json b/conf/reachability-metadata.json index 52b6a02aa..dbdd0dee4 100644 --- a/conf/reachability-metadata.json +++ b/conf/reachability-metadata.json @@ -228,6 +228,15 @@ } ] }, + { + "type": "io.seqera.tower.cli.commands.AgentsCmd", + "methods": [ + { + "name": "", + "parameterTypes": [] + } + ] + }, { "type": "io.seqera.tower.cli.commands.CollaboratorsCmd", "allDeclaredFields": true, @@ -416,6 +425,15 @@ } ] }, + { + "type": "io.seqera.tower.cli.commands.ServiceAccountsCmd", + "methods": [ + { + "name": "", + "parameterTypes": [] + } + ] + }, { "type": "io.seqera.tower.cli.commands.SshKeysCmd", "methods": [ @@ -628,6 +646,289 @@ } ] }, + { + "type": "io.seqera.tower.cli.commands.agents.AbstractAgentsCmd" + }, + { + "type": "io.seqera.tower.cli.commands.agents.AddCmd", + "fields": [ + { + "name": "description" + }, + { + "name": "githubAppCredentialsId" + }, + { + "name": "instructions" + }, + { + "name": "name" + }, + { + "name": "serviceAccount" + }, + { + "name": "workspace" + } + ], + "methods": [ + { + "name": "", + "parameterTypes": [] + } + ] + }, + { + "type": "io.seqera.tower.cli.commands.agents.AgentInstructionsOptions", + "fields": [ + { + "name": "file" + }, + { + "name": "text" + } + ], + "methods": [ + { + "name": "", + "parameterTypes": [] + } + ] + }, + { + "type": "io.seqera.tower.cli.commands.agents.AgentRefOptions", + "fields": [ + { + "name": "agent" + } + ], + "methods": [ + { + "name": "", + "parameterTypes": [] + } + ] + }, + { + "type": "io.seqera.tower.cli.commands.agents.AgentRefOptions$AgentRef", + "fields": [ + { + "name": "id" + }, + { + "name": "name" + } + ], + "methods": [ + { + "name": "", + "parameterTypes": [] + } + ] + }, + { + "type": "io.seqera.tower.cli.commands.agents.AgentServiceAccountOptions", + "fields": [ + { + "name": "id" + }, + { + "name": "name" + } + ], + "methods": [ + { + "name": "", + "parameterTypes": [] + } + ] + }, + { + "type": "io.seqera.tower.cli.commands.agents.DeleteCmd", + "fields": [ + { + "name": "ref" + }, + { + "name": "workspace" + } + ], + "methods": [ + { + "name": "", + "parameterTypes": [] + } + ] + }, + { + "type": "io.seqera.tower.cli.commands.agents.DisableCmd", + "fields": [ + { + "name": "ref" + }, + { + "name": "workspace" + } + ], + "methods": [ + { + "name": "", + "parameterTypes": [] + } + ] + }, + { + "type": "io.seqera.tower.cli.commands.agents.EnableCmd", + "fields": [ + { + "name": "ref" + }, + { + "name": "workspace" + } + ], + "methods": [ + { + "name": "", + "parameterTypes": [] + } + ] + }, + { + "type": "io.seqera.tower.cli.commands.agents.LaunchCmd", + "fields": [ + { + "name": "agentRef" + }, + { + "name": "instructions" + }, + { + "name": "workspace" + } + ], + "methods": [ + { + "name": "", + "parameterTypes": [] + } + ] + }, + { + "type": "io.seqera.tower.cli.commands.agents.ListCmd", + "fields": [ + { + "name": "filter" + }, + { + "name": "paginationOptions" + }, + { + "name": "workspace" + } + ], + "methods": [ + { + "name": "", + "parameterTypes": [] + } + ] + }, + { + "type": "io.seqera.tower.cli.commands.agents.UpdateCmd", + "fields": [ + { + "name": "description" + }, + { + "name": "githubAppCredentialsId" + }, + { + "name": "instructions" + }, + { + "name": "newName" + }, + { + "name": "ref" + }, + { + "name": "serviceAccount" + }, + { + "name": "workspace" + } + ], + "methods": [ + { + "name": "", + "parameterTypes": [] + } + ] + }, + { + "type": "io.seqera.tower.cli.commands.agents.ViewCmd", + "fields": [ + { + "name": "ref" + }, + { + "name": "workspace" + } + ], + "methods": [ + { + "name": "", + "parameterTypes": [] + } + ] + }, + { + "type": "io.seqera.tower.cli.commands.agents.runs.ListCmd", + "fields": [ + { + "name": "filter" + }, + { + "name": "paginationOptions" + }, + { + "name": "workspace" + } + ], + "methods": [ + { + "name": "", + "parameterTypes": [] + } + ] + }, + { + "type": "io.seqera.tower.cli.commands.agents.runs.RunsCmd", + "methods": [ + { + "name": "", + "parameterTypes": [] + } + ] + }, + { + "type": "io.seqera.tower.cli.commands.agents.runs.ViewCmd", + "fields": [ + { + "name": "id" + }, + { + "name": "workspace" + } + ], + "methods": [ + { + "name": "", + "parameterTypes": [] + } + ] + }, { "type": "io.seqera.tower.cli.commands.collaborators.AbstractCollaboratorsCmd", "allDeclaredFields": true, @@ -3926,6 +4227,151 @@ } ] }, + { + "type": "io.seqera.tower.cli.commands.serviceaccounts.AbstractServiceAccountsCmd" + }, + { + "type": "io.seqera.tower.cli.commands.serviceaccounts.AddCmd", + "fields": [ + { + "name": "description" + }, + { + "name": "name" + }, + { + "name": "organizationRef" + } + ], + "methods": [ + { + "name": "", + "parameterTypes": [] + } + ] + }, + { + "type": "io.seqera.tower.cli.commands.serviceaccounts.DeleteCmd", + "fields": [ + { + "name": "organizationRef" + }, + { + "name": "ref" + } + ], + "methods": [ + { + "name": "", + "parameterTypes": [] + } + ] + }, + { + "type": "io.seqera.tower.cli.commands.serviceaccounts.ListCmd", + "fields": [ + { + "name": "paginationOptions" + }, + { + "name": "scope" + } + ], + "methods": [ + { + "name": "", + "parameterTypes": [] + } + ] + }, + { + "type": "io.seqera.tower.cli.commands.serviceaccounts.ListCmd$Scope", + "fields": [ + { + "name": "organizationRef" + }, + { + "name": "workspaceRef" + } + ], + "methods": [ + { + "name": "", + "parameterTypes": [] + } + ] + }, + { + "type": "io.seqera.tower.cli.commands.serviceaccounts.ServiceAccountRefOptions", + "fields": [ + { + "name": "serviceAccount" + } + ], + "methods": [ + { + "name": "", + "parameterTypes": [] + } + ] + }, + { + "type": "io.seqera.tower.cli.commands.serviceaccounts.ServiceAccountRefOptions$ServiceAccountRef", + "fields": [ + { + "name": "id" + }, + { + "name": "name" + } + ], + "methods": [ + { + "name": "", + "parameterTypes": [] + } + ] + }, + { + "type": "io.seqera.tower.cli.commands.serviceaccounts.UpdateCmd", + "fields": [ + { + "name": "description" + }, + { + "name": "newName" + }, + { + "name": "organizationRef" + }, + { + "name": "ref" + } + ], + "methods": [ + { + "name": "", + "parameterTypes": [] + } + ] + }, + { + "type": "io.seqera.tower.cli.commands.serviceaccounts.ViewCmd", + "fields": [ + { + "name": "organizationRef" + }, + { + "name": "ref" + } + ], + "methods": [ + { + "name": "", + "parameterTypes": [] + } + ] + }, { "type": "io.seqera.tower.cli.commands.sshkeys.AbstractSshKeysCmd" }, @@ -4750,6 +5196,38 @@ "allDeclaredMethods": true, "allDeclaredConstructors": true }, + { + "type": "io.seqera.tower.cli.responses.agents.AgentAdded", + "allDeclaredFields": true + }, + { + "type": "io.seqera.tower.cli.responses.agents.AgentDeleted", + "allDeclaredFields": true + }, + { + "type": "io.seqera.tower.cli.responses.agents.AgentLaunched", + "allDeclaredFields": true + }, + { + "type": "io.seqera.tower.cli.responses.agents.AgentRunView", + "allDeclaredFields": true + }, + { + "type": "io.seqera.tower.cli.responses.agents.AgentRunsList", + "allDeclaredFields": true + }, + { + "type": "io.seqera.tower.cli.responses.agents.AgentUpdated", + "allDeclaredFields": true + }, + { + "type": "io.seqera.tower.cli.responses.agents.AgentView", + "allDeclaredFields": true + }, + { + "type": "io.seqera.tower.cli.responses.agents.AgentsList", + "allDeclaredFields": true + }, { "type": "io.seqera.tower.cli.responses.collaborators.CollaboratorsList", "allDeclaredFields": true, @@ -5214,6 +5692,26 @@ "type": "io.seqera.tower.cli.responses.secrets.SecretsList", "allDeclaredFields": true }, + { + "type": "io.seqera.tower.cli.responses.serviceaccounts.ServiceAccountAdded", + "allDeclaredFields": true + }, + { + "type": "io.seqera.tower.cli.responses.serviceaccounts.ServiceAccountDeleted", + "allDeclaredFields": true + }, + { + "type": "io.seqera.tower.cli.responses.serviceaccounts.ServiceAccountUpdated", + "allDeclaredFields": true + }, + { + "type": "io.seqera.tower.cli.responses.serviceaccounts.ServiceAccountView", + "allDeclaredFields": true + }, + { + "type": "io.seqera.tower.cli.responses.serviceaccounts.ServiceAccountsList", + "allDeclaredFields": true + }, { "type": "io.seqera.tower.cli.responses.sshkeys.SshKeyAdded", "allDeclaredFields": true @@ -54179,6 +54677,14 @@ { "type": "java.math.BigDecimal[]" }, + { + "type": "java.net.HttpURLConnection", + "fields": [ + { + "name": "method" + } + ] + }, { "type": "java.net.UnknownHostException", "methods": [ @@ -55026,6 +55532,14 @@ } ] }, + { + "type": "sun.net.www.protocol.https.HttpsURLConnectionImpl", + "fields": [ + { + "name": "delegate" + } + ] + }, { "type": "sun.security.pkcs.SignerInfo[]" }, diff --git a/src/main/java/io/seqera/tower/cli/Tower.java b/src/main/java/io/seqera/tower/cli/Tower.java index b90593d97..02ec10291 100644 --- a/src/main/java/io/seqera/tower/cli/Tower.java +++ b/src/main/java/io/seqera/tower/cli/Tower.java @@ -23,6 +23,7 @@ import io.seqera.tower.cli.commands.AbstractCmd; import io.seqera.tower.cli.commands.AccessTokensCmd; import io.seqera.tower.cli.commands.ActionsCmd; +import io.seqera.tower.cli.commands.AgentsCmd; import io.seqera.tower.cli.commands.CollaboratorsCmd; import io.seqera.tower.cli.commands.ComputeEnvsCmd; import io.seqera.tower.cli.commands.CredentialsCmd; @@ -41,6 +42,7 @@ import io.seqera.tower.cli.commands.RolesCmd; 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.SshKeysCmd; import io.seqera.tower.cli.commands.TeamsCmd; import io.seqera.tower.cli.commands.WorkspacesCmd; @@ -63,6 +65,7 @@ subcommands = { AccessTokensCmd.class, ActionsCmd.class, + AgentsCmd.class, CollaboratorsCmd.class, ComputeEnvsCmd.class, CredentialsCmd.class, @@ -86,6 +89,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 7bbf072a5..1f35bb6e2 100644 --- a/src/main/java/io/seqera/tower/cli/commands/AbstractApiCmd.java +++ b/src/main/java/io/seqera/tower/cli/commands/AbstractApiCmd.java @@ -21,6 +21,7 @@ import io.seqera.tower.ApiResponse; import io.seqera.tower.Pair; import io.seqera.tower.api.ActionsApi; +import io.seqera.tower.api.AgentsApi; import io.seqera.tower.api.AvatarsApi; import io.seqera.tower.api.CompatibilityApi; import io.seqera.tower.api.ComputeEnvsApi; @@ -40,6 +41,7 @@ import io.seqera.tower.api.PipelinesApi; import io.seqera.tower.api.PlatformsApi; import io.seqera.tower.api.RolesApi; +import io.seqera.tower.api.ServiceAccountsApi; import io.seqera.tower.api.ServiceInfoApi; import io.seqera.tower.api.SshKeysApi; import io.seqera.tower.api.StudiosApi; @@ -50,6 +52,7 @@ import io.seqera.tower.api.WorkflowsApi; import io.seqera.tower.api.WorkspacesApi; import io.seqera.tower.cli.Tower; +import io.seqera.tower.cli.commands.global.PaginationOptions; import io.seqera.tower.cli.commands.labels.Label; import io.seqera.tower.cli.commands.labels.LabelsFinder; import io.seqera.tower.cli.commands.pipelines.versions.VersionRefOptions; @@ -71,13 +74,16 @@ import io.seqera.tower.model.DataStudioQueryAttribute; import io.seqera.tower.model.ListComputeEnvsResponseEntry; import io.seqera.tower.model.ListPipelineVersionsResponse; +import io.seqera.tower.model.ListServiceAccountsResponse; import io.seqera.tower.model.ListWorkspacesAndOrgResponse; import io.seqera.tower.model.OrgAndWorkspaceDto; import io.seqera.tower.model.PipelineDbDto; import io.seqera.tower.model.PipelineQueryAttribute; import io.seqera.tower.model.PipelineVersionFullInfoDto; +import io.seqera.tower.model.ServiceAccountDto; import io.seqera.tower.model.UserResponseDto; import io.seqera.tower.model.WorkflowQueryAttribute; +import jakarta.annotation.Nullable; import org.glassfish.jersey.CommonProperties; import org.glassfish.jersey.client.ClientConfig; import org.glassfish.jersey.logging.LoggingFeature; @@ -144,6 +150,7 @@ public abstract class AbstractApiCmd extends AbstractCmd { private PipelineVersionsApi pipelineVersionsApi; private PlatformsApi platformsApi; private RolesApi rolesApi; + private ServiceAccountsApi serviceAccountsApi; private ServiceInfoApi serviceInfoApi; private SshKeysApi sshKeysApi; private StudiosApi studiosApi; @@ -215,6 +222,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; } @@ -291,6 +302,10 @@ protected RolesApi rolesApi() throws ApiException { return rolesApi == null ? new RolesApi(apiClient()) : rolesApi; } + protected ServiceAccountsApi serviceAccountsApi() throws ApiException { + return serviceAccountsApi == null ? new ServiceAccountsApi(apiClient()) : serviceAccountsApi; + } + protected ServiceInfoApi serviceInfoApi() throws ApiException { return serviceInfoApi == null ? new ServiceInfoApi(apiClient()) : serviceInfoApi; } @@ -580,6 +595,24 @@ protected OrgAndWorkspaceDto findOrganizationByRef(String organizationRef) throw return orgAndWorkspaceDbDtoList.stream().findFirst().orElseThrow(() -> new OrganizationNotFoundException(organizationRef)); } + @Nullable + protected ServiceAccountDto findServiceAccountByName(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()) { + return null; + } + } + } + protected List findOrCreateLabels(Long wspId, List