Skip to content

Commit 3bd98d3

Browse files
feat(credentials): surface External ID and trust policy after add [COMP-1405] (#634)
* feat(credentials): surface External ID and trust policy after add After `tw credentials add` succeeds, follow up with a GET /credentials/{id} to enrich the success message with: - the generated External ID (AWS role-mode credentials) - the server-rendered provider-side setup snippet (e.g. AWS IAM trust policy when the installation is configured with a jump role) This brings CLI output to parity with the web UI modal, so users no longer need a follow-up `tw credentials view` (which doesn't exist) or a trip to the docs to assemble the trust policy by hand. The follow-up describe is best-effort: if it fails the credential is still created and the CLI returns the same minimal output as before. Bump tower-java-sdk to 1.167.0 (and VERSION-API + service-info fixture to match) to consume the new `setupSnippet` field on DescribeCredentialsResponse. * updated reflect-config.json * fix(credentials): use platform line separator in trust policy indent The indent() helper hardcoded '\n' while the surrounding format string used %n. On Windows %n resolves to \r\n, producing a byte-level mismatch between expected (built in JVM with %n=\r\n) and actual (native binary stdout) even though the rendered output looked identical. Use String.format("%n") inside indent() to match the rest of the message. * refactor(credentials): warn on describe failure and skip describe when not needed The follow-up describeCredentials() call after a credential add was running for every provider and silently swallowing any failure. Now: - Only call describe when useExternalId is true (AWS role mode or --generate-external-id) — the only flows where the response actually carries an External ID or trust policy. Avoids an unnecessary GET on every non-AWS / non-role credential add. - On describe failure, print a yellow Warning to stderr stating that credential details could not be fetched and that the credential was created. Silent swallow gave the user no signal that enrichment failed. Updates the existing AWS role/generate-external-id tests to mock the describe call so their stderr stays empty. * removed Boolean.TRUE.equals * refactor(credentials): make useExternalId return primitive boolean CredentialsProvider.useExternalId() returned a nullable Boolean — null for most providers, true/false for AWS. Auto-unboxing at the call site triggered NPE on non-AWS adds. Switch to primitive boolean (default false) so callers can use the value directly without null guards. AwsProvider previously returned null to mean "feature off"; that now maps to false, which the createCredentials call passes through to Platform as useExternalId=false (semantically equivalent to omitting the param for the toggle). * refactor(credentials): clean up after add-credential review - Drop redundant field-level javadoc on CredentialsAdded.externalId and setupSnippet; field names are self-explanatory. - Cache getProvider() and useExternalId() once at the top of AbstractAddCmd.exec() instead of recomputing on every reference. - Use Java 21 pattern matching for the AwsSecurityKeys instanceof branch.
1 parent c9464ab commit 3bd98d3

10 files changed

Lines changed: 520 additions & 39 deletions

File tree

VERSION-API

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
1.148.0
1+
1.167.0
22
// Only first line of this file is read
33
// This version should be bumped to the minimum version where dependent API changes were introduced
44
// But never higher then the current Platform API Version deployed in Cloud Production: https://cloud.seqera.io/api/service-info

conf/reflect-config.json

Lines changed: 416 additions & 21 deletions
Large diffs are not rendered by default.

gradle/libs.versions.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ mockserverVersion = "5.15.0"
1515
picocliVersion = "4.6.3"
1616
shadowVersion = "9.4.1"
1717
slf4jVersion = "2.0.17"
18-
towerJavaSdkVersion = "1.150.0"
18+
towerJavaSdkVersion = "1.167.0"
1919
xzVersion = "1.10"
2020

2121
[libraries]

src/main/java/io/seqera/tower/cli/commands/credentials/add/AbstractAddCmd.java

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,11 @@
2323
import io.seqera.tower.cli.exceptions.CredentialsNotFoundException;
2424
import io.seqera.tower.cli.responses.CredentialsAdded;
2525
import io.seqera.tower.cli.responses.Response;
26+
import io.seqera.tower.model.AwsSecurityKeys;
2627
import io.seqera.tower.model.CreateCredentialsRequest;
2728
import io.seqera.tower.model.CreateCredentialsResponse;
2829
import io.seqera.tower.model.Credentials;
30+
import io.seqera.tower.model.DescribeCredentialsResponse;
2931
import io.seqera.tower.model.SecurityKeys;
3032
import picocli.CommandLine;
3133
import picocli.CommandLine.Command;
@@ -48,19 +50,40 @@ public abstract class AbstractAddCmd<T extends SecurityKeys> extends AbstractCre
4850
@Override
4951
protected Response exec() throws ApiException, IOException {
5052
Long wspId = workspaceId(workspace.workspace);
53+
CredentialsProvider provider = getProvider();
54+
boolean useExternalId = provider.useExternalId();
5155

5256
Credentials specs = new Credentials();
5357
specs
54-
.keys(getProvider().securityKeys())
58+
.keys(provider.securityKeys())
5559
.name(name)
56-
.baseUrl(getProvider().baseUrl())
57-
.provider(getProvider().type());
60+
.baseUrl(provider.baseUrl())
61+
.provider(provider.type());
5862

5963
if (overwrite) tryDeleteCredentials(name, wspId);
6064

61-
CreateCredentialsResponse resp = credentialsApi().createCredentials(new CreateCredentialsRequest().credentials(specs), wspId, getProvider().useExternalId());
65+
CreateCredentialsResponse resp = credentialsApi().createCredentials(new CreateCredentialsRequest().credentials(specs), wspId, useExternalId);
6266

63-
return new CredentialsAdded(getProvider().type().name(), resp.getCredentialsId(), name, workspaceRef(wspId));
67+
String externalId = null;
68+
String setupSnippet = null;
69+
if (useExternalId) {
70+
try {
71+
DescribeCredentialsResponse describe = credentialsApi().describeCredentials(resp.getCredentialsId(), wspId);
72+
if (describe != null) {
73+
if (describe.getCredentials() != null && describe.getCredentials().getKeys() instanceof AwsSecurityKeys aws) {
74+
externalId = aws.getExternalId();
75+
}
76+
setupSnippet = describe.getSetupSnippet();
77+
}
78+
} catch (ApiException e) {
79+
getSpec().commandLine().getErr().println(ansi(String.format(
80+
"@|fg(yellow) Warning:|@ could not fetch credential details after creation: %s. The credential was created.",
81+
e.getMessage())));
82+
}
83+
}
84+
85+
return new CredentialsAdded(provider.type().name(), resp.getCredentialsId(), name, workspaceRef(wspId),
86+
externalId, setupSnippet);
6487
}
6588

6689
protected abstract CredentialsProvider getProvider();

src/main/java/io/seqera/tower/cli/commands/credentials/providers/AwsProvider.java

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -63,15 +63,12 @@ public AwsSecurityKeys securityKeys() {
6363
}
6464

6565
@Override
66-
public Boolean useExternalId() {
66+
public boolean useExternalId() {
6767
AwsCredentialsMode mode = getMode();
6868
if (mode == AwsCredentialsMode.role) {
6969
return true;
7070
}
71-
if (generateExternalId && assumeRoleArn != null) {
72-
return true;
73-
}
74-
return null;
71+
return generateExternalId && assumeRoleArn != null;
7572
}
7673

7774
private AwsCredentialsMode getMode() {

src/main/java/io/seqera/tower/cli/commands/credentials/providers/CredentialsProvider.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ public interface CredentialsProvider {
2929

3030
SecurityKeys securityKeys() throws IOException, ApiException;
3131

32-
default Boolean useExternalId() {
33-
return null;
32+
default boolean useExternalId() {
33+
return false;
3434
}
3535
}

src/main/java/io/seqera/tower/cli/responses/CredentialsAdded.java

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,16 +22,44 @@ public class CredentialsAdded extends Response {
2222
public final String provider;
2323
public final String name;
2424
public final String workspaceRef;
25+
public final String externalId;
26+
public final String setupSnippet;
2527

2628
public CredentialsAdded(String provider, String id, String name, String workspaceRef) {
29+
this(provider, id, name, workspaceRef, null, null);
30+
}
31+
32+
public CredentialsAdded(String provider, String id, String name, String workspaceRef,
33+
String externalId, String setupSnippet) {
2734
this.provider = provider;
2835
this.id = id;
2936
this.name = name;
3037
this.workspaceRef = workspaceRef;
38+
this.externalId = externalId;
39+
this.setupSnippet = setupSnippet;
3140
}
3241

3342
@Override
3443
public String toString() {
35-
return ansi(String.format("%n @|yellow New %S credentials '%s (%s)' added at %s workspace|@%n", provider, name, id, workspaceRef));
44+
StringBuilder out = new StringBuilder();
45+
out.append(ansi(String.format("%n @|yellow New %S credentials '%s (%s)' added at %s workspace|@%n",
46+
provider, name, id, workspaceRef)));
47+
if (externalId != null && !externalId.isEmpty()) {
48+
out.append(ansi(String.format("%n @|bold External ID:|@ %s%n", externalId)));
49+
}
50+
if (setupSnippet != null && !setupSnippet.isEmpty()) {
51+
out.append(ansi(String.format("%n @|bold Trust policy|@ (paste this into your IAM role's trust relationship):%n%n%s%n",
52+
indent(setupSnippet, " "))));
53+
}
54+
return out.toString();
55+
}
56+
57+
private static String indent(String text, String prefix) {
58+
String nl = String.format("%n");
59+
StringBuilder sb = new StringBuilder();
60+
for (String line : text.split("\\R", -1)) {
61+
sb.append(prefix).append(line).append(nl);
62+
}
63+
return sb.toString();
3664
}
3765
}

src/test/java/io/seqera/tower/cli/InfoCmdTest.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ void testInfo(OutputType format, MockServerClient mock) throws IOException {
5656
Map<String, String> opts = new HashMap<>();
5757
opts.put("cliVersion", getCliVersion() );
5858
opts.put("cliApiVersion", getCliApiVersion());
59-
opts.put("towerApiVersion", "1.148.0");
59+
opts.put("towerApiVersion", "1.167.0");
6060
opts.put("towerVersion", "22.3.0-torricelli");
6161
opts.put("towerApiEndpoint", "http://localhost:"+mock.getPort());
6262
opts.put("userName", "jordi");
@@ -86,7 +86,7 @@ void testInfoStatusTokenFail(MockServerClient mock) throws IOException {
8686
Map<String, String> opts = new HashMap<>();
8787
opts.put("cliVersion", getCliVersion() );
8888
opts.put("cliApiVersion", getCliApiVersion());
89-
opts.put("towerApiVersion", "1.148.0");
89+
opts.put("towerApiVersion", "1.167.0");
9090
opts.put("towerVersion", "22.3.0-torricelli");
9191
opts.put("towerApiEndpoint", "http://localhost:"+mock.getPort());
9292
opts.put("userName", null);

src/test/java/io/seqera/tower/cli/credentials/providers/AwsProviderTest.java

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,12 @@ void testAddWithRoleMode(OutputType format, MockServerClient mock) {
114114
response().withStatusCode(200).withBody("{\"credentialsId\":\"3cz5A8cuBkB5iJliCwJCFU\"}").withContentType(MediaType.APPLICATION_JSON)
115115
);
116116

117+
mock.when(
118+
request().withMethod("GET").withPath("/credentials/3cz5A8cuBkB5iJliCwJCFU"), exactly(1)
119+
).respond(
120+
response().withStatusCode(200).withBody("{\"credentials\":{\"id\":\"3cz5A8cuBkB5iJliCwJCFU\",\"name\":\"aws-role\",\"provider\":\"aws\",\"keys\":{\"discriminator\":\"aws\",\"mode\":\"role\",\"assumeRoleArn\":\"arn:aws:iam::123456789012:role/MyRole\"}}}").withContentType(MediaType.APPLICATION_JSON)
121+
);
122+
117123
ExecOut out = exec(format, mock, "credentials", "add", "aws", "-n", "aws-role", "--mode=role", "-r", "arn:aws:iam::123456789012:role/MyRole");
118124
assertOutput(format, out, new CredentialsAdded("AWS", "3cz5A8cuBkB5iJliCwJCFU", "aws-role", USER_WORKSPACE_NAME));
119125
}
@@ -133,10 +139,42 @@ void testAddKeysModeWithGenerateExternalId(OutputType format, MockServerClient m
133139
response().withStatusCode(200).withBody("{\"credentialsId\":\"4cz5A8cuBkB5iJliCwJCFU\"}").withContentType(MediaType.APPLICATION_JSON)
134140
);
135141

142+
mock.when(
143+
request().withMethod("GET").withPath("/credentials/4cz5A8cuBkB5iJliCwJCFU"), exactly(1)
144+
).respond(
145+
response().withStatusCode(200).withBody("{\"credentials\":{\"id\":\"4cz5A8cuBkB5iJliCwJCFU\",\"name\":\"aws-ext\",\"provider\":\"aws\",\"keys\":{\"discriminator\":\"aws\",\"accessKey\":\"access_key\",\"assumeRoleArn\":\"arn_role\"}}}").withContentType(MediaType.APPLICATION_JSON)
146+
);
147+
136148
ExecOut out = exec(format, mock, "credentials", "add", "aws", "-n", "aws-ext", "-a", "access_key", "-s", "secret_key", "-r", "arn_role", "--generate-external-id");
137149
assertOutput(format, out, new CredentialsAdded("AWS", "4cz5A8cuBkB5iJliCwJCFU", "aws-ext", USER_WORKSPACE_NAME));
138150
}
139151

152+
@ParameterizedTest
153+
@EnumSource(OutputType.class)
154+
void testAddRoleModeSurfacesExternalIdAndTrustPolicy(OutputType format, MockServerClient mock) {
155+
156+
mock.when(
157+
request()
158+
.withMethod("POST")
159+
.withPath("/credentials")
160+
.withQueryStringParameter("useExternalId", "true")
161+
.withBody(json("{\"credentials\":{\"keys\":{\"mode\":\"role\",\"assumeRoleArn\":\"arn:aws:iam::222222222222:role/CustomerRole\"},\"name\":\"aws-role-jump\",\"provider\":\"aws\"}}")),
162+
exactly(1)
163+
).respond(
164+
response().withStatusCode(200).withBody("{\"credentialsId\":\"5cz5A8cuBkB5iJliCwJCFU\"}").withContentType(MediaType.APPLICATION_JSON)
165+
);
166+
167+
mock.when(
168+
request().withMethod("GET").withPath("/credentials/5cz5A8cuBkB5iJliCwJCFU"), exactly(1)
169+
).respond(
170+
response().withStatusCode(200).withBody("{\"credentials\":{\"id\":\"5cz5A8cuBkB5iJliCwJCFU\",\"name\":\"aws-role-jump\",\"provider\":\"aws\",\"keys\":{\"discriminator\":\"aws\",\"mode\":\"role\",\"assumeRoleArn\":\"arn:aws:iam::222222222222:role/CustomerRole\",\"externalId\":\"a1b2c3d4-e5f6\"}},\"setupSnippet\":\"{ \\\"Version\\\": \\\"2012-10-17\\\" }\"}").withContentType(MediaType.APPLICATION_JSON)
171+
);
172+
173+
ExecOut out = exec(format, mock, "credentials", "add", "aws", "-n", "aws-role-jump", "--mode=role", "-r", "arn:aws:iam::222222222222:role/CustomerRole");
174+
assertOutput(format, out, new CredentialsAdded("AWS", "5cz5A8cuBkB5iJliCwJCFU", "aws-role-jump", USER_WORKSPACE_NAME,
175+
"a1b2c3d4-e5f6", "{ \"Version\": \"2012-10-17\" }"));
176+
}
177+
140178
@Test
141179
void testAddRoleModeRejectsAccessKeys(MockServerClient mock) {
142180

src/test/resources/runcmd/info/service-info.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"serviceInfo": {
33
"version": "22.3.0-torricelli",
4-
"apiVersion": "1.148.0",
4+
"apiVersion": "1.167.0",
55
"commitId": "3f04bfd4",
66
"authTypes": [
77
"github",

0 commit comments

Comments
 (0)