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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 28 additions & 3 deletions .github/workflows/gradle.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,35 @@ jobs:
run: ./gradlew build
- name: Copy Artifact for integration test
run: ./gradlew :functional-test:copyPluginArtifact
if: github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/')
- name: Run integration Test
run: ./gradlew :functional-test:functionalTest
if: github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/')
run: |
set -eo pipefail
# Non-hidden dirs: upload-artifact often omits dot-directories like .temp/
mkdir -p ci-logs "functional-test/build/ci-reports"
STAMP="$(date -u +%Y%m%dT%H%M%SZ)"
LOG_CI="ci-logs/functionalTest-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}-${STAMP}.log"
LOG_BUILD="functional-test/build/ci-reports/console-${STAMP}.log"
echo "Full Gradle log (two copies): $LOG_CI and $LOG_BUILD"
./gradlew :functional-test:functionalTest --stacktrace --info 2>&1 | tee "$LOG_CI" | tee "$LOG_BUILD"
- name: List functional test log outputs
if: always()
run: |
echo "=== ci-logs ==="
ls -la ci-logs/ 2>/dev/null || echo "(missing)"
echo "=== functional-test/build/ci-reports ==="
ls -la functional-test/build/ci-reports/ 2>/dev/null || echo "(missing)"
- name: Upload functional test reports and logs
if: always()
uses: actions/upload-artifact@v4
with:
name: ansible-functional-test-${{ github.run_id }}-${{ github.run_attempt }}
path: |
ci-logs/
functional-test/build/ci-reports/
functional-test/build/reports/tests/
functional-test/build/test-results/
build/reports/problems/
if-no-files-found: warn
- name: Get Release Version
id: get_version
run: VERSION=$(./gradlew currentVersion -q -Prelease.quiet) && echo "VERSION=$VERSION" >> $GITHUB_OUTPUT
Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,9 @@ crashlytics-build.properties
.gradle
build/

# CI / local full Gradle transcripts (GitHub Actions writes here; avoids hidden .temp upload issues)
ci-logs/

# Ignore Gradle GUI config
gradle-app.setting

Expand Down
4 changes: 2 additions & 2 deletions functional-test/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ This directory contains functional tests for the Rundeck Ansible plugin using Te
## Prerequisites

- Docker (Docker Desktop or Rancher Desktop)
- Java 11 or later
- Gradle 7.2 or later
- Java 17
- Gradle 8

## Docker Configuration

Expand Down
20 changes: 14 additions & 6 deletions functional-test/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -37,12 +37,10 @@ dependencies {
tasks.register('functionalTest', Test) {
useJUnitPlatform()

// Rundeck test image version
// Minimum supported Rundeck version for this plugin: 5.1.1 (see main README/changelog).
// Functional tests intentionally run against a newer version, Rundeck 5.18.0, to validate
// compatibility with current releases. When raising the minimum supported version, update
// this test image tag in tandem.
systemProperty('RUNDECK_TEST_IMAGE', "rundeck/rundeck:5.18.0")
// Rundeck Docker image tag must match `rundeck-core` in ../gradle/libs.versions.toml (single source of truth).
systemProperty('RUNDECK_TEST_IMAGE', "rundeck/rundeck:${libs.versions.rundeck.core.get()}")
// Project archive manifest must match server generation; see TestUtil.createArchiveJarFile
systemProperty('RUNDECK_ARCHIVE_APP_VERSION', libs.versions.rundeck.core.get())

// Docker configuration for Testcontainers
// For Rancher Desktop on macOS: Use /Users/<username>/.rd/docker.sock
Expand All @@ -51,6 +49,16 @@ tasks.register('functionalTest', Test) {
// You can also configure this via ~/.testcontainers.properties (see functional-test/README.md)

description = "Run Ansible integration tests"

// CI / local: surface Spock stderr (e.g. ProjectImportStatus JSON) and full stack traces in console + tee logs
testLogging {
events 'failed', 'passed', 'skipped', 'standard_out', 'standard_error'
exceptionFormat 'full'
showExceptions true
showCauses true
showStackTraces true
showStandardStreams true
}
}

tasks.register('copyPluginArtifact', Copy) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
package functional

import functional.base.BaseTestConfiguration
import functional.util.TestUtil
import org.rundeck.client.api.model.JobRun
import org.testcontainers.spock.Testcontainers

Expand Down Expand Up @@ -36,32 +35,8 @@ class MultiNodeAuthSpec extends BaseTestConfiguration {
// Store private key for node 4 in Rundeck key storage
storePrivateKeyInKeyStorage("ssh-node-4.key", NODE4_PRIVATE_KEY_PATH)

// Create project
def projList = client.apiCall { api -> api.listProjects() }
if (!projList*.name.contains(PROJ_NAME)) {
client.apiCall { api ->
api.createProject(new org.rundeck.client.api.model.ProjectItem(name: PROJ_NAME))
}
}

// Import project configuration
File projectFile = TestUtil.createArchiveJarFile(
PROJ_NAME,
new File("src/test/resources/project-import/$PROJ_NAME")
)
okhttp3.RequestBody body = okhttp3.RequestBody.create(
projectFile,
org.rundeck.client.util.Client.MEDIA_TYPE_ZIP
)
client.apiCall { api ->
api.importProjectArchive(
PROJ_NAME,
"preserve",
true, true, true, true, true, true, true,
[:],
body
)
}
createProjectGrails7IfMissing(PROJ_NAME)
importProjectArchiveFromTestResources(PROJ_NAME)

// Wait for nodes to be available
waitForNodeAvailability(PROJ_NAME, NODE1_NAME)
Expand All @@ -73,8 +48,8 @@ class MultiNodeAuthSpec extends BaseTestConfiguration {
// Helper method to store password in Rundeck key storage
private void storePasswordInKeyStorage(String keyPath, String password) {
okhttp3.RequestBody requestBody = okhttp3.RequestBody.create(
password.getBytes(),
org.rundeck.client.util.Client.MEDIA_TYPE_X_RUNDECK_PASSWORD
org.rundeck.client.util.Client.MEDIA_TYPE_X_RUNDECK_PASSWORD,
password.getBytes()
)
client.apiCall { api ->
api.createKeyStorage("project/$PROJ_NAME/$keyPath", requestBody)
Expand All @@ -85,8 +60,8 @@ class MultiNodeAuthSpec extends BaseTestConfiguration {
private void storePrivateKeyInKeyStorage(String keyPath, String privateKeyFilePath) {
File privateKeyFile = new File(privateKeyFilePath)
okhttp3.RequestBody requestBody = okhttp3.RequestBody.create(
privateKeyFile,
org.rundeck.client.util.Client.MEDIA_TYPE_OCTET_STREAM
org.rundeck.client.util.Client.MEDIA_TYPE_OCTET_STREAM,
privateKeyFile
)
client.apiCall { api ->
api.createKeyStorage("project/$PROJ_NAME/$keyPath", requestBody)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,18 +1,26 @@
package functional.base

import com.fasterxml.jackson.databind.ObjectMapper
import functional.util.TestUtil
import okhttp3.Request
import okhttp3.RequestBody
import org.rundeck.client.api.RequestFailed
import org.rundeck.client.api.RundeckApi
import org.rundeck.client.api.model.ExecLog
import org.rundeck.client.api.model.ExecOutput
import org.rundeck.client.api.model.ExecutionStateResponse
import org.rundeck.client.api.model.ProjectImportStatus
import org.rundeck.client.api.model.ProjectItem
import org.rundeck.client.util.Client
import spock.lang.Shared
import spock.lang.Specification

import java.util.concurrent.TimeUnit

class BaseTestConfiguration extends Specification{

private static final ObjectMapper JSON_MAPPER = new ObjectMapper()

@Shared
Client<RundeckApi> client

Expand Down Expand Up @@ -88,55 +96,137 @@ class BaseTestConfiguration extends Specification{
def configureRundeck(String projectName, String nodeName){

//add private key
RequestBody requestBody = RequestBody.create(new File("src/test/resources/docker/keys/id_rsa"), Client.MEDIA_TYPE_OCTET_STREAM)
// OkHttp 4+: MediaType first (classpath resolves okhttp 4.12 from rd-api-client / retrofit)
RequestBody requestBody = RequestBody.create(Client.MEDIA_TYPE_OCTET_STREAM, new File("src/test/resources/docker/keys/id_rsa"))
def keyResult = client.apiCall {api-> api.createKeyStorage("project/$projectName/ssh-node.key", requestBody)}

//add private key with passphrase
requestBody = RequestBody.create(new File("src/test/resources/docker/keys/id_rsa_passphrase"), Client.MEDIA_TYPE_OCTET_STREAM)
requestBody = RequestBody.create(Client.MEDIA_TYPE_OCTET_STREAM, new File("src/test/resources/docker/keys/id_rsa_passphrase"))
keyResult = client.apiCall {api-> api.createKeyStorage("project/$projectName/ssh-node-passphrase.key", requestBody)}

//add passphrase
requestBody = RequestBody.create(NODE_KEY_PASSPHRASE.getBytes(), Client.MEDIA_TYPE_X_RUNDECK_PASSWORD)
requestBody = RequestBody.create(Client.MEDIA_TYPE_X_RUNDECK_PASSWORD, NODE_KEY_PASSPHRASE.getBytes())
keyResult = client.apiCall {api-> api.createKeyStorage("project/$projectName/ssh-node-passphrase.pass", requestBody)}

//add node user ssh-password
requestBody = RequestBody.create(NODE_USER_PASSWORD.getBytes(), Client.MEDIA_TYPE_X_RUNDECK_PASSWORD)
requestBody = RequestBody.create(Client.MEDIA_TYPE_X_RUNDECK_PASSWORD, NODE_USER_PASSWORD.getBytes())
keyResult = client.apiCall {api-> api.createKeyStorage("project/$projectName/ssh-node.pass", requestBody)}

//user vault password
requestBody = RequestBody.create(USER_VAULT_PASSWORD.getBytes(), Client.MEDIA_TYPE_X_RUNDECK_PASSWORD)
requestBody = RequestBody.create(Client.MEDIA_TYPE_X_RUNDECK_PASSWORD, USER_VAULT_PASSWORD.getBytes())
keyResult = client.apiCall {api-> api.createKeyStorage("project/$projectName/vault-user.pass", requestBody)}

//add encrypted inventory password
requestBody = RequestBody.create(ENCRYPTED_INVENTORY_VAULT_PASSWORD.getBytes(), Client.MEDIA_TYPE_X_RUNDECK_PASSWORD)
requestBody = RequestBody.create(Client.MEDIA_TYPE_X_RUNDECK_PASSWORD, ENCRYPTED_INVENTORY_VAULT_PASSWORD.getBytes())
keyResult = client.apiCall {api-> api.createKeyStorage("project/$projectName/vault-inventory.password", requestBody)}

//create project
def projList = client.apiCall(api -> api.listProjects())
createProjectGrails7IfMissing(projectName)
importProjectArchiveFromTestResources(projectName)

waitForNodeAvailability(projectName, nodeName)

}

/**
* Grails 7: POST /projects expects top-level {@code name} and {@code config["project.name"]}
* (rundeck OSS {@code BaseContainer.setupProjectArchiveFile}).
*/
protected void createProjectGrails7IfMissing(String projectName) {
def projList = client.apiCall { api -> api.listProjects() }
if (!projList*.name.contains(projectName)) {
def project = client.apiCall(api -> api.createProject(new ProjectItem(name: projectName)))
def item = new ProjectItem()
item.name = projectName
item.config = [('project.name'): projectName]
client.apiCall { api -> api.createProject(item) }
}
}

//import project
File projectFile = TestUtil.createArchiveJarFile(projectName, new File("src/test/resources/project-import/" + projectName))
RequestBody body = RequestBody.create(projectFile, Client.MEDIA_TYPE_ZIP)
client.apiCall(api ->
api.importProjectArchive(projectName, "preserve", true, true, true, true, true, true, true, [:], body)
)
/**
* Import {@code src/test/resources/project-import/{projectName}} and fail with stderr diagnostics if the server
* reports failure. HTTP errors still throw {@link org.rundeck.client.api.RequestFailed} from {@code apiCall}.
*/
protected void importProjectArchiveFromTestResources(String projectName) {
File projectDir = new File("src/test/resources/project-import/${projectName}")
File projectFile = TestUtil.createArchiveJarFile(projectName, projectDir)
RequestBody body = RequestBody.create(Client.MEDIA_TYPE_ZIP, projectFile)
ProjectImportStatus importStatus
try {
importStatus = client.apiCall { api ->
api.importProjectArchive(projectName, "preserve",
true, true, true, true, true, true, true, true,
body)
}
} catch (RequestFailed rf) {
System.err.println(
"Project import HTTP/API error for '${projectName}': HTTP ${rf.statusCode} ${rf.message} status=${rf.status}")
dumpRundeckLogsAfterImportFailure(projectName)
throw rf
}
assertProjectImportSucceeded(projectName, importStatus)
}

waitForNodeAvailability(projectName, nodeName)
protected void assertProjectImportSucceeded(String projectName, ProjectImportStatus importStatus) {
if (importStatus != null && importStatus.getResultSuccess()) {
return
}
String parsedJson
try {
parsedJson = importStatus == null ? 'null'
: JSON_MAPPER.writerWithDefaultPrettyPrinter().writeValueAsString(importStatus)
} catch (Exception e) {
parsedJson = "Could not serialize ProjectImportStatus: ${e.message}; toString=${String.valueOf(importStatus)}"
}
String detail = "Project import did not succeed for '${projectName}'. " +
"Parsed ProjectImportStatus from API (rd-api-client model as JSON):\n${parsedJson}"
System.err.println(detail)
dumpRundeckLogsAfterImportFailure(projectName)
throw new IllegalStateException(detail)
}

private void dumpRundeckLogsAfterImportFailure(String projectName) {
if (rundeckEnvironment == null) {
return
}
rundeckEnvironment.appendRundeckContainerLogsToStdErr(
"--- Rundeck container logs (project '${projectName}' import failure) ---")
}

def waitForNodeAvailability(String projectName, String nodeName){
def result = client.apiCall {api-> api.listNodes(projectName,".*")}
def count =0
/**
* Nudge resource providers (Ansible inventory, etc.) like OSS {@code BaseContainer.waitingResourceEnabled}:
* PUT project/{project}/config/time then poll until the expected node appears.
*/
def waitForNodeAvailability(String projectName, String nodeName) {
touchProjectConfigTime(projectName)
final long deadlineNanos = System.nanoTime() + TimeUnit.MINUTES.toNanos(3)
def result = client.apiCall { api -> api.listNodes(projectName, ".*") }

while (result.get(nodeName) == null && System.nanoTime() < deadlineNanos) {
touchProjectConfigTime(projectName)
sleep(3000)
result = client.apiCall { api -> api.listNodes(projectName, ".*") }
}
}

while(result.get(nodeName)==null && count<5){
sleep(2000)
result = client.apiCall {api-> api.listNodes(projectName,".*")}
count++
private void touchProjectConfigTime(String projectName) {
String base = client.getApiBaseUrl()
String url = (base.endsWith('/') ? base : base + '/') + "project/${projectName}/config/time"
String json = JSON_MAPPER.writeValueAsString([value: String.valueOf(System.currentTimeMillis())])
RequestBody jsonBody = RequestBody.create(Client.MEDIA_TYPE_JSON, json)
Request httpReq = new Request.Builder()
.url(url)
.put(jsonBody)
.header('Accept', 'application/json')
.header('Content-Type', 'application/json')
.build()
def call = client.getRetrofit().callFactory().newCall(httpReq)
def resp = call.execute()
try {
if (!resp.successful) {
String err = resp.body()?.string() ?: ''
throw new IllegalStateException("PUT project config/time failed: HTTP ${resp.code} ${err}")
}
} finally {
resp.close()
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,30 @@ class RundeckCompose extends DockerComposeContainer<RundeckCompose> {
return client
}

/**
* Dump recent Rundeck service logs to stderr (e.g. after failed project import). Only works while compose is up.
*/
void appendRundeckContainerLogsToStdErr(String header, int maxChars = 120_000) {
try {
def opt = getContainerByServiceName("rundeck")
if (!opt.isPresent()) {
System.err.println("${header}\n(no rundeck service container in compose state)")
return
}
String logs = opt.get().getLogs()
if (logs == null) {
System.err.println("${header}\n(logs null)")
return
}
if (logs.length() > maxChars) {
int start = logs.length() - maxChars
logs = "(truncated to last ${maxChars} chars of ${logs.length()})\n" + logs.substring(start)
}
System.err.println("${header}\n${logs}")
} catch (Exception e) {
System.err.println("${header}\n(could not read container logs: ${e.class.name}: ${e.message})")
}
}

static class TestLogger implements Client.Logger {
@Override
Expand Down
Loading
Loading