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
51 changes: 48 additions & 3 deletions docs/src/modules/ROOT/pages/deploying-to-platform/guide.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -79,16 +79,61 @@ When selecting its permissions, scope the token to the tenant you want to regist

See https://docs.timefold.ai/timefold-platform/latest/api/platform-api#_authentication_with_personal_access_tokens[Authentication with Personal Access Tokens] for the full walkthrough, including screenshots of the token generation dialog.

=== Export the token
[#_provide_the_token]
=== Provide the token

The plugin reads your PAT from the `TIMEFOLD_PAT` environment variable.
Export it before building:
The plugin takes the PAT from the `TIMEFOLD_PAT` environment variable, and falls back to your Maven settings when that variable is not set.

Export the variable when you deploy from CI, where the token comes from the pipeline's secret store, or for a one-off deploy from your own shell:

[source,bash,options="nowrap"]
----
export TIMEFOLD_PAT=<your-personal-access-token>
----

On your own machine, prefer storing the token in your Maven settings instead, so that you configure it once rather than in every shell.
Maven encrypts server passwords with a master password of your own, so the token is not written to disk in clear text.
Create that master password once, and put the result in `~/.m2/settings-security.xml`:

[source,bash,options="nowrap"]
----
mvn --encrypt-master-password
----

[source,xml,options="nowrap"]
----
<settingsSecurity>
<master>{jSMOWnoPFgsHVpMvz5VrIt5kRbzGpI8u+9EF1iFQyJQ=}</master>
</settingsSecurity>
----

Then encrypt the PAT, and add it as a `timefold-platform` server in `~/.m2/settings.xml`:

[source,bash,options="nowrap"]
----
mvn --encrypt-password
----

[source,xml,options="nowrap"]
----
<settings>
<servers>
<server>
<id>timefold-platform</id>
<password>{COQLCE6DU6GtcS5P=}</password>
</server>
</servers>
</settings>
----

Both commands prompt for the value, so it never reaches your shell history.
If you keep tokens for several platform environments, point the build at another entry with `-Dtimefold.serverId=<id>`.
See https://maven.apache.org/guides/mini/guide-encryption.html[Maven Password Encryption] for the full details.

CAUTION: Maven 3 encrypts the master password itself with a hardcoded key, so `settings-security.xml` protects your token against casual reading rather than against someone who can read both files. Maven 4 lifts that limitation: its `mvnenc` tool can take the master password from a GPG agent, a pinentry prompt, an environment variable or 1Password, and the plugin reads those tokens too.

NOTE: `TIMEFOLD_PAT` wins when both are configured, so unset it if a stale value in your shell shadows the token in your settings.

[#_build_and_deploy]
== Build and deploy

Expand Down
74 changes: 66 additions & 8 deletions service/tools/maven-plugin/README.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ The plugin uses a goal prefix of `timefold` (see the plugin configuration in the
- Fails the build when the project does not inherit from `ai.timefold.solver:timefold-solver-service-parent`, which Timefold Platform requires
- 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)
- Requires a platform personal access token, taken from the `TIMEFOLD_PAT` environment variable or from an encrypted `<server>` entry in your Maven settings (see "Authentication" below)
- 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:
Expand Down Expand Up @@ -60,6 +60,7 @@ These are the most important configuration properties for the plugin. They are s
- `timefold.model.handleSubscription` (boolean, default=false) — whether the platform should auto-subscribe when registering/undeploying. Use `-Dtimefold.model.handleSubscription=true` to enable.
- `project.build.directory` (String) — standard Maven property for build dir; plugin uses `${project.build.directory}/model-descriptor.zip` by default (configurable via Maven project settings).
- `timefold.dryRun` (boolean, default=false) — when true, `configure`, `deploy` and `undeploy` will perform a dry run (no changes or uploads). Used as `-Dtimefold.dryRun=true`.
- `timefold.serverId` (String, default=`timefold-platform`) — id of the `<server>` entry in your Maven settings that holds the personal access token. Set via `<serverId>` or `-Dtimefold.serverId=...`. See "Authentication" below.

=== Configure goal specific

Expand All @@ -77,17 +78,73 @@ These are the most important configuration properties for the plugin. They are s

- `tfp.model.undeploy.skip` (boolean, default=false) — skip undeploy goal. Note: the property used by the plugin for undeploy skip is `tfp.model.undeploy.skip` (not `timefold.model.undeploy.skip`).

=== Authentication (environment)
== Authentication

- `TIMEFOLD_PAT` — personal access token for Timefold Platform. The plugin reads this environment variable and sets the `Authorization: Bearer <token>` header on requests.
The token must have the `registered-model:create` and `registered-model:update` scopes; requests will fail with an authorization error if either scope is missing.
See link:https://docs.timefold.ai/timefold-platform/latest/api/platform-api#_authentication_with_personal_access_tokens[Authentication with Personal Access Tokens] for how to create a token.
The plugin authenticates with a Timefold Platform personal access token, which it sends as the `Authorization: Bearer <token>` header.
The token must have the `registered-model:create` and `registered-model:update` scopes; requests will fail with an authorization error if either scope is missing.
See link:https://docs.timefold.ai/timefold-platform/latest/api/platform-api#_authentication_with_personal_access_tokens[Authentication with Personal Access Tokens] for how to create a token.

The token is resolved in this order:

1. The `TIMEFOLD_PAT` environment variable.
2. The `<password>` of a `<server>` entry in your Maven settings, `timefold-platform` by default.

The environment variable is the natural fit for CI, where the token comes from the pipeline's secret store.
The server entry is the natural fit for a developer machine, where re-exporting the token in every shell is tedious — and where writing it to a file in clear text is not acceptable.

=== Store the token in your Maven settings

Maven encrypts server passwords with a master password of your own, so the token is not kept in clear text.
Create the master password once, and put it in `~/.m2/settings-security.xml`:

[source,bash]
----
mvn --encrypt-master-password
----

[source,xml]
----
<settingsSecurity>
<master>{jSMOWnoPFgsHVpMvz5VrIt5kRbzGpI8u+9EF1iFQyJQ=}</master>
</settingsSecurity>
----

Then encrypt the personal access token, and add it as a server entry in `~/.m2/settings.xml`:

[source,bash]
----
mvn --encrypt-password
----

[source,xml]
----
<settings>
<servers>
<server>
<id>timefold-platform</id>
<password>{COQLCE6DU6GtcS5P=}</password>
</server>
</servers>
</settings>
----

Both commands prompt for the value, so it does not end up in your shell history.
The plugin decrypts the password through Maven's own `SettingsDecrypter`, so a token encrypted with Maven 4's `mvnenc encrypt` works just as well.
An unencrypted `<password>` is accepted too, but the plugin logs a warning telling you to encrypt it.
`<username>` on the server entry is ignored; the plugin only reads the password.

Use a different entry with `-Dtimefold.serverId=<id>` or `<serverId>` in the plugin configuration, for example to keep separate tokens for separate platform environments.

See link:https://maven.apache.org/guides/mini/guide-encryption.html[Maven Password Encryption] for the full details, including how to keep the master password on a removable drive.

CAUTION: Maven 3 encrypts the master password itself with a hardcoded key, so `settings-security.xml` protects the token against casual reading, not against someone who can read both files.
Maven 4 removes that limitation, as `mvnenc` can take the master password from a GPG agent, a pinentry prompt, an environment variable or 1Password.

== Headers & HTTP details

Requests include the following headers:

- `Authorization: Bearer <token>` (from TIMEFOLD_PAT)
- `Authorization: Bearer <token>` (see "Authentication" above)
- `Content-Type: application/octet-stream` for model upload requests
- `Accept: application/json`
- `X-TF-TENANT-ID` — set to the first tenant if `timefold.model.tenants` is provided
Expand Down Expand Up @@ -134,7 +191,7 @@ TIP: To find your tenant ID, log in to the Timefold Platform UI, select *Manage

With this binding in place, `mvn clean package timefold:deploy` runs `configure` during the `initialize` phase (before the container image is built later in `package`). Without it, `configure` never runs unless invoked explicitly, and the container image build won't have the platform's required registry/group configuration.

Or call the plugin directly from CLI with system properties and environment var:
Or call the plugin directly from CLI with system properties, with the token either configured in your Maven settings or exported for this shell:

[source,bash]
----
Expand All @@ -159,4 +216,5 @@ Goals are implemented as Mojos in `src/main/java/ai/timefold/solver/tools/maven`
- `UndeployModelMojo` — handles deletion
- `AbstractPlatformModelMojo` — common behavior, HTTP client, descriptor reading

The plugin relies on the environment variable `TIMEFOLD_PAT` for authentication; tests provide a test helper to mock token retrieval.
Token resolution lives in `AccessTokenProvider`, which reads the environment and, failing that, the Maven settings; `AbstractPlatformModelMojo` builds it from the session and hands it to every goal.
Tests provide a test helper (`TestAccessTokenProvider`) to mock token retrieval.
23 changes: 23 additions & 0 deletions service/tools/maven-plugin/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,17 @@
<artifactId>maven-artifact</artifactId>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.apache.maven</groupId>
<artifactId>maven-settings</artifactId>
<scope>provided</scope>
</dependency>
<dependency>
<!-- Decrypts the personal access token stored in the Maven settings. -->
<groupId>org.apache.maven</groupId>
<artifactId>maven-settings-builder</artifactId>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.apache.maven.plugin-tools</groupId>
<artifactId>maven-plugin-annotations</artifactId>
Expand All @@ -56,6 +67,18 @@
<scope>test</scope>
</dependency>

<!-- Used by AccessTokenProviderTest to encrypt a token the way Maven's password encryption does. -->
<dependency>
<groupId>org.codehaus.plexus</groupId>
<artifactId>plexus-cipher</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.codehaus.plexus</groupId>
<artifactId>plexus-sec-dispatcher</artifactId>
<scope>test</scope>
</dependency>

<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-api</artifactId>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,15 +21,16 @@

import org.apache.maven.execution.MavenSession;
import org.apache.maven.plugin.AbstractMojo;
import org.apache.maven.plugin.MojoExecutionException;
import org.apache.maven.plugins.annotations.Component;
import org.apache.maven.plugins.annotations.Parameter;
import org.apache.maven.settings.crypto.SettingsDecrypter;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;

public abstract class AbstractPlatformModelMojo extends AbstractMojo {

private AccessTokenProvider accessTokenProvider = new AccessTokenProvider();

private static final String DESCRIPTOR_FILE_NAME = "timefold-model-descriptor.json";

public static final String PROP_DRY_RUN = "timefold.dryRun";
Expand All @@ -42,9 +43,26 @@ public abstract class AbstractPlatformModelMojo extends AbstractMojo {

protected static final String PROP_MODEL_SUBS = "timefold.model.handleSubscription";

protected static final String PROP_SERVER_ID = "timefold.serverId";

@Parameter(defaultValue = "${session}", readonly = true)
protected MavenSession session;

@Component
private SettingsDecrypter settingsDecrypter;

/**
* Id of the {@code <server>} entry in the Maven settings that holds the personal access token, as an alternative
* to exporting it as {@code TIMEFOLD_PAT}
*/
@Parameter(property = PROP_SERVER_ID, required = false, defaultValue = AccessTokenProvider.DEFAULT_SERVER_ID)
protected String serverId;
Comment thread
cristianonicolai marked this conversation as resolved.

/**
* Built lazily, as it needs the settings of the session the mojo runs in; tests replace it with a double.
*/
private AccessTokenProvider accessTokenProvider;

/**
* URL to the platform that model should be deployed to
*/
Expand Down Expand Up @@ -82,20 +100,19 @@ public abstract class AbstractPlatformModelMojo extends AbstractMojo {
.connectTimeout(Duration.ofSeconds(10)).build();

protected AccessTokenProvider getAccessTokenProvider() {
if (accessTokenProvider == null) {
accessTokenProvider = new AccessTokenProvider(session == null ? null : session.getSettings(),
settingsDecrypter, getConfiguredServerId(), getLog());
}
return accessTokenProvider;
}

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

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

if (platformPAT == null) {
throw new IllegalArgumentException(
"Personal Access Token for Timefold Platform is required. Set this via TIMEFOLD_PAT environment variable");
}
protected PlatformIdentityInfo fetchPlatformIdentityInfo(boolean includeConfig) throws MojoExecutionException {
var platformPAT = requireAccessToken();

var requestBuilder = HttpRequest.newBuilder().GET();
requestBuilder.header("Accept", "application/json");
Expand All @@ -109,22 +126,63 @@ protected PlatformIdentityInfo fetchPlatformIdentityInfo(boolean includeConfig)
return mapper.readValue(authResponse.body(), PlatformIdentityInfo.class);
} else {
getLog().debug(authResponse.body());
throw new IllegalStateException(
"Platform authentication failed with " + authResponse.statusCode() + " status code");
throw new MojoExecutionException("Platform authentication failed with " + authResponse.statusCode()
+ " status code: " + readErrorMessage(authResponse.body()));
}
} catch (IllegalStateException e) {
} catch (MojoExecutionException e) {
throw e;
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new RuntimeException("Unexpected error while making platform info call", e);
} catch (Exception e) {
throw new RuntimeException("Unexpected error while making platform info call", e);
throw new MojoExecutionException("Interrupted while making platform info call", e);
} catch (IOException e) {
throw new MojoExecutionException("Unexpected error while making platform info call", e);
}
}

protected void configureHttpRequest(Builder builder) {
/**
* Resolves the personal access token, failing the build when none is configured. Without this the request goes out
* with an empty bearer token and the platform answers with an authentication error, which points at the token
* being wrong rather than at it never having been configured.
* <p>
* Deliberately called while a request is built rather than up front, so that the goals which send nothing on a
* dry run still run without a token: {@code deploy} and {@code undeploy} only reach here once they have decided
* to actually call the platform. {@code configure} reads the platform configuration even on a dry run, as it has
* to write the registry and account id it would build with, so that goal needs a token either way.
*
* @throws MojoExecutionException when no token is configured, or when the configured one cannot be read
*/
protected String requireAccessToken() throws MojoExecutionException {
String accessToken = getAccessTokenProvider().getAccessToken();
if (accessToken == null || accessToken.isBlank()) {
throw new MojoExecutionException("""
Personal Access Token for Timefold Platform is required.
Either export it for this build:
export %s=<your token>
or store it, encrypted, in your Maven settings (~/.m2/settings.xml):
<server>
<id>%s</id>
<password>{encrypted token}</password>
</server>
Encrypt the token with 'mvn --encrypt-password', after creating a master password with \
'mvn --encrypt-master-password'; see %s
See https://docs.timefold.ai/timefold-solver/latest/deploying-to-platform/guide"""
.formatted(AccessTokenProvider.PAT_ENV_VARIABLE, getAccessTokenProvider().getServerId(),
AccessTokenProvider.ENCRYPTION_GUIDE_URL));
}
return accessToken;
}

/**
* The raw configured value, which {@link AccessTokenProvider} normalizes; read it back from there rather than
* here whenever it is reported, so that it names the entry that is actually looked up.
*/
private String getConfiguredServerId() {
return session == null ? serverId : getPropertyOrParameter(PROP_SERVER_ID, serverId);
}

protected void configureHttpRequest(Builder builder) throws MojoExecutionException {
builder.timeout(Duration.ofSeconds(30));
builder.header("Authorization", "Bearer " + accessTokenProvider.getAccessToken());
builder.header("Authorization", "Bearer " + requireAccessToken());
builder.header("Content-Type", "application/octet-stream");
builder.header("Accept", "application/json");
var tenants = getTenants();
Expand Down
Loading
Loading