Skip to content
Merged
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
10 changes: 5 additions & 5 deletions service/tools/maven-plugin/README.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,11 @@ The plugin uses a goal prefix of `timefold` (see the plugin configuration in the
- Fails the build when no `ai.timefold.solver.enterprise` artifact is on the resolved classpath, i.e. the model was built with the Community Edition, which Timefold Platform does not accept. Both checks only run when `timefold:deploy` is among the requested goals, run even when `timefold.model.configuration.skip` is set.
- Fetches platform identity/config by calling GET /api/platform/v1/aboutme?includeConfig=true
- Requires a platform personal access token available via environment variable `TIMEFOLD_PAT` (see "Authentication" below)
- If a single accountId is returned by the platform and `timefold.accountId` wasn't provided, the plugin will use it
- Fails the build when the account id can neither be taken from `timefold.accountId` nor derived from the platform response, i.e. when the personal access token is associated with no account or with several of them. In the latter case `timefold.accountId` has to be set explicitly
- If a single namespace is returned by the platform and `timefold.namespace` wasn't provided, the plugin will use it. The platform reports the namespaces either as `namespaces` or, before the migration to that name is complete, as `accountIds`; both are accepted
- Fails the build when the namespace can neither be taken from `timefold.namespace` nor derived from the platform response, i.e. when the personal access token is associated with no namespace or with several of them. In the latter case `timefold.namespace` has to be set explicitly
- Writes a properties file at `target/generated-resources/timefold-build.properties` with entries such as:
** `quarkus.container-image.registry` — value taken from platform config.containerRegistry
** `quarkus.container-image.group` — the account id used
** `quarkus.container-image.group` — the namespace used
** `quarkus.container-image.push` — set to `true`
** `image.native-suffix` — set to `""` when native support is disabled (default)

Expand Down Expand Up @@ -63,7 +63,7 @@ These are the most important configuration properties for the plugin. They are s

=== Configure goal specific

- `timefold.accountId` (String) — optional account id to use; if not provided and platform returns a single account, the plugin uses it. Set via `<accountId>` or `-Dtimefold.accountId=...`.
- `timefold.namespace` (String) — optional namespace to use; if not provided and platform returns a single namespace, the plugin uses it. Set via `<namespace>` or `-Dtimefold.namespace=...`.
- `timefold.model.configuration.skip` (boolean, default=false) — skip `timefold:configure`. Property name remains `timefold.model.configuration.skip`.
- `timefold.model.nativeSupported` (boolean, default=false) — whether target image should use native suffix; when `false` `image.native-suffix` is set to an empty string allowing JVM builds to be used for native image use cases. Use `-Dtimefold.model.nativeSupported=true` to mark native support.

Expand Down Expand Up @@ -123,7 +123,7 @@ Bind `timefold:configure` to the `initialize` phase via `<executions>` so it run
<!-- tenants are optional for Public models -->
<tenant>UUID_OF_THE_TENANT</tenant>
</tenants>
<!-- optional: accountId, etc. -->
<!-- optional: namespace, etc. -->
</configuration>
</plugin>
</plugins>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
@Mojo(name = "configure", defaultPhase = LifecyclePhase.INITIALIZE, requiresDependencyResolution = ResolutionScope.COMPILE)
public class ConfigureMojo extends AbstractPlatformModelMojo {

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

protected static final String PROP_MODEL_NATIVE_SUPPORTED = "timefold.model.nativeSupported";

Expand All @@ -46,10 +46,10 @@ public class ConfigureMojo extends AbstractPlatformModelMojo {
private MavenProject project;

/**
* Account id that model is associated with
* Namespace that model is associated with
*/
@Parameter(property = PROP_ACCOUNT_ID, required = false)
protected String accountId;
@Parameter(property = PROP_NAMESPACE, required = false)
protected String namespace;

/**
* Determines if the native build of the model is supported and by that should be defined in model descriptor
Expand Down Expand Up @@ -87,13 +87,13 @@ public void execute() throws MojoExecutionException, MojoFailureException {
PlatformIdentityInfo info = fetchPlatformIdentityInfo(true);

if (info == null || !info.hasPushAccessRights()) {
throw new RuntimeException("No access to deploy model on Timefold Platform");
throw new MojoFailureException("No access to deploy model on Timefold Platform");
}
var resolvedAccountId = resolveAccountId(info);
var resolvedNamespace = resolveNamespace(info);

if (!info.hasAccessToAccountId(resolvedAccountId)) {
throw new RuntimeException(
"No access to configured account id " + resolvedAccountId + " or account not configured");
if (!info.hasAccessToNamespace(resolvedNamespace)) {
// Only a namespace configured explicitly can get here; one derived from the token is always allowed.
throw new MojoFailureException(describeMissingNamespaceAccess(info, resolvedNamespace));
}

Path path = Paths.get("target", "generated-resources", "timefold-build.properties");
Expand All @@ -109,7 +109,7 @@ public void execute() throws MojoExecutionException, MojoFailureException {
timefoldBuildProperties.setProperty("quarkus.profile", "container");
timefoldBuildProperties.setProperty("quarkus.container-image.build", "true");
timefoldBuildProperties.setProperty("quarkus.container-image.registry", registry);
timefoldBuildProperties.setProperty("quarkus.container-image.group", resolvedAccountId);
timefoldBuildProperties.setProperty("quarkus.container-image.group", resolvedNamespace);

// configure container image and arguments based on model parent pom settings
timefoldBuildProperties.setProperty("quarkus.jib.jvm-additional-arguments",
Expand Down Expand Up @@ -146,42 +146,55 @@ public void execute() throws MojoExecutionException, MojoFailureException {
}

/**
* Resolves the account id the model is deployed under, which becomes the group of the container image. It is either
* configured explicitly, or, when the personal access token is associated with exactly one account, that account.
* Resolves the namespace the model is deployed under, which becomes the group of the container image. It is either
* configured explicitly, or, when the personal access token is associated with exactly one namespace, that namespace.
*
* @throws MojoFailureException when the account id is neither configured nor unambiguously derivable from the
* @throws MojoFailureException when the namespace is neither configured nor unambiguously derivable from the
* personal access token; without it the container image cannot be named, so the build must not continue.
*/
protected String resolveAccountId(PlatformIdentityInfo info) throws MojoFailureException {
String configuredAccountId = getPropertyOrParameter(PROP_ACCOUNT_ID, this.accountId);
if (configuredAccountId != null && !configuredAccountId.isBlank()) {
return configuredAccountId.trim();
protected String resolveNamespace(PlatformIdentityInfo info) throws MojoFailureException {
String configuredNamespace = getPropertyOrParameter(PROP_NAMESPACE, this.namespace);
if (configuredNamespace != null && !configuredNamespace.isBlank()) {
return configuredNamespace.trim();
}

Set<String> accountIds = info.accountIds() == null ? Set.of() : info.accountIds();
if (accountIds.size() == 1) {
return accountIds.iterator().next();
Set<String> namespaces = info.namespaces();
if (namespaces.size() == 1) {
return namespaces.iterator().next();
}

if (accountIds.isEmpty()) {
if (namespaces.isEmpty()) {
throw new MojoFailureException("""
Unable to resolve the Timefold Platform account id: the personal access token is not associated with \
any account, so the container image of this model cannot be built.
Use a personal access token of an account that is allowed to deploy models.
Unable to resolve the Timefold Platform namespace: the personal access token is not associated with \
any namespace, so the container image of this model cannot be built.
Use a personal access token that is associated with a namespace allowed to deploy models.
See https://docs.timefold.ai/timefold-solver/latest/deploying-to-platform/guide""");
}
throw new MojoFailureException("""
Unable to resolve the Timefold Platform account id: the personal access token is associated with %d \
accounts (%s), so the account to deploy this model to has to be configured explicitly.
Unable to resolve the Timefold Platform namespace: the personal access token is associated with %d \
namespaces (%s), so the namespace to deploy this model to has to be configured explicitly.
Either pass it on the command line:
mvn clean package -D%s=<account id> timefold:deploy
mvn clean package -D%s=<namespace> timefold:deploy
or declare it in the plugin configuration:
<configuration>
<accountId>...</accountId>
<namespace>...</namespace>
</configuration>
See https://docs.timefold.ai/timefold-solver/latest/deploying-to-platform/guide"""
.formatted(accountIds.size(), accountIds.stream().sorted().collect(Collectors.joining(", ")),
PROP_ACCOUNT_ID));
.formatted(namespaces.size(), namespaces.stream().sorted().collect(Collectors.joining(", ")),
PROP_NAMESPACE));
}

/**
* Explains why the configured namespace cannot be deployed to, telling a namespace the token does not grant apart
* from a token that grants no namespace at all, as those need different fixes.
*/
private static String describeMissingNamespaceAccess(PlatformIdentityInfo info, String configuredNamespace) {
if (info.namespaces().isEmpty()) {
return "The personal access token is not associated with any namespace, so this model cannot be deployed "
+ "to the configured namespace " + configuredNamespace;
}
return "The personal access token is not associated with the configured namespace %s, but with %s"
.formatted(configuredNamespace, info.namespaces().stream().sorted().collect(Collectors.joining(", ")));
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ private void report(PlatformIdentityInfo info) {
getLog().info(" User : " + orNone(info.user()));
getLog().info(" Scopes : " + joinStrings(info.scopes()));
getLog().info(" Tenants : " + joinUuids(info.tenants()));
getLog().info(" Namespaces : " + joinStrings(info.accountIds()));
getLog().info(" Namespaces : " + joinStrings(info.namespaces()));

var selectedTenants = getTenants();
if (selectedTenants != null && !selectedTenants.isEmpty()) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,24 +3,34 @@
import java.util.Set;
import java.util.UUID;

import com.fasterxml.jackson.annotation.JsonAlias;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonInclude.Include;

/**
* The identity behind the personal access token, as reported by the platform's {@code aboutme} endpoint. The namespaces
* the token is associated with are being migrated by Timefold Platform from {@code accountIds} to {@code namespaces},
* so both field names are accepted and either one may be missing from the response.
*/
@JsonIgnoreProperties(ignoreUnknown = true)
@JsonInclude(value = Include.NON_NULL)
public record PlatformIdentityInfo(String user, Set<String> scopes, Set<String> accountIds, Set<UUID> tenants,
ConfigurationInfo config) {
public record PlatformIdentityInfo(String user, Set<String> scopes, @JsonAlias("accountIds") Set<String> namespaces,
Set<UUID> tenants, ConfigurationInfo config) {

private static final Set<String> REQUIRED_SCOPES = Set.of("registered-model:create", "registered-model:update");

public boolean hasPushAccessRights() {
return scopes().stream().anyMatch(scope -> REQUIRED_SCOPES.contains(scope));
public PlatformIdentityInfo {
// The platform may leave these out of the response, so normalize them and keep the rest of the plugin null free.
scopes = scopes == null ? Set.of() : scopes;
namespaces = namespaces == null ? Set.of() : namespaces;
}

public boolean hasAccessToAccountId(String account) {
if (accountIds == null || accountIds.isEmpty()) {
return false;
}
public boolean hasPushAccessRights() {
return scopes().stream().anyMatch(REQUIRED_SCOPES::contains);
}

return accountIds().contains(account);
public boolean hasAccessToNamespace(String namespace) {
return namespaces().contains(namespace);
}
}
Loading
Loading