feat: add Windows service support (#44496)

Closes: #37704

Signed-off-by: Peter Zaoral <pepo48@gmail.com>
This commit is contained in:
Peter Zaoral 2025-12-19 17:55:42 +01:00 committed by GitHub
parent 04c0c874f9
commit 7da8a8a2e3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 1029 additions and 41 deletions

View 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

View File

@ -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

View File

@ -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

View File

@ -23,4 +23,5 @@ importExport
vault vault
all-config all-config
all-provider-config all-provider-config
update-compatibility update-compatibility
windows-service

View 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>

View File

@ -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.

View File

@ -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);
} }

View File

@ -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";

View File

@ -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";
}

View File

@ -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;
}
}

View File

@ -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);
}
}
}

View File

@ -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>

View File

@ -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", "");
} }
} }

View 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;
}
}
}

View File

@ -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.

View File

@ -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.

View File

@ -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.

View File

@ -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.

View File

@ -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>