mirror of
https://github.com/keycloak/keycloak.git
synced 2026-01-09 15:02:05 -03:30
feat: add Windows service support (#44496)
Closes: #37704 Signed-off-by: Peter Zaoral <pepo48@gmail.com>
This commit is contained in:
parent
04c0c874f9
commit
7da8a8a2e3
81
.github/actions/prunsrv-setup/action.yml
vendored
Normal file
81
.github/actions/prunsrv-setup/action.yml
vendored
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
name: Setup Apache Commons Daemon (Procrun)
|
||||||
|
description: Download and cache Apache Commons Daemon prunsrv.exe for Windows service tests
|
||||||
|
|
||||||
|
inputs:
|
||||||
|
version:
|
||||||
|
description: Apache Commons Daemon version (leave empty for latest)
|
||||||
|
required: false
|
||||||
|
default: ""
|
||||||
|
|
||||||
|
outputs:
|
||||||
|
prunsrv-path:
|
||||||
|
description: Path to the prunsrv.exe executable
|
||||||
|
value: ${{ steps.setup.outputs.prunsrv-path }}
|
||||||
|
version:
|
||||||
|
description: The resolved Apache Commons Daemon version
|
||||||
|
value: ${{ steps.resolve-version.outputs.version }}
|
||||||
|
|
||||||
|
runs:
|
||||||
|
using: composite
|
||||||
|
steps:
|
||||||
|
- name: Resolve latest version
|
||||||
|
id: resolve-version
|
||||||
|
shell: pwsh
|
||||||
|
run: |
|
||||||
|
$inputVersion = "${{ inputs.version }}"
|
||||||
|
if ([string]::IsNullOrWhiteSpace($inputVersion)) {
|
||||||
|
Write-Host "No version specified, detecting latest version from Apache downloads..."
|
||||||
|
$indexUrl = "https://downloads.apache.org/commons/daemon/binaries/windows/"
|
||||||
|
$response = Invoke-WebRequest -Uri $indexUrl -UseBasicParsing
|
||||||
|
# Parse the HTML to find the zip file and extract version
|
||||||
|
$match = [regex]::Match($response.Content, 'commons-daemon-(\d+\.\d+\.\d+)-bin-windows\.zip')
|
||||||
|
if ($match.Success) {
|
||||||
|
$version = $match.Groups[1].Value
|
||||||
|
Write-Host "Detected latest version: $version"
|
||||||
|
} else {
|
||||||
|
Write-Error "Could not detect latest version from Apache downloads page"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$version = $inputVersion
|
||||||
|
Write-Host "Using specified version: $version"
|
||||||
|
}
|
||||||
|
echo "version=$version" >> $env:GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
|
||||||
|
id: cache
|
||||||
|
name: Cache Apache Commons Daemon
|
||||||
|
with:
|
||||||
|
path: ${{ runner.temp }}/commons-daemon
|
||||||
|
key: commons-daemon-${{ steps.resolve-version.outputs.version }}-windows-amd64
|
||||||
|
|
||||||
|
- name: Download Apache Commons Daemon
|
||||||
|
if: steps.cache.outputs.cache-hit != 'true'
|
||||||
|
shell: pwsh
|
||||||
|
run: |
|
||||||
|
$version = "${{ steps.resolve-version.outputs.version }}"
|
||||||
|
$downloadUrl = "https://downloads.apache.org/commons/daemon/binaries/windows/commons-daemon-${version}-bin-windows.zip"
|
||||||
|
$zipPath = Join-Path "${{ runner.temp }}" "commons-daemon.zip"
|
||||||
|
$extractPath = Join-Path "${{ runner.temp }}" "commons-daemon"
|
||||||
|
|
||||||
|
Write-Host "Downloading Apache Commons Daemon $version from $downloadUrl"
|
||||||
|
Invoke-WebRequest -Uri $downloadUrl -OutFile $zipPath -UseBasicParsing
|
||||||
|
|
||||||
|
Write-Host "Extracting to $extractPath"
|
||||||
|
Expand-Archive -Path $zipPath -DestinationPath $extractPath -Force
|
||||||
|
Remove-Item $zipPath -Force
|
||||||
|
|
||||||
|
- name: Setup environment
|
||||||
|
id: setup
|
||||||
|
shell: pwsh
|
||||||
|
run: |
|
||||||
|
$prunsrvPath = "${{ runner.temp }}/commons-daemon/amd64/prunsrv.exe"
|
||||||
|
|
||||||
|
if (-not (Test-Path $prunsrvPath)) {
|
||||||
|
Write-Error "prunsrv.exe not found at $prunsrvPath"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "Apache Commons Daemon prunsrv.exe found at: $prunsrvPath"
|
||||||
|
echo "COMMONS_DAEMON_HOME=${{ runner.temp }}/commons-daemon/amd64" >> $env:GITHUB_ENV
|
||||||
|
echo "prunsrv-path=$prunsrvPath" >> $env:GITHUB_OUTPUT
|
||||||
5
.github/workflows/ci.yml
vendored
5
.github/workflows/ci.yml
vendored
@ -277,6 +277,11 @@ jobs:
|
|||||||
name: Integration test setup
|
name: Integration test setup
|
||||||
uses: ./.github/actions/integration-test-setup
|
uses: ./.github/actions/integration-test-setup
|
||||||
|
|
||||||
|
- id: prunsrv-setup
|
||||||
|
name: Setup Apache Commons Daemon (Procrun) for Windows service tests
|
||||||
|
if: runner.os == 'Windows'
|
||||||
|
uses: ./.github/actions/prunsrv-setup
|
||||||
|
|
||||||
# Smoke tests should cover scenarios that could be broken by changes in other modules that quarkus
|
# Smoke tests should cover scenarios that could be broken by changes in other modules that quarkus
|
||||||
# test classes and even individual tests are included in the following suites by junit tags
|
# test classes and even individual tests are included in the following suites by junit tags
|
||||||
# kc.quarkus.tests.groups acts as the tag filter
|
# kc.quarkus.tests.groups acts as the tag filter
|
||||||
|
|||||||
@ -143,6 +143,14 @@ You can now use a new client certificate lookup provider that is compliant with
|
|||||||
This enables native support e.g. for Caddy and other reverse proxies that follow the RFC.
|
This enables native support e.g. for Caddy and other reverse proxies that follow the RFC.
|
||||||
For details, navigate to link:{server_guide_base_link}/reverseproxy#_enabling_client_certificate_lookup[Enabling Client Certificate Lookup] section of the documentation.
|
For details, navigate to link:{server_guide_base_link}/reverseproxy#_enabling_client_certificate_lookup[Enabling Client Certificate Lookup] section of the documentation.
|
||||||
|
|
||||||
|
== Running Keycloak as a Windows service
|
||||||
|
|
||||||
|
{project_name} can now be installed and run as a Windows service using Apache Commons Daemon (Procrun). The new `tools windows-service` CLI subcommand simplifies service installation and uninstallation.
|
||||||
|
|
||||||
|
The service runs `kc.bat start` as an external process, ensuring all environment variables and configuration files are respected. This provides seamless integration with the Windows Services management console and enables automatic startup on system boot without requiring a user to be logged on.
|
||||||
|
|
||||||
|
For more information, see the https://www.keycloak.org/server/windows-service[Running Keycloak as a Windows Service] guide.
|
||||||
|
|
||||||
= Observability
|
= Observability
|
||||||
|
|
||||||
== Export traces with custom request headers
|
== Export traces with custom request headers
|
||||||
|
|||||||
@ -23,4 +23,5 @@ importExport
|
|||||||
vault
|
vault
|
||||||
all-config
|
all-config
|
||||||
all-provider-config
|
all-provider-config
|
||||||
update-compatibility
|
update-compatibility
|
||||||
|
windows-service
|
||||||
174
docs/guides/server/windows-service.adoc
Normal file
174
docs/guides/server/windows-service.adoc
Normal file
@ -0,0 +1,174 @@
|
|||||||
|
<#import "/templates/guide.adoc" as tmpl>
|
||||||
|
<#import "/templates/links.adoc" as links>
|
||||||
|
|
||||||
|
<@tmpl.guide
|
||||||
|
title="Run {project_name} as a Windows Service"
|
||||||
|
summary="Install and run {project_name} as a Windows service using Apache Commons Daemon.">
|
||||||
|
|
||||||
|
This guide explains how to install and run {project_name} as a Windows service using Apache Commons Daemon. The service runs `kc.bat` in "exe" mode so behavior matches running `kc.bat start` manually. The service runs in "exe" mode, where Procrun executes `kc.bat start` as an external process. Environment variables, such as `KC_*`, along with `conf/keycloak.conf`, are respected. The `kc.bat` script handles augmentation and build logic, ensuring the service behaves exactly like a manual start.
|
||||||
|
|
||||||
|
== Apache Commons Daemon Setup
|
||||||
|
To run {project_name} as a Windows service, you need the Apache Commons Daemon Procrun binary (`prunsrv.exe`). Download it for your platform: https://downloads.apache.org/commons/daemon/binaries/windows/
|
||||||
|
|
||||||
|
Then place `prunsrv.exe` into the Keycloak `bin` folder.
|
||||||
|
|
||||||
|
[source,bash]
|
||||||
|
----
|
||||||
|
copy "path\to\prunsrv.exe" "%KEYCLOAK_HOME%\bin\prunsrv.exe"
|
||||||
|
----
|
||||||
|
|
||||||
|
Use the amd64 binary for 64-bit and x86 for 32-bit systems.
|
||||||
|
|
||||||
|
== Optional: Pre-build
|
||||||
|
|
||||||
|
Pre-building is optional. If you do not pre-build, Keycloak will build automatically on first start, which takes longer.
|
||||||
|
|
||||||
|
[source,bash]
|
||||||
|
----
|
||||||
|
bin/kc.bat build --db=postgres
|
||||||
|
----
|
||||||
|
|
||||||
|
== Installing the Service
|
||||||
|
|
||||||
|
Keycloak includes a `tools windows-service` subcommand to simplify service installation and uninstallation.
|
||||||
|
|
||||||
|
[source,bash]
|
||||||
|
----
|
||||||
|
bin\kc.bat tools windows-service install --help
|
||||||
|
----
|
||||||
|
|
||||||
|
[source,bash]
|
||||||
|
----
|
||||||
|
bin\kc.bat tools windows-service uninstall --help
|
||||||
|
----
|
||||||
|
|
||||||
|
=== Examples
|
||||||
|
|
||||||
|
Install a basic service (runs as Local System by default):
|
||||||
|
|
||||||
|
[source,bash]
|
||||||
|
----
|
||||||
|
bin\kc.bat tools windows-service install --name keycloak
|
||||||
|
----
|
||||||
|
|
||||||
|
Manual startup and longer stop timeout:
|
||||||
|
|
||||||
|
[source,bash]
|
||||||
|
----
|
||||||
|
bin\kc.bat tools windows-service install --startup=manual --stop-timeout=60
|
||||||
|
----
|
||||||
|
|
||||||
|
Delayed auto-start:
|
||||||
|
|
||||||
|
[source,bash]
|
||||||
|
----
|
||||||
|
bin\kc.bat tools windows-service install --startup=delayed
|
||||||
|
----
|
||||||
|
|
||||||
|
Custom display name:
|
||||||
|
|
||||||
|
[source,bash]
|
||||||
|
----
|
||||||
|
bin\kc.bat tools windows-service install --name=my-keycloak --display-name="My Keycloak Server"
|
||||||
|
----
|
||||||
|
|
||||||
|
Use `--depends-on` to ensure required Windows services start before Keycloak (for example, a local database). By default Apache Commons Daemon may add `Tcpip` and `Afd` network dependencies.
|
||||||
|
|
||||||
|
[source,bash]
|
||||||
|
----
|
||||||
|
bin\kc.bat tools windows-service install --depends-on="postgresql-x64-15;Tcpip;Afd"
|
||||||
|
----
|
||||||
|
|
||||||
|
The default is to run the service as the Local System account - `--service-user` and `--service-password` can be omitted (recommended). To run as a specific user, the account must have the "Log on as a service" right.
|
||||||
|
|
||||||
|
[source,bash]
|
||||||
|
----
|
||||||
|
bin\kc.bat tools windows-service install --service-user="DOMAIN\Username" --service-password="password"
|
||||||
|
----
|
||||||
|
|
||||||
|
You can supply the service password securely via an environment variable, which is recommended:
|
||||||
|
|
||||||
|
[source,bash]
|
||||||
|
----
|
||||||
|
set KC_SERVICE_PASSWORD=s3cret
|
||||||
|
bin\kc.bat tools windows-service install --service-user="DOMAIN\Username"
|
||||||
|
----
|
||||||
|
|
||||||
|
Start the service:
|
||||||
|
|
||||||
|
[source,bash]
|
||||||
|
----
|
||||||
|
net start keycloak
|
||||||
|
----
|
||||||
|
|
||||||
|
Stop the service:
|
||||||
|
|
||||||
|
[source,bash]
|
||||||
|
----
|
||||||
|
net stop keycloak
|
||||||
|
----
|
||||||
|
|
||||||
|
Uninstall the service:
|
||||||
|
|
||||||
|
[source,bash]
|
||||||
|
----
|
||||||
|
bin\kc.bat tools windows-service uninstall --name keycloak
|
||||||
|
----
|
||||||
|
|
||||||
|
Check status using the Windows Services console (`services.msc`).
|
||||||
|
|
||||||
|
== Logging
|
||||||
|
|
||||||
|
When {project_name} runs as a service, it is recommended to enable file logging - see <@links.server id="logging"/>.
|
||||||
|
|
||||||
|
The service wrapper logs (e.g. `commons-daemon.YYYY-MM-DD.log`) respects the `log-path` option value during service creation.
|
||||||
|
|
||||||
|
== Configuration Changes
|
||||||
|
|
||||||
|
To change runtime configuration:
|
||||||
|
|
||||||
|
1. Stop the service: `net stop keycloak`.
|
||||||
|
2. Update environment variables or `conf/keycloak.conf`.
|
||||||
|
3. Optionally re-run build: `bin\kc.bat build [new-options]`.
|
||||||
|
4. Start the service: `net start keycloak`.
|
||||||
|
|
||||||
|
== Troubleshooting
|
||||||
|
|
||||||
|
=== Access Denied errors
|
||||||
|
|
||||||
|
* Ensure the service runs as Local System (default) or that the specified account has "Log on as a service".
|
||||||
|
|
||||||
|
=== Options defined as environment variables are ignored
|
||||||
|
|
||||||
|
Windows Services run in a separate session (usually as the LocalSystem account) and do not inherit the environment variables of the user who created the service. Define the required `KC_*` environment variables as system-wide environment variables, so they are available to the service.
|
||||||
|
|
||||||
|
=== Forcefully terminate the service
|
||||||
|
|
||||||
|
If the Apache Commons Daemon wrapper becomes unresponsive:
|
||||||
|
|
||||||
|
[source,bash]
|
||||||
|
----
|
||||||
|
taskkill /f /im prunsrv.exe
|
||||||
|
----
|
||||||
|
|
||||||
|
Use caution — this will affect all Procrun-managed services on the host.
|
||||||
|
|
||||||
|
== Apache Commons Daemon configuration under the hood
|
||||||
|
|
||||||
|
When you create the service, the following Apache Commons Daemon Procrun settings are applied:
|
||||||
|
|
||||||
|
* StartMode: `exe` (runs `kc.bat` as an external process)
|
||||||
|
* StartImage: `<KEYCLOAK_HOME>\bin\kc.bat`
|
||||||
|
* StartParams: `start`
|
||||||
|
* StopMode: `exe`
|
||||||
|
* StopImage: `<KEYCLOAK_HOME>\bin\kc.bat`
|
||||||
|
* StopParams: `stop`
|
||||||
|
* StopTimeout: configurable (default: 30 seconds)
|
||||||
|
|
||||||
|
Service configuration is stored in the Windows Registry under:
|
||||||
|
|
||||||
|
----
|
||||||
|
HKEY_LOCAL_MACHINE\SOFTWARE\WOW6432Node\Apache Software Foundation\ProcRun 2.0\<ServiceName>
|
||||||
|
----
|
||||||
|
|
||||||
|
</@tmpl.guide>
|
||||||
@ -121,8 +121,21 @@ Example:
|
|||||||
|
|
||||||
### Updating Expectations
|
### Updating Expectations
|
||||||
|
|
||||||
Changing to the help output will cause HelpCommandDistTest to fail. You may use:
|
Changing the help output will cause HelpCommandDistTest to fail. This test uses [ApprovalTests](https://github.com/approvals/ApprovalTests.Java) which creates `.received.txt` files containing the actual output when tests fail. To update the expected output (see [Approving The Result](https://github.com/approvals/ApprovalTests.Java/blob/master/approvaltests/docs/tutorials/GettingStarted.md#approving-the-result)):
|
||||||
|
|
||||||
KEYCLOAK_REPLACE_EXPECTED=true ../mvnw clean install -Dtest=HelpCommandDistTest
|
1. Run the failing test:
|
||||||
|
```
|
||||||
|
../mvnw clean install -Dtest=HelpCommandDistTest
|
||||||
|
```
|
||||||
|
|
||||||
to replace the expected output, then use a diff to ensure the changes look good.
|
2. Review the generated `.received.txt` files in the test directory and compare them with the `.approved.txt` files.
|
||||||
|
|
||||||
|
3. If the changes look correct, rename the `.received.txt` files to `.approved.txt` to approve the new output:
|
||||||
|
```
|
||||||
|
# Example for a specific test
|
||||||
|
mv HelpCommandDistTest.testHelp.received.txt HelpCommandDistTest.testHelp.approved.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
Note: If the files match, the received file will be deleted automatically. You must include the `.approved.` files in source control.
|
||||||
|
|
||||||
|
Alternatively, you can configure an [approval reporter](https://github.com/approvals/ApprovalTests.Java/blob/master/approvaltests/docs/reference/Reporters.md) to use a diff tool for easier comparison.
|
||||||
|
|||||||
@ -45,6 +45,8 @@ import org.keycloak.quarkus.runtime.cli.command.AbstractCommand;
|
|||||||
import org.keycloak.quarkus.runtime.cli.command.AbstractNonServerCommand;
|
import org.keycloak.quarkus.runtime.cli.command.AbstractNonServerCommand;
|
||||||
import org.keycloak.quarkus.runtime.cli.command.Build;
|
import org.keycloak.quarkus.runtime.cli.command.Build;
|
||||||
import org.keycloak.quarkus.runtime.cli.command.Main;
|
import org.keycloak.quarkus.runtime.cli.command.Main;
|
||||||
|
import org.keycloak.quarkus.runtime.cli.command.Tools;
|
||||||
|
import org.keycloak.quarkus.runtime.cli.command.WindowsService;
|
||||||
import org.keycloak.quarkus.runtime.configuration.ConfigArgsConfigSource;
|
import org.keycloak.quarkus.runtime.configuration.ConfigArgsConfigSource;
|
||||||
import org.keycloak.quarkus.runtime.configuration.Configuration;
|
import org.keycloak.quarkus.runtime.configuration.Configuration;
|
||||||
import org.keycloak.quarkus.runtime.configuration.DisabledMappersInterceptor;
|
import org.keycloak.quarkus.runtime.configuration.DisabledMappersInterceptor;
|
||||||
@ -627,9 +629,27 @@ public class Picocli {
|
|||||||
cmd.getHelpSectionMap().put(SECTION_KEY_COMMAND_LIST, new SubCommandListRenderer());
|
cmd.getHelpSectionMap().put(SECTION_KEY_COMMAND_LIST, new SubCommandListRenderer());
|
||||||
cmd.setErr(getErrWriter());
|
cmd.setErr(getErrWriter());
|
||||||
cmd.setOut(getOutWriter());
|
cmd.setOut(getOutWriter());
|
||||||
|
|
||||||
|
removePlatformSpecificCommands(cmd);
|
||||||
|
|
||||||
return cmd;
|
return cmd;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes platform-specific commands on non-applicable platforms
|
||||||
|
*/
|
||||||
|
private void removePlatformSpecificCommands(CommandLine cmd) {
|
||||||
|
if (!Environment.isWindows()) {
|
||||||
|
CommandLine toolsCmd = cmd.getSubcommands().get(Tools.NAME);
|
||||||
|
if (toolsCmd != null) {
|
||||||
|
CommandLine windowsServiceCmd = toolsCmd.getSubcommands().get(WindowsService.NAME);
|
||||||
|
if (windowsServiceCmd != null) {
|
||||||
|
toolsCmd.getCommandSpec().removeSubcommand(WindowsService.NAME);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public PrintWriter getErrWriter() {
|
public PrintWriter getErrWriter() {
|
||||||
return new PrintWriter(System.err, true);
|
return new PrintWriter(System.err, true);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -19,9 +19,9 @@ package org.keycloak.quarkus.runtime.cli.command;
|
|||||||
|
|
||||||
import picocli.CommandLine.Command;
|
import picocli.CommandLine.Command;
|
||||||
|
|
||||||
@Command(name = "tools",
|
@Command(name = Tools.NAME,
|
||||||
description = "Utilities for use and interaction with the server.",
|
description = "Utilities for use and interaction with the server.",
|
||||||
subcommands = {Completion.class})
|
subcommands = {Completion.class, WindowsService.class})
|
||||||
public class Tools {
|
public class Tools {
|
||||||
|
|
||||||
public static final String NAME = "tools";
|
public static final String NAME = "tools";
|
||||||
|
|||||||
@ -0,0 +1,29 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2025 Red Hat, Inc. and/or its affiliates
|
||||||
|
* and other contributors as indicated by the @author tags.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.keycloak.quarkus.runtime.cli.command;
|
||||||
|
|
||||||
|
import picocli.CommandLine.Command;
|
||||||
|
|
||||||
|
@Command(name = WindowsService.NAME,
|
||||||
|
description = "Manage Keycloak as a Windows service.",
|
||||||
|
subcommands = {WindowsServiceInstall.class, WindowsServiceUninstall.class})
|
||||||
|
public class WindowsService {
|
||||||
|
|
||||||
|
public static final String NAME = "windows-service";
|
||||||
|
|
||||||
|
}
|
||||||
@ -0,0 +1,230 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2025 Red Hat, Inc. and/or its affiliates
|
||||||
|
* and other contributors as indicated by the @author tags.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.keycloak.quarkus.runtime.cli.command;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
import org.keycloak.config.LoggingOptions;
|
||||||
|
import org.keycloak.quarkus.runtime.Environment;
|
||||||
|
import org.keycloak.quarkus.runtime.configuration.Configuration;
|
||||||
|
|
||||||
|
import picocli.CommandLine;
|
||||||
|
import picocli.CommandLine.Command;
|
||||||
|
import picocli.CommandLine.Option;
|
||||||
|
|
||||||
|
@Command(name = WindowsServiceInstall.NAME,
|
||||||
|
header = "Install Keycloak as a Windows service.",
|
||||||
|
description = {
|
||||||
|
"%nInstall a Windows service that runs Keycloak using 'kc.bat start'.",
|
||||||
|
"",
|
||||||
|
"This command requires prunsrv.exe to be present in the bin directory.",
|
||||||
|
"Download it from https://downloads.apache.org/commons/daemon/binaries/windows/",
|
||||||
|
"",
|
||||||
|
"The service runs in exe mode, executing kc.bat as an external process.",
|
||||||
|
"This means all environment variables and configuration files are respected.",
|
||||||
|
"",
|
||||||
|
"For faster startup, run 'kc.bat build' before installing the service.",
|
||||||
|
"Without a pre-build, the first service start will be slower as it builds."
|
||||||
|
},
|
||||||
|
footerHeading = "Examples:",
|
||||||
|
footer = { " Install with default settings:%n%n"
|
||||||
|
+ " $ ${PARENT-COMMAND-FULL-NAME:-$PARENTCOMMAND} ${COMMAND-NAME}%n%n"
|
||||||
|
+ " Install with custom service name:%n%n"
|
||||||
|
+ " $ ${PARENT-COMMAND-FULL-NAME:-$PARENTCOMMAND} ${COMMAND-NAME} --name=my-keycloak%n%n"
|
||||||
|
+ " Install with dependencies on other services:%n%n"
|
||||||
|
+ " $ ${PARENT-COMMAND-FULL-NAME:-$PARENTCOMMAND} ${COMMAND-NAME} --depends-on=\"postgresql-x64-15;Tcpip\"%n"})
|
||||||
|
public class WindowsServiceInstall extends AbstractCommand {
|
||||||
|
|
||||||
|
public static final String NAME = "install";
|
||||||
|
|
||||||
|
public static final String SERVICE_PASSWORD_ENV = "KC_SERVICE_PASSWORD";
|
||||||
|
|
||||||
|
private static final String DEFAULT_SERVICE_NAME = "keycloak";
|
||||||
|
private static final String DEFAULT_DISPLAY_NAME = "Keycloak Server";
|
||||||
|
private static final String DEFAULT_DESCRIPTION = "Keycloak Identity and Access Management";
|
||||||
|
|
||||||
|
@Option(names = "--name",
|
||||||
|
description = "The name of the Windows service.",
|
||||||
|
defaultValue = DEFAULT_SERVICE_NAME)
|
||||||
|
String serviceName;
|
||||||
|
|
||||||
|
@Option(names = "--display-name",
|
||||||
|
description = "The display name of the Windows service.",
|
||||||
|
defaultValue = DEFAULT_DISPLAY_NAME)
|
||||||
|
String displayName;
|
||||||
|
|
||||||
|
@Option(names = "--description",
|
||||||
|
description = "The description of the Windows service.",
|
||||||
|
defaultValue = DEFAULT_DESCRIPTION)
|
||||||
|
String description;
|
||||||
|
|
||||||
|
@Option(names = "--startup",
|
||||||
|
description = "Service startup mode: auto, manual.",
|
||||||
|
defaultValue = "auto")
|
||||||
|
String startupMode;
|
||||||
|
|
||||||
|
@Option(names = "--service-user",
|
||||||
|
description = "The user account the service should run as. Defaults to LocalSystem.")
|
||||||
|
String serviceUser;
|
||||||
|
|
||||||
|
@Option(names = "--service-password",
|
||||||
|
description = "The password for the service user account. Can also be set via the " + SERVICE_PASSWORD_ENV + " environment variable.")
|
||||||
|
String servicePassword;
|
||||||
|
|
||||||
|
@Option(names = "--stop-timeout",
|
||||||
|
description = "Timeout in seconds to wait for service to stop gracefully.",
|
||||||
|
defaultValue = "30")
|
||||||
|
Integer stopTimeout;
|
||||||
|
|
||||||
|
@Option(names = "--depends-on",
|
||||||
|
description = "Services that must start before this service. Separate multiple services with semicolons (e.g., \"postgresql-x64-15;Tcpip\").")
|
||||||
|
String dependsOn;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getName() {
|
||||||
|
return NAME;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean isHelpAll() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void runCommand() {
|
||||||
|
if (!Environment.isWindows()) {
|
||||||
|
executionError(spec.commandLine(), "Windows service management is only available on Windows.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for password from environment variable if not provided via command line
|
||||||
|
if (servicePassword == null || servicePassword.isEmpty()) {
|
||||||
|
servicePassword = System.getenv(SERVICE_PASSWORD_ENV);
|
||||||
|
}
|
||||||
|
|
||||||
|
Path homePath = Environment.getHomePath().orElseThrow(() ->
|
||||||
|
new CommandLine.ExecutionException(spec.commandLine(),
|
||||||
|
"Could not determine Keycloak home directory"));
|
||||||
|
|
||||||
|
Path prunsrvPath = homePath.resolve("bin").resolve("prunsrv.exe");
|
||||||
|
if (!Files.exists(prunsrvPath)) {
|
||||||
|
picocli.println("Looking for prunsrv.exe in: " + prunsrvPath);
|
||||||
|
picocli.println("Download from https://downloads.apache.org/commons/daemon/binaries/windows/");
|
||||||
|
executionError(spec.commandLine(), "Apache Commons Daemon (Procrun) executable not found at " + prunsrvPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
Path kcBatPath = homePath.resolve("bin").resolve("kc.bat");
|
||||||
|
if (!Files.exists(kcBatPath)) {
|
||||||
|
executionError(spec.commandLine(), "kc.bat not found at " + kcBatPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If a custom log file location is set, the service wrapper logs are stored in the same directory
|
||||||
|
Path logPath;
|
||||||
|
Optional<String> logFileOption = Configuration.getOptionalKcValue(LoggingOptions.LOG_FILE);
|
||||||
|
if (logFileOption.isPresent()) {
|
||||||
|
Path logFile = Path.of(logFileOption.get());
|
||||||
|
if (!logFile.isAbsolute()) {
|
||||||
|
logFile = homePath.resolve(logFile);
|
||||||
|
}
|
||||||
|
logPath = logFile.getParent();
|
||||||
|
} else {
|
||||||
|
logPath = homePath.resolve("data").resolve("log");
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
Files.createDirectories(logPath);
|
||||||
|
} catch (IOException e) {
|
||||||
|
executionError(spec.commandLine(), "Failed to create log directory: " + logPath, e);
|
||||||
|
}
|
||||||
|
|
||||||
|
picocli.println("Creating Keycloak Windows service '" + serviceName + "'...");
|
||||||
|
picocli.println("Service will run: " + kcBatPath + " start");
|
||||||
|
|
||||||
|
List<String> command = buildPrunsrvCommand(prunsrvPath, homePath, kcBatPath, logPath);
|
||||||
|
|
||||||
|
try {
|
||||||
|
ProcessBuilder pb = new ProcessBuilder(command);
|
||||||
|
pb.inheritIO();
|
||||||
|
Process process = pb.start();
|
||||||
|
int exitCode = process.waitFor();
|
||||||
|
|
||||||
|
if (exitCode == 0) {
|
||||||
|
picocli.println("Service '" + serviceName + "' installed successfully.");
|
||||||
|
if (serviceUser == null) {
|
||||||
|
picocli.println("Service is configured to run as Local System account.");
|
||||||
|
}
|
||||||
|
picocli.println("");
|
||||||
|
picocli.println("To start the service, run as Administrator:");
|
||||||
|
picocli.println(" net start " + serviceName);
|
||||||
|
} else {
|
||||||
|
executionError(spec.commandLine(),
|
||||||
|
"Failed to install service '" + serviceName + "'. Exit code: " + exitCode);
|
||||||
|
}
|
||||||
|
} catch (IOException | InterruptedException e) {
|
||||||
|
executionError(spec.commandLine(), "Failed to execute prunsrv: " + e.getMessage(), e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<String> buildPrunsrvCommand(Path prunsrvPath, Path homePath, Path kcBatPath, Path logPath) {
|
||||||
|
List<String> cmd = new ArrayList<>();
|
||||||
|
cmd.add(prunsrvPath.toString());
|
||||||
|
cmd.add("install");
|
||||||
|
cmd.add(serviceName);
|
||||||
|
cmd.add("--DisplayName=" + displayName);
|
||||||
|
cmd.add("--Description=" + description);
|
||||||
|
cmd.add("--Startup=" + startupMode);
|
||||||
|
|
||||||
|
// Use exe mode to run kc.bat directly
|
||||||
|
cmd.add("--StartMode=exe");
|
||||||
|
cmd.add("--StartPath=" + homePath);
|
||||||
|
cmd.add("--StartImage=" + kcBatPath);
|
||||||
|
cmd.add("--StartParams=start");
|
||||||
|
|
||||||
|
cmd.add("--StopMode=exe");
|
||||||
|
cmd.add("--StopPath=" + homePath);
|
||||||
|
cmd.add("--StopImage=" + kcBatPath);
|
||||||
|
cmd.add("--StopParams=stop");
|
||||||
|
cmd.add("--StopTimeout=" + stopTimeout);
|
||||||
|
|
||||||
|
// Add service dependencies if specified
|
||||||
|
if (dependsOn != null && !dependsOn.isEmpty()) {
|
||||||
|
cmd.add("++DependsOn=" + dependsOn);
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.add("--LogPath=" + logPath);
|
||||||
|
cmd.add("--LogLevel=Info");
|
||||||
|
|
||||||
|
// Configure service account
|
||||||
|
if (serviceUser != null && !serviceUser.isEmpty()) {
|
||||||
|
picocli.println("Configuring service to run as user: " + serviceUser);
|
||||||
|
cmd.add("--ServiceUser=" + serviceUser);
|
||||||
|
if (servicePassword != null && !servicePassword.isEmpty()) {
|
||||||
|
cmd.add("--ServicePassword=" + servicePassword);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
picocli.println("Configuring service to run as Local System account");
|
||||||
|
cmd.add("--ServiceUser=LocalSystem");
|
||||||
|
}
|
||||||
|
|
||||||
|
return cmd;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,105 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2025 Red Hat, Inc. and/or its affiliates
|
||||||
|
* and other contributors as indicated by the @author tags.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.keycloak.quarkus.runtime.cli.command;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import org.keycloak.quarkus.runtime.Environment;
|
||||||
|
|
||||||
|
import picocli.CommandLine;
|
||||||
|
import picocli.CommandLine.Command;
|
||||||
|
import picocli.CommandLine.Option;
|
||||||
|
|
||||||
|
@Command(name = WindowsServiceUninstall.NAME,
|
||||||
|
header = "Uninstall Keycloak Windows service.",
|
||||||
|
description = {
|
||||||
|
"%nUninstall Keycloak Windows service installed with 'kc.bat tools windows-service install'.",
|
||||||
|
"",
|
||||||
|
"This command requires prunsrv.exe to be present in the bin directory."
|
||||||
|
},
|
||||||
|
footerHeading = "Examples:",
|
||||||
|
footer = { " Uninstall with default service name:%n%n"
|
||||||
|
+ " $ ${PARENT-COMMAND-FULL-NAME:-$PARENTCOMMAND} ${COMMAND-NAME}%n%n"
|
||||||
|
+ " Uninstall a custom-named service:%n%n"
|
||||||
|
+ " $ ${PARENT-COMMAND-FULL-NAME:-$PARENTCOMMAND} ${COMMAND-NAME} --name=my-keycloak%n"})
|
||||||
|
public class WindowsServiceUninstall extends AbstractCommand {
|
||||||
|
|
||||||
|
public static final String NAME = "uninstall";
|
||||||
|
|
||||||
|
private static final String DEFAULT_SERVICE_NAME = "keycloak";
|
||||||
|
|
||||||
|
@Option(names = "--name",
|
||||||
|
description = "The name of the Windows service to uninstall.",
|
||||||
|
defaultValue = DEFAULT_SERVICE_NAME)
|
||||||
|
String serviceName;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getName() {
|
||||||
|
return NAME;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean isHelpAll() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void runCommand() {
|
||||||
|
if (!Environment.isWindows()) {
|
||||||
|
executionError(spec.commandLine(), "Windows service management is only available on Windows.");
|
||||||
|
}
|
||||||
|
|
||||||
|
Path homePath = Environment.getHomePath().orElseThrow(() ->
|
||||||
|
new CommandLine.ExecutionException(spec.commandLine(),
|
||||||
|
"Could not determine Keycloak home directory"));
|
||||||
|
|
||||||
|
Path prunsrvPath = homePath.resolve("bin").resolve("prunsrv.exe");
|
||||||
|
if (!Files.exists(prunsrvPath)) {
|
||||||
|
picocli.println("Looking for prunsrv.exe in: " + prunsrvPath);
|
||||||
|
picocli.println("Download from https://downloads.apache.org/commons/daemon/binaries/windows/");
|
||||||
|
executionError(spec.commandLine(), "Apache Commons Daemon (Procrun) executable not found at " + prunsrvPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
picocli.println("Deleting Keycloak service '" + serviceName + "'...");
|
||||||
|
|
||||||
|
List<String> command = new ArrayList<>();
|
||||||
|
command.add(prunsrvPath.toString());
|
||||||
|
command.add("delete");
|
||||||
|
command.add(serviceName);
|
||||||
|
|
||||||
|
try {
|
||||||
|
ProcessBuilder pb = new ProcessBuilder(command);
|
||||||
|
pb.inheritIO();
|
||||||
|
Process process = pb.start();
|
||||||
|
int exitCode = process.waitFor();
|
||||||
|
|
||||||
|
if (exitCode == 0) {
|
||||||
|
picocli.println("Service '" + serviceName + "' uninstalled successfully.");
|
||||||
|
} else {
|
||||||
|
executionError(spec.commandLine(),
|
||||||
|
"Failed to uninstall service '" + serviceName + "'. Exit code: " + exitCode);
|
||||||
|
}
|
||||||
|
} catch (IOException | InterruptedException e) {
|
||||||
|
executionError(spec.commandLine(), "Failed to execute prunsrv: " + e.getMessage(), e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -37,7 +37,7 @@
|
|||||||
<properties>
|
<properties>
|
||||||
<kc.quarkus.tests.dist>raw</kc.quarkus.tests.dist>
|
<kc.quarkus.tests.dist>raw</kc.quarkus.tests.dist>
|
||||||
<kc.quarkus.tests.groups>!invalid</kc.quarkus.tests.groups>
|
<kc.quarkus.tests.groups>!invalid</kc.quarkus.tests.groups>
|
||||||
<approvaltests.version>14.0.0</approvaltests.version>
|
<approvaltests.version>26.1.0</approvaltests.version>
|
||||||
<build-helper-maven-plugin.version>3.3.0</build-helper-maven-plugin.version>
|
<build-helper-maven-plugin.version>3.3.0</build-helper-maven-plugin.version>
|
||||||
</properties>
|
</properties>
|
||||||
|
|
||||||
|
|||||||
@ -17,16 +17,13 @@
|
|||||||
|
|
||||||
package org.keycloak.it.cli.dist;
|
package org.keycloak.it.cli.dist;
|
||||||
|
|
||||||
import java.io.IOException;
|
|
||||||
import java.nio.charset.StandardCharsets;
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Locale;
|
|
||||||
import java.util.regex.Pattern;
|
|
||||||
|
|
||||||
import org.keycloak.it.junit5.extension.CLIResult;
|
import org.keycloak.it.junit5.extension.CLIResult;
|
||||||
import org.keycloak.it.junit5.extension.DistributionTest;
|
import org.keycloak.it.junit5.extension.DistributionTest;
|
||||||
import org.keycloak.it.junit5.extension.RawDistOnly;
|
import org.keycloak.it.junit5.extension.RawDistOnly;
|
||||||
import org.keycloak.it.utils.KeycloakDistribution;
|
import org.keycloak.it.utils.KeycloakDistribution;
|
||||||
|
import org.keycloak.quarkus.runtime.Environment;
|
||||||
import org.keycloak.quarkus.runtime.cli.command.BootstrapAdmin;
|
import org.keycloak.quarkus.runtime.cli.command.BootstrapAdmin;
|
||||||
import org.keycloak.quarkus.runtime.cli.command.BootstrapAdminService;
|
import org.keycloak.quarkus.runtime.cli.command.BootstrapAdminService;
|
||||||
import org.keycloak.quarkus.runtime.cli.command.BootstrapAdminUser;
|
import org.keycloak.quarkus.runtime.cli.command.BootstrapAdminUser;
|
||||||
@ -39,13 +36,14 @@ import org.keycloak.quarkus.runtime.cli.command.UpdateCompatibility;
|
|||||||
import org.keycloak.quarkus.runtime.cli.command.UpdateCompatibilityCheck;
|
import org.keycloak.quarkus.runtime.cli.command.UpdateCompatibilityCheck;
|
||||||
import org.keycloak.quarkus.runtime.cli.command.UpdateCompatibilityMetadata;
|
import org.keycloak.quarkus.runtime.cli.command.UpdateCompatibilityMetadata;
|
||||||
|
|
||||||
|
import com.spun.util.io.FileUtils;
|
||||||
import io.quarkus.test.junit.main.Launch;
|
import io.quarkus.test.junit.main.Launch;
|
||||||
import org.apache.commons.io.FileUtils;
|
|
||||||
import org.approvaltests.Approvals;
|
import org.approvaltests.Approvals;
|
||||||
|
import org.approvaltests.core.Options;
|
||||||
|
import org.approvaltests.core.VerifyResult;
|
||||||
import org.hamcrest.MatcherAssert;
|
import org.hamcrest.MatcherAssert;
|
||||||
import org.hamcrest.Matchers;
|
import org.hamcrest.Matchers;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.junit.jupiter.api.condition.OS;
|
|
||||||
|
|
||||||
import static org.keycloak.quarkus.runtime.cli.command.AbstractAutoBuildCommand.OPTIMIZED_BUILD_OPTION_LONG;
|
import static org.keycloak.quarkus.runtime.cli.command.AbstractAutoBuildCommand.OPTIMIZED_BUILD_OPTION_LONG;
|
||||||
|
|
||||||
@ -53,8 +51,6 @@ import static org.keycloak.quarkus.runtime.cli.command.AbstractAutoBuildCommand.
|
|||||||
@RawDistOnly(reason = "Verifying the help message output doesn't need long spin-up of docker dist tests.")
|
@RawDistOnly(reason = "Verifying the help message output doesn't need long spin-up of docker dist tests.")
|
||||||
public class HelpCommandDistTest {
|
public class HelpCommandDistTest {
|
||||||
|
|
||||||
public static final String REPLACE_EXPECTED = "KEYCLOAK_REPLACE_EXPECTED";
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@Launch({})
|
@Launch({})
|
||||||
void testDefaultToHelp(CLIResult cliResult) {
|
void testDefaultToHelp(CLIResult cliResult) {
|
||||||
@ -193,7 +189,7 @@ public class HelpCommandDistTest {
|
|||||||
for (String cmd : List.of("", "start", "start-dev", "build")) {
|
for (String cmd : List.of("", "start", "start-dev", "build")) {
|
||||||
String debugOption = "--debug";
|
String debugOption = "--debug";
|
||||||
|
|
||||||
if (OS.WINDOWS.isCurrentOs()) {
|
if (Environment.isWindows()) {
|
||||||
debugOption = "--debug=8787";
|
debugOption = "--debug=8787";
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -213,29 +209,32 @@ public class HelpCommandDistTest {
|
|||||||
.replaceAll("((Disables|Enables) a set of one or more features. Possible values are: )[^.]{30,}", "$1<...>")
|
.replaceAll("((Disables|Enables) a set of one or more features. Possible values are: )[^.]{30,}", "$1<...>")
|
||||||
.replaceAll("(create a metric.\\s+Possible values are:)[^.]{30,}.[^.]*.", "$1<...>");
|
.replaceAll("(create a metric.\\s+Possible values are:)[^.]{30,}.[^.]*.", "$1<...>");
|
||||||
|
|
||||||
String osName = System.getProperty("os.name");
|
if (Environment.isWindows()) {
|
||||||
if(osName.toLowerCase(Locale.ROOT).contains("windows")) {
|
|
||||||
// On Windows, all output should have at least one "kc.bat" in it.
|
|
||||||
MatcherAssert.assertThat(output, Matchers.containsString("kc.bat"));
|
MatcherAssert.assertThat(output, Matchers.containsString("kc.bat"));
|
||||||
output = output.replaceAll("kc.bat", "kc.sh");
|
output = output
|
||||||
output = output.replaceAll(Pattern.quote("data\\log\\"), "data/log/");
|
.replace("kc.bat", "kc.sh")
|
||||||
// line wrap which looks differently due to ".bat" vs. ".sh"
|
.replace("data\\log\\", "data/log/")
|
||||||
output = output.replaceAll("including\nbuild ", "including build\n");
|
.replace("including\nbuild ", "including build\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
// Custom comparator that strips Windows-specific lines from the approved file on non-Windows platforms
|
||||||
Approvals.verify(output);
|
Options options = new Options().withComparator((receivedFile, approvedFile) -> {
|
||||||
} catch (Error cause) {
|
String received = FileUtils.readFile(receivedFile);
|
||||||
if ("true".equals(System.getenv(REPLACE_EXPECTED))) {
|
String approved = FileUtils.readFile(approvedFile);
|
||||||
try {
|
|
||||||
FileUtils.write(Approvals.createApprovalNamer().getApprovedFile(".txt"), output,
|
if (!Environment.isWindows()) {
|
||||||
StandardCharsets.UTF_8);
|
approved = stripWindowsServiceLines(approved);
|
||||||
} catch (IOException e) {
|
|
||||||
throw new RuntimeException("Failed to assert help, and could not replace expected", cause);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
throw cause;
|
|
||||||
}
|
}
|
||||||
}
|
return VerifyResult.from(approved.equals(received));
|
||||||
|
});
|
||||||
|
|
||||||
|
Approvals.verify(output, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String stripWindowsServiceLines(String text) {
|
||||||
|
return text
|
||||||
|
.replaceAll("(?m)^ {4}windows-service\\s+Manage Keycloak as a Windows service\\.\\R", "")
|
||||||
|
.replaceAll("(?m)^ {6}install\\s+Install Keycloak as a Windows service\\.\\R", "")
|
||||||
|
.replaceAll("(?m)^ {6}uninstall\\s+Uninstall Keycloak Windows service\\.\\R", "");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
314
quarkus/tests/integration/src/test/java/org/keycloak/it/cli/dist/WindowsServiceDistTest.java
vendored
Normal file
314
quarkus/tests/integration/src/test/java/org/keycloak/it/cli/dist/WindowsServiceDistTest.java
vendored
Normal file
@ -0,0 +1,314 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2025 Red Hat, Inc. and/or its affiliates
|
||||||
|
* and other contributors as indicated by the @author tags.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.keycloak.it.cli.dist;
|
||||||
|
|
||||||
|
import java.io.BufferedReader;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.InputStreamReader;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.nio.file.StandardCopyOption;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
|
||||||
|
import org.keycloak.it.junit5.extension.CLIResult;
|
||||||
|
import org.keycloak.it.junit5.extension.DistributionTest;
|
||||||
|
import org.keycloak.it.junit5.extension.RawDistOnly;
|
||||||
|
import org.keycloak.it.utils.KeycloakDistribution;
|
||||||
|
import org.keycloak.it.utils.RawKeycloakDistribution;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.AfterEach;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Tag;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.condition.EnabledOnOs;
|
||||||
|
import org.junit.jupiter.api.condition.OS;
|
||||||
|
|
||||||
|
import static io.restassured.RestAssured.given;
|
||||||
|
import static org.hamcrest.MatcherAssert.assertThat;
|
||||||
|
import static org.hamcrest.Matchers.containsString;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
@EnabledOnOs(value = OS.WINDOWS, disabledReason = "Windows service tests are only applicable on Windows")
|
||||||
|
@DistributionTest
|
||||||
|
@RawDistOnly(reason = "Windows service management requires raw distribution")
|
||||||
|
@Tag(DistributionTest.WIN)
|
||||||
|
public class WindowsServiceDistTest {
|
||||||
|
|
||||||
|
private static final String TEST_SERVICE_NAME_PREFIX = "keycloak-test-";
|
||||||
|
private static final int SERVICE_START_TIMEOUT_SECONDS = 60;
|
||||||
|
private static final int SERVICE_STOP_TIMEOUT_SECONDS = 30;
|
||||||
|
|
||||||
|
private RawKeycloakDistribution rawDist;
|
||||||
|
private Path distPath;
|
||||||
|
private String testServiceName;
|
||||||
|
private boolean serviceCreated = false;
|
||||||
|
private boolean prunsrvAvailable = false;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp(KeycloakDistribution dist) {
|
||||||
|
this.rawDist = dist.unwrap(RawKeycloakDistribution.class);
|
||||||
|
this.distPath = rawDist.getDistPath();
|
||||||
|
this.testServiceName = TEST_SERVICE_NAME_PREFIX + System.currentTimeMillis();
|
||||||
|
|
||||||
|
// Check if prunsrv.exe is available in the distribution
|
||||||
|
Path prunsrvPath = distPath.resolve("bin").resolve("prunsrv.exe");
|
||||||
|
if (!Files.exists(prunsrvPath)) {
|
||||||
|
String prunsrvSystemPath = findPrunsrvInSystem();
|
||||||
|
if (prunsrvSystemPath != null) {
|
||||||
|
try {
|
||||||
|
Files.copy(Path.of(prunsrvSystemPath), prunsrvPath, StandardCopyOption.REPLACE_EXISTING);
|
||||||
|
prunsrvAvailable = true;
|
||||||
|
} catch (IOException e) {
|
||||||
|
System.err.println("Could not copy prunsrv.exe to distribution: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
prunsrvAvailable = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@AfterEach
|
||||||
|
void tearDown() {
|
||||||
|
if (serviceCreated) {
|
||||||
|
try {
|
||||||
|
stopService();
|
||||||
|
} catch (Exception e) {
|
||||||
|
System.err.println("Failed to stop service during cleanup: " + e.getMessage());
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
deleteService();
|
||||||
|
} catch (Exception e) {
|
||||||
|
System.err.println("Failed to delete service during cleanup: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testServiceLifecycle() throws Exception {
|
||||||
|
assertPrunsrvAvailable();
|
||||||
|
assertAdminPrivileges();
|
||||||
|
|
||||||
|
String customDisplayName = "Keycloak Test Service " + testServiceName;
|
||||||
|
String customDescription = "Keycloak integration test service";
|
||||||
|
|
||||||
|
rawDist.setProperty("http-enabled", "true");
|
||||||
|
rawDist.setProperty("hostname-strict", "false");
|
||||||
|
rawDist.setProperty("log", "console,file");
|
||||||
|
rawDist.setProperty("log-file", distPath.resolve("log").resolve("keycloak.log").toString());
|
||||||
|
|
||||||
|
// Install the service with custom name and display name
|
||||||
|
CLIResult installResult = rawDist.run("tools", "windows-service", "install",
|
||||||
|
"--name=" + testServiceName,
|
||||||
|
"--display-name=" + customDisplayName,
|
||||||
|
"--description=" + customDescription,
|
||||||
|
"--startup=manual");
|
||||||
|
|
||||||
|
assertEquals(0, installResult.exitCode(), "Service installation failed: " + installResult.getOutput());
|
||||||
|
assertThat(installResult.getOutput(), containsString("installed successfully"));
|
||||||
|
serviceCreated = true;
|
||||||
|
assertTrue(isServiceCreated(testServiceName), "Service should be installed");
|
||||||
|
|
||||||
|
// Verify the display name in service configuration
|
||||||
|
String serviceInfo = getServiceInfo(testServiceName);
|
||||||
|
assertThat("Service info should contain custom display name", serviceInfo, containsString(customDisplayName));
|
||||||
|
|
||||||
|
// Test service start
|
||||||
|
assertTrue(startService(), "Service should start successfully");
|
||||||
|
assertTrue(waitForKeycloakReady(), "Keycloak should be accessible after service start");
|
||||||
|
assertEquals("RUNNING", getServiceState(testServiceName), "Service should be in RUNNING state");
|
||||||
|
|
||||||
|
// Verify log file was created and contains startup message
|
||||||
|
Path logFile = distPath.resolve("log").resolve("keycloak.log");
|
||||||
|
assertTrue(waitForLogFile(logFile), "Log file should be created");
|
||||||
|
String logContent = Files.readString(logFile);
|
||||||
|
assertThat("Log should contain Keycloak startup message", logContent, containsString("Listening on:"));
|
||||||
|
|
||||||
|
// Test service stop
|
||||||
|
assertTrue(stopService(), "Service should stop successfully");
|
||||||
|
assertTrue(waitForServiceStopped(), "Service should be in STOPPED state");
|
||||||
|
assertFalse(isKeycloakAccessible(), "Keycloak should not be accessible after service stop");
|
||||||
|
|
||||||
|
// Test service uninstall
|
||||||
|
CLIResult uninstallResult = rawDist.run("tools", "windows-service", "uninstall", "--name=" + testServiceName);
|
||||||
|
assertEquals(0, uninstallResult.exitCode(), "Service uninstallation failed: " + uninstallResult.getOutput());
|
||||||
|
assertThat(uninstallResult.getOutput(), containsString("uninstalled successfully"));
|
||||||
|
serviceCreated = false;
|
||||||
|
assertFalse(isServiceCreated(testServiceName), "Service should be uninstalled");
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean waitForLogFile(Path logFile) {
|
||||||
|
try {
|
||||||
|
org.awaitility.Awaitility.await()
|
||||||
|
.atMost(SERVICE_START_TIMEOUT_SECONDS, TimeUnit.SECONDS)
|
||||||
|
.pollInterval(2, TimeUnit.SECONDS)
|
||||||
|
.until(() -> Files.exists(logFile) && Files.size(logFile) > 0);
|
||||||
|
return true;
|
||||||
|
} catch (org.awaitility.core.ConditionTimeoutException e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean waitForKeycloakReady() {
|
||||||
|
try {
|
||||||
|
org.awaitility.Awaitility.await()
|
||||||
|
.atMost(SERVICE_START_TIMEOUT_SECONDS, TimeUnit.SECONDS)
|
||||||
|
.pollInterval(2, TimeUnit.SECONDS)
|
||||||
|
.until(this::isKeycloakAccessible);
|
||||||
|
return true;
|
||||||
|
} catch (org.awaitility.core.ConditionTimeoutException e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void assertPrunsrvAvailable() {
|
||||||
|
assertTrue(prunsrvAvailable, "prunsrv.exe not available. Download from https://downloads.apache.org/commons/daemon/binaries/windows/");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void assertAdminPrivileges() {
|
||||||
|
try {
|
||||||
|
ProcessBuilder pb = new ProcessBuilder("net", "session");
|
||||||
|
pb.redirectErrorStream(true);
|
||||||
|
Process process = pb.start();
|
||||||
|
// Consume output to prevent blocking
|
||||||
|
process.getInputStream().transferTo(java.io.OutputStream.nullOutputStream());
|
||||||
|
int exitCode = process.waitFor();
|
||||||
|
assertEquals(0, exitCode, "Administrator privileges required to run Windows service tests. Run tests from an elevated terminal or IDE.");
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new AssertionError("Could not verify admin privileges: " + e.getMessage(), e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String findPrunsrvInSystem() {
|
||||||
|
List<String> possiblePaths = new ArrayList<>();
|
||||||
|
|
||||||
|
String prunsrvHome = System.getenv("PRUNSRV_HOME");
|
||||||
|
if (prunsrvHome != null) {
|
||||||
|
possiblePaths.add(prunsrvHome + "\\prunsrv.exe");
|
||||||
|
}
|
||||||
|
|
||||||
|
String commonsDaemonHome = System.getenv("COMMONS_DAEMON_HOME");
|
||||||
|
if (commonsDaemonHome != null) {
|
||||||
|
possiblePaths.add(commonsDaemonHome + "\\prunsrv.exe");
|
||||||
|
}
|
||||||
|
|
||||||
|
possiblePaths.add("C:\\Program Files\\Apache\\commons-daemon\\prunsrv.exe");
|
||||||
|
|
||||||
|
return possiblePaths.stream()
|
||||||
|
.filter(path -> Files.exists(Path.of(path)))
|
||||||
|
.findFirst()
|
||||||
|
.orElse(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean startService() throws IOException, InterruptedException {
|
||||||
|
ProcessBuilder pb = new ProcessBuilder("net", "start", testServiceName);
|
||||||
|
pb.redirectErrorStream(true);
|
||||||
|
Process process = pb.start();
|
||||||
|
String output = new String(process.getInputStream().readAllBytes());
|
||||||
|
int exitCode = process.waitFor();
|
||||||
|
if (exitCode != 0) {
|
||||||
|
System.err.println("Service start failed with exit code " + exitCode + ": " + output);
|
||||||
|
}
|
||||||
|
return exitCode == 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean stopService() throws IOException, InterruptedException {
|
||||||
|
ProcessBuilder pb = new ProcessBuilder("net", "stop", testServiceName);
|
||||||
|
Process process = pb.start();
|
||||||
|
process.waitFor(SERVICE_STOP_TIMEOUT_SECONDS, TimeUnit.SECONDS);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void deleteService() {
|
||||||
|
rawDist.run("tools", "windows-service", "uninstall", "--name=" + testServiceName);
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isServiceCreated(String serviceName) {
|
||||||
|
try {
|
||||||
|
ProcessBuilder pb = new ProcessBuilder("sc", "query", serviceName);
|
||||||
|
Process process = pb.start();
|
||||||
|
int exitCode = process.waitFor();
|
||||||
|
return exitCode == 0;
|
||||||
|
} catch (Exception e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String getServiceState(String serviceName) {
|
||||||
|
try {
|
||||||
|
ProcessBuilder pb = new ProcessBuilder("sc", "query", serviceName);
|
||||||
|
Process process = pb.start();
|
||||||
|
|
||||||
|
try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()))) {
|
||||||
|
String line;
|
||||||
|
while ((line = reader.readLine()) != null) {
|
||||||
|
if (line.contains("STATE")) {
|
||||||
|
if (line.contains("RUNNING")) return "RUNNING";
|
||||||
|
if (line.contains("STOPPED")) return "STOPPED";
|
||||||
|
if (line.contains("PAUSED")) return "PAUSED";
|
||||||
|
if (line.contains("START_PENDING")) return "START_PENDING";
|
||||||
|
if (line.contains("STOP_PENDING")) return "STOP_PENDING";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
process.waitFor();
|
||||||
|
} catch (Exception e) {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
return "UNKNOWN";
|
||||||
|
}
|
||||||
|
|
||||||
|
private String getServiceInfo(String serviceName) {
|
||||||
|
try {
|
||||||
|
ProcessBuilder pb = new ProcessBuilder("sc", "qc", serviceName);
|
||||||
|
pb.redirectErrorStream(true);
|
||||||
|
Process process = pb.start();
|
||||||
|
String output = new String(process.getInputStream().readAllBytes());
|
||||||
|
process.waitFor();
|
||||||
|
return output;
|
||||||
|
} catch (Exception e) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean waitForServiceStopped() {
|
||||||
|
try {
|
||||||
|
org.awaitility.Awaitility.await()
|
||||||
|
.atMost(SERVICE_STOP_TIMEOUT_SECONDS, TimeUnit.SECONDS)
|
||||||
|
.pollInterval(1, TimeUnit.SECONDS)
|
||||||
|
.until(() -> "STOPPED".equals(getServiceState(testServiceName)));
|
||||||
|
return true;
|
||||||
|
} catch (org.awaitility.core.ConditionTimeoutException e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isKeycloakAccessible() {
|
||||||
|
try {
|
||||||
|
return given()
|
||||||
|
.when()
|
||||||
|
.get("http://localhost:8080/realms/master/")
|
||||||
|
.getStatusCode() == 200;
|
||||||
|
} catch (Exception e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -27,6 +27,9 @@ Commands:
|
|||||||
show-config Print out the current configuration.
|
show-config Print out the current configuration.
|
||||||
tools Utilities for use and interaction with the server.
|
tools Utilities for use and interaction with the server.
|
||||||
completion Generate bash/zsh completion script for kc.sh.
|
completion Generate bash/zsh completion script for kc.sh.
|
||||||
|
windows-service Manage Keycloak as a Windows service.
|
||||||
|
install Install Keycloak as a Windows service.
|
||||||
|
uninstall Uninstall Keycloak Windows service.
|
||||||
bootstrap-admin Commands for bootstrapping admin access
|
bootstrap-admin Commands for bootstrapping admin access
|
||||||
user Add an admin user with a password
|
user Add an admin user with a password
|
||||||
service Add an admin service account
|
service Add an admin service account
|
||||||
@ -60,4 +63,4 @@ Examples:
|
|||||||
production.
|
production.
|
||||||
|
|
||||||
Use "kc.sh start --help" for the available options when starting the server.
|
Use "kc.sh start --help" for the available options when starting the server.
|
||||||
Use "kc.sh <command> --help" for more information about other commands.
|
Use "kc.sh <command> --help" for more information about other commands.
|
||||||
@ -27,6 +27,9 @@ Commands:
|
|||||||
show-config Print out the current configuration.
|
show-config Print out the current configuration.
|
||||||
tools Utilities for use and interaction with the server.
|
tools Utilities for use and interaction with the server.
|
||||||
completion Generate bash/zsh completion script for kc.sh.
|
completion Generate bash/zsh completion script for kc.sh.
|
||||||
|
windows-service Manage Keycloak as a Windows service.
|
||||||
|
install Install Keycloak as a Windows service.
|
||||||
|
uninstall Uninstall Keycloak Windows service.
|
||||||
bootstrap-admin Commands for bootstrapping admin access
|
bootstrap-admin Commands for bootstrapping admin access
|
||||||
user Add an admin user with a password
|
user Add an admin user with a password
|
||||||
service Add an admin service account
|
service Add an admin service account
|
||||||
@ -60,4 +63,4 @@ Examples:
|
|||||||
production.
|
production.
|
||||||
|
|
||||||
Use "kc.sh start --help" for the available options when starting the server.
|
Use "kc.sh start --help" for the available options when starting the server.
|
||||||
Use "kc.sh <command> --help" for more information about other commands.
|
Use "kc.sh <command> --help" for more information about other commands.
|
||||||
@ -27,6 +27,9 @@ Commands:
|
|||||||
show-config Print out the current configuration.
|
show-config Print out the current configuration.
|
||||||
tools Utilities for use and interaction with the server.
|
tools Utilities for use and interaction with the server.
|
||||||
completion Generate bash/zsh completion script for kc.sh.
|
completion Generate bash/zsh completion script for kc.sh.
|
||||||
|
windows-service Manage Keycloak as a Windows service.
|
||||||
|
install Install Keycloak as a Windows service.
|
||||||
|
uninstall Uninstall Keycloak Windows service.
|
||||||
bootstrap-admin Commands for bootstrapping admin access
|
bootstrap-admin Commands for bootstrapping admin access
|
||||||
user Add an admin user with a password
|
user Add an admin user with a password
|
||||||
service Add an admin service account
|
service Add an admin service account
|
||||||
@ -60,4 +63,4 @@ Examples:
|
|||||||
production.
|
production.
|
||||||
|
|
||||||
Use "kc.sh start --help" for the available options when starting the server.
|
Use "kc.sh start --help" for the available options when starting the server.
|
||||||
Use "kc.sh <command> --help" for more information about other commands.
|
Use "kc.sh <command> --help" for more information about other commands.
|
||||||
@ -15,4 +15,4 @@ Commands:
|
|||||||
configuration. A zero exit code means a rolling update is
|
configuration. A zero exit code means a rolling update is
|
||||||
possible between old and the current metadata.
|
possible between old and the current metadata.
|
||||||
metadata Stores the metadata necessary to determine if a configuration is
|
metadata Stores the metadata necessary to determine if a configuration is
|
||||||
compatible.
|
compatible.
|
||||||
@ -36,7 +36,7 @@
|
|||||||
|
|
||||||
<properties>
|
<properties>
|
||||||
<kc.quarkus.tests.dist>raw</kc.quarkus.tests.dist>
|
<kc.quarkus.tests.dist>raw</kc.quarkus.tests.dist>
|
||||||
<approvaltests.version>14.0.0</approvaltests.version>
|
<approvaltests.version>26.1.0</approvaltests.version>
|
||||||
<build-helper-maven-plugin.version>3.3.0</build-helper-maven-plugin.version>
|
<build-helper-maven-plugin.version>3.3.0</build-helper-maven-plugin.version>
|
||||||
<maven-resolver-util.version>1.9.10</maven-resolver-util.version>
|
<maven-resolver-util.version>1.9.10</maven-resolver-util.version>
|
||||||
</properties>
|
</properties>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user