Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions service/tools/maven-plugin/README.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ The plugin uses a goal prefix of `timefold` (see the plugin configuration in the
- `timefold:configure` — gather platform identity/config and write build properties (default phase: initialize)
- `timefold:deploy` — upload/register a model descriptor ZIP to the platform (default phase: pre-integration-test)
- `timefold:undeploy` — remove a registered model from the platform (default phase: post-integration-test)
- `timefold:permissions` — print the permissions/scopes the configured token has on the platform (no default phase; invoke explicitly)

== Features & behavior

Expand All @@ -27,6 +28,13 @@ The plugin uses a goal prefix of `timefold` (see the plugin configuration in the
** `quarkus.container-image.push` — set to `true`
** `image.native-suffix` — set to `""` when native support is disabled (default)

=== Permissions goal (timefold:permissions)

- Read-only diagnostic that never changes anything on the platform; useful to verify whether the configured token has the right permissions for the selected tenant before (or after a failed) deploy
- Uses the same configuration as the other goals (`timefold.platformUrl`, `timefold.model.tenants`) and the same `TIMEFOLD_PAT` token
- Fetches the token identity by calling GET /api/platform/v1/aboutme and prints a human-readable summary: user, granted scopes (permissions), tenants, accounts, the selected tenant, and whether the token is allowed to register/update models
- Not bound to any lifecycle phase; invoke it explicitly, e.g. `mvn timefold:permissions`

=== Deploy goal (timefold:deploy)

- Uploads a model descriptor ZIP located at `${project.build.directory}/model-descriptor.zip` (default build directory) to the platform POST /api/platform/v1/models
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,14 @@

import java.io.IOException;
import java.io.InputStream;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpClient.Redirect;
import java.net.http.HttpClient.Version;
import java.net.http.HttpRequest;
import java.net.http.HttpRequest.Builder;
import java.net.http.HttpResponse;
import java.net.http.HttpResponse.BodyHandlers;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
Expand All @@ -17,6 +21,8 @@
import java.util.zip.ZipEntry;
import java.util.zip.ZipFile;

import ai.timefold.solver.tools.maven.client.PlatformIdentityInfo;

import org.apache.maven.execution.MavenSession;
import org.apache.maven.plugin.AbstractMojo;
import org.apache.maven.plugins.annotations.Parameter;
Expand Down Expand Up @@ -80,6 +86,47 @@
protected HttpClient httpClient = HttpClient.newBuilder().version(Version.HTTP_2).followRedirects(Redirect.NORMAL)
.connectTimeout(Duration.ofSeconds(10)).build();

protected AccessTokenProvider getAccessTokenProvider() {
return accessTokenProvider;
}

protected void setAccessTokenProvider(AccessTokenProvider accessTokenProvider) {
this.accessTokenProvider = accessTokenProvider;
}

protected PlatformIdentityInfo fetchPlatformIdentityInfo(boolean includeConfig) {
String platformPAT = accessTokenProvider.getAccessToken();

if (platformPAT == null) {
throw new RuntimeException(

Check warning on line 101 in service/tools/maven-plugin/src/main/java/ai/timefold/solver/tools/maven/AbstractPlatformModelMojo.java

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Replace generic exceptions with specific library exceptions or a custom exception.

See more on https://sonarcloud.io/project/issues?id=ai.timefold%3Atimefold-solver&issues=AZ_2mtuIuvvkFsMmmPKb&open=AZ_2mtuIuvvkFsMmmPKb&pullRequest=2588
"Personal Access Token for Timefold Platform is required. Set this via TIMEFOLD_PAT environment variable");
}

Builder requestBuilder = HttpRequest.newBuilder().GET();
requestBuilder.header("Accept", "application/json");
requestBuilder.header("Authorization", "Bearer " + platformPAT);
requestBuilder.uri(URI.create(getPlatformUrl() + "/api/platform/v1/aboutme?includeConfig=" + includeConfig));

HttpRequest httpRequest = requestBuilder.build();
try {
HttpResponse<String> authResponse = httpClient.send(httpRequest, BodyHandlers.ofString());
if (authResponse.statusCode() == 200) {
return mapper.readValue(authResponse.body(), PlatformIdentityInfo.class);
} else {
getLog().debug(authResponse.body());
throw new IllegalStateException(
"Platform authentication failed with " + authResponse.statusCode() + " status code");
}
} catch (IllegalStateException e) {
throw e;
} catch (Exception e) {
if (e instanceof InterruptedException) {

Check warning on line 123 in service/tools/maven-plugin/src/main/java/ai/timefold/solver/tools/maven/AbstractPlatformModelMojo.java

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Replace the usage of the "instanceof" operator by a catch block.

See more on https://sonarcloud.io/project/issues?id=ai.timefold%3Atimefold-solver&issues=AZ_2mtuIuvvkFsMmmPKc&open=AZ_2mtuIuvvkFsMmmPKc&pullRequest=2588
Thread.currentThread().interrupt();
}
throw new RuntimeException("Unexpected error while making platform info call", e);
}
}

protected void configureHttpRequest(Builder builder) {
builder.timeout(Duration.ofSeconds(30));
builder.header("Authorization", "Bearer " + accessTokenProvider.getAccessToken());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,6 @@
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpClient.Redirect;
import java.net.http.HttpRequest;
import java.net.http.HttpRequest.Builder;
import java.net.http.HttpResponse;
import java.net.http.HttpResponse.BodyHandlers;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
Expand All @@ -28,15 +21,9 @@
import org.apache.maven.plugins.annotations.ResolutionScope;
import org.apache.maven.project.MavenProject;

import com.fasterxml.jackson.databind.ObjectMapper;

@Mojo(name = "configure", defaultPhase = LifecyclePhase.INITIALIZE, requiresDependencyResolution = ResolutionScope.COMPILE)
public class ConfigureMojo extends AbstractPlatformModelMojo {

private HttpClient httpClient = HttpClient.newBuilder().followRedirects(Redirect.ALWAYS).build();
private ObjectMapper mapper = new ObjectMapper();
private AccessTokenProvider accessTokenProvider = new AccessTokenProvider();

protected static final String PROP_ACCOUNT_ID = "timefold.accountId";

protected static final String PROP_MODEL_NATIVE_SUPPORTED = "timefold.model.nativeSupported";
Expand Down Expand Up @@ -95,7 +82,7 @@ public void execute() throws MojoExecutionException, MojoFailureException {
}
if (deployRequested) {
try {
PlatformIdentityInfo info = fetchPlatformConfiguration();
PlatformIdentityInfo info = fetchPlatformIdentityInfo(true);

if (info == null || !info.hasPushAccessRights()) {
throw new RuntimeException("No access to deploy model on Timefold Platform");
Expand Down Expand Up @@ -139,7 +126,7 @@ public void execute() throws MojoExecutionException, MojoFailureException {

// configure container registry credentials as system properties to not write them to any files
System.setProperty("quarkus.container-image.username", "token");
System.setProperty("quarkus.container-image.password", accessTokenProvider.getAccessToken());
System.setProperty("quarkus.container-image.password", getAccessTokenProvider().getAccessToken());
}
if (!getPropertyOrParameter(PROP_MODEL_NATIVE_SUPPORTED, nativeSupported)) {
// allow to use jvm image for native use cases
Expand Down Expand Up @@ -211,43 +198,6 @@ private boolean hasEnterpriseArtifacts() {
|| groupId.startsWith(ENTERPRISE_GROUP_ID + "."));
}

private PlatformIdentityInfo fetchPlatformConfiguration() {
String platformPAT = accessTokenProvider.getAccessToken();

if (platformPAT == null) {
throw new RuntimeException(
"Personal Access Token for Timefold Platform is required. Set this via TIMEFOLD_PAT environment variable");
}

Builder requestBuilder = HttpRequest.newBuilder().GET();
requestBuilder.header("Accept", "application/json");

requestBuilder.header("Authorization", "Bearer " + platformPAT);
String platformUrl = getPlatformUrl();
requestBuilder.uri(URI.create(platformUrl + "/api/platform/v1/aboutme?includeConfig=true"));

HttpRequest httpRequest = requestBuilder.build();
try {
HttpResponse<String> authResponse = httpClient.send(httpRequest, BodyHandlers.ofString());
if (authResponse.statusCode() == 200) {
String payload = authResponse.body();

return mapper.readValue(payload, PlatformIdentityInfo.class);
} else {
getLog().debug(authResponse.body());
throw new IllegalStateException(
"Platform authentication failed with " + authResponse.statusCode() + " status code");
}
} catch (IllegalStateException e) {
throw e;
} catch (Exception e) {
if (e instanceof InterruptedException) {
Thread.currentThread().interrupt();
}
throw new RuntimeException("Unexpected error while making platform info call", e);
}
}

/*
* Executes only when timefold:deploy goal is requested
*/
Expand All @@ -256,10 +206,6 @@ protected boolean shouldExecute() {
return goals.contains("timefold:deploy");
}

protected void setAccessTokenProvider(AccessTokenProvider provider) {
this.accessTokenProvider = provider;
}

protected MavenProject getProject() {
return project;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
package ai.timefold.solver.tools.maven;

import java.util.List;
import java.util.Set;
import java.util.TreeSet;
import java.util.UUID;

import ai.timefold.solver.tools.maven.client.PlatformIdentityInfo;

import org.apache.maven.plugin.MojoExecutionException;
import org.apache.maven.plugins.annotations.Mojo;

/**
* Goal that reports the permissions/scopes the configured Personal Access Token has on Timefold Platform.
*/
@Mojo(name = "permissions")
public class PermissionsMojo extends AbstractPlatformModelMojo {

private static final String NONE = "(none)";

@Override
public void execute() throws MojoExecutionException {
PlatformIdentityInfo info = fetchPlatformIdentityInfo(false);
if (info == null) {
throw new MojoExecutionException("Timefold Platform did not return any information for the configured token");
}
report(info);
}

private void report(PlatformIdentityInfo info) {
getLog().info("Timefold Platform - configured token");
getLog().info(" Platform : " + getPlatformUrl());
getLog().info(" User : " + orNone(info.user()));
getLog().info(" Scopes : " + joinStrings(info.scopes()));
getLog().info(" Tenants : " + joinUuids(info.tenants()));
getLog().info(" Accounts : " + joinStrings(info.accountIds()));

List<String> selectedTenants = getTenants();
if (selectedTenants != null && !selectedTenants.isEmpty()) {
getLog().info(" Selected tenant : " + selectedTenants.getFirst());
}

if (info.hasPushAccessRights()) {
getLog().info(" Deploy : OK - token can register/update models (push access granted)");
} else {
getLog().warn(" Deploy : token CANNOT register/update models - missing "
+ "'registered-model:create' or 'registered-model:update' scope");
}
}

private static String orNone(String value) {
return value == null || value.isBlank() ? NONE : value;
}

private static String joinStrings(Set<String> values) {
if (values == null || values.isEmpty()) {
return NONE;
}
return String.join(", ", new TreeSet<>(values));
}

private static String joinUuids(Set<UUID> values) {
if (values == null || values.isEmpty()) {
return NONE;
}
TreeSet<String> sorted = new TreeSet<>();
for (UUID value : values) {
sorted.add(value.toString());
}
return String.join(", ", sorted);
}
}
Loading
Loading