From de0a5af061f716b6e5b89f59c304fefaf10949d6 Mon Sep 17 00:00:00 2001 From: Rundeck CI Date: Mon, 6 Apr 2026 21:22:21 -0700 Subject: [PATCH 01/10] Update libs.versions.toml --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 2d751a3b..bac5a76d 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -18,7 +18,7 @@ bytebuddy = "1.14.11" objenesis = "3.4" # Rundeck version -rundeck-core = "6.0.0-SNAPSHOT" +rundeck-core = "6.0.0-alpha1-20260407" [libraries] # Security overrides From 09379041c4509fbd792184b80d4f1913966bf88a Mon Sep 17 00:00:00 2001 From: Rundeck CI Date: Mon, 6 Apr 2026 21:31:07 -0700 Subject: [PATCH 02/10] Re-enable funcational tests in CI/CD on grails7 branch --- .github/workflows/gradle.yml | 2 -- functional-test/README.md | 4 ++-- functional-test/build.gradle | 8 ++------ 3 files changed, 4 insertions(+), 10 deletions(-) diff --git a/.github/workflows/gradle.yml b/.github/workflows/gradle.yml index ee954de0..e888e66e 100644 --- a/.github/workflows/gradle.yml +++ b/.github/workflows/gradle.yml @@ -25,10 +25,8 @@ 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/') - name: Get Release Version id: get_version run: VERSION=$(./gradlew currentVersion -q -Prelease.quiet) && echo "VERSION=$VERSION" >> $GITHUB_OUTPUT diff --git a/functional-test/README.md b/functional-test/README.md index bedac1f1..c21022e9 100644 --- a/functional-test/README.md +++ b/functional-test/README.md @@ -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 diff --git a/functional-test/build.gradle b/functional-test/build.gradle index e3a18460..27d6b07a 100644 --- a/functional-test/build.gradle +++ b/functional-test/build.gradle @@ -37,12 +37,8 @@ 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()}") // Docker configuration for Testcontainers // For Rancher Desktop on macOS: Use /Users//.rd/docker.sock From e4423c85579878a9fa87526fdd9651aef47886d7 Mon Sep 17 00:00:00 2001 From: Rundeck CI Date: Mon, 6 Apr 2026 21:41:39 -0700 Subject: [PATCH 03/10] Fix CI/CD Test fails --- .../groovy/functional/MultiNodeAuthSpec.groovy | 12 ++++++------ .../functional/base/BaseTestConfiguration.groovy | 15 ++++++++------- 2 files changed, 14 insertions(+), 13 deletions(-) diff --git a/functional-test/src/test/groovy/functional/MultiNodeAuthSpec.groovy b/functional-test/src/test/groovy/functional/MultiNodeAuthSpec.groovy index a9f4e787..8250616c 100644 --- a/functional-test/src/test/groovy/functional/MultiNodeAuthSpec.groovy +++ b/functional-test/src/test/groovy/functional/MultiNodeAuthSpec.groovy @@ -50,8 +50,8 @@ class MultiNodeAuthSpec extends BaseTestConfiguration { new File("src/test/resources/project-import/$PROJ_NAME") ) okhttp3.RequestBody body = okhttp3.RequestBody.create( - projectFile, - org.rundeck.client.util.Client.MEDIA_TYPE_ZIP + org.rundeck.client.util.Client.MEDIA_TYPE_ZIP, + projectFile ) client.apiCall { api -> api.importProjectArchive( @@ -73,8 +73,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) @@ -85,8 +85,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) diff --git a/functional-test/src/test/groovy/functional/base/BaseTestConfiguration.groovy b/functional-test/src/test/groovy/functional/base/BaseTestConfiguration.groovy index 810df540..b6ab418e 100644 --- a/functional-test/src/test/groovy/functional/base/BaseTestConfiguration.groovy +++ b/functional-test/src/test/groovy/functional/base/BaseTestConfiguration.groovy @@ -88,27 +88,28 @@ 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 @@ -120,7 +121,7 @@ class BaseTestConfiguration extends Specification{ //import project File projectFile = TestUtil.createArchiveJarFile(projectName, new File("src/test/resources/project-import/" + projectName)) - RequestBody body = RequestBody.create(projectFile, Client.MEDIA_TYPE_ZIP) + RequestBody body = RequestBody.create(Client.MEDIA_TYPE_ZIP, projectFile) client.apiCall(api -> api.importProjectArchive(projectName, "preserve", true, true, true, true, true, true, true, [:], body) ) From 12c619e033e40b63c6b6d0a08f7be70e5f080799 Mon Sep 17 00:00:00 2001 From: Rundeck CI Date: Mon, 6 Apr 2026 21:47:31 -0700 Subject: [PATCH 04/10] More Test Fixes --- .../groovy/functional/MultiNodeAuthSpec.groovy | 3 +-- .../functional/base/BaseTestConfiguration.groovy | 14 ++++++++------ 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/functional-test/src/test/groovy/functional/MultiNodeAuthSpec.groovy b/functional-test/src/test/groovy/functional/MultiNodeAuthSpec.groovy index 8250616c..e5ce0c3d 100644 --- a/functional-test/src/test/groovy/functional/MultiNodeAuthSpec.groovy +++ b/functional-test/src/test/groovy/functional/MultiNodeAuthSpec.groovy @@ -57,8 +57,7 @@ class MultiNodeAuthSpec extends BaseTestConfiguration { api.importProjectArchive( PROJ_NAME, "preserve", - true, true, true, true, true, true, true, - [:], + true, true, true, true, true, true, true, true, body ) } diff --git a/functional-test/src/test/groovy/functional/base/BaseTestConfiguration.groovy b/functional-test/src/test/groovy/functional/base/BaseTestConfiguration.groovy index b6ab418e..4f935721 100644 --- a/functional-test/src/test/groovy/functional/base/BaseTestConfiguration.groovy +++ b/functional-test/src/test/groovy/functional/base/BaseTestConfiguration.groovy @@ -113,18 +113,20 @@ class BaseTestConfiguration extends Specification{ keyResult = client.apiCall {api-> api.createKeyStorage("project/$projectName/vault-inventory.password", requestBody)} //create project - def projList = client.apiCall(api -> api.listProjects()) + def projList = client.apiCall { api -> api.listProjects() } if (!projList*.name.contains(projectName)) { - def project = client.apiCall(api -> api.createProject(new ProjectItem(name: projectName))) + client.apiCall { api -> api.createProject(new ProjectItem(name: projectName)) } } - //import project + //import project — rd-api-client: 8 boolean flags then RequestBody (no Map); see RundeckApi.importProjectArchive File projectFile = TestUtil.createArchiveJarFile(projectName, new File("src/test/resources/project-import/" + projectName)) RequestBody body = RequestBody.create(Client.MEDIA_TYPE_ZIP, projectFile) - client.apiCall(api -> - api.importProjectArchive(projectName, "preserve", true, true, true, true, true, true, true, [:], body) - ) + client.apiCall { api -> + api.importProjectArchive(projectName, "preserve", + true, true, true, true, true, true, true, true, + body) + } waitForNodeAvailability(projectName, nodeName) From 02cc874fd7c151740b7c77074b6be86c5e30e207 Mon Sep 17 00:00:00 2001 From: Rundeck CI Date: Mon, 6 Apr 2026 21:59:18 -0700 Subject: [PATCH 05/10] more CI/CD details --- .github/workflows/gradle.yml | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/.github/workflows/gradle.yml b/.github/workflows/gradle.yml index e888e66e..5571acb0 100644 --- a/.github/workflows/gradle.yml +++ b/.github/workflows/gradle.yml @@ -26,7 +26,23 @@ jobs: - name: Copy Artifact for integration test run: ./gradlew :functional-test:copyPluginArtifact - name: Run integration Test - run: ./gradlew :functional-test:functionalTest + run: | + set -eo pipefail + mkdir -p .temp + LOG=".temp/functionalTest-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}-$(date -u +%Y%m%dT%H%M%SZ).log" + echo "Full Gradle log: $LOG" + ./gradlew :functional-test:functionalTest --stacktrace 2>&1 | tee "$LOG" + - 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: | + .temp/*.log + 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 From ceec02d56cb71faa6dc2ea6ec9f6a10c830ee12e Mon Sep 17 00:00:00 2001 From: Rundeck CI Date: Mon, 6 Apr 2026 22:14:44 -0700 Subject: [PATCH 06/10] Update BaseTestConfiguration.groovy --- .../base/BaseTestConfiguration.groovy | 65 +++++++++++++++---- 1 file changed, 54 insertions(+), 11 deletions(-) diff --git a/functional-test/src/test/groovy/functional/base/BaseTestConfiguration.groovy b/functional-test/src/test/groovy/functional/base/BaseTestConfiguration.groovy index 4f935721..51bdbee7 100644 --- a/functional-test/src/test/groovy/functional/base/BaseTestConfiguration.groovy +++ b/functional-test/src/test/groovy/functional/base/BaseTestConfiguration.groovy @@ -1,18 +1,25 @@ package functional.base +import com.fasterxml.jackson.databind.ObjectMapper import functional.util.TestUtil +import okhttp3.Request import okhttp3.RequestBody 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 client @@ -112,34 +119,70 @@ class BaseTestConfiguration extends Specification{ 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 + // create project — Grails 7 expects top-level name plus config.project.name (see rundeck OSS BaseContainer.setupProjectArchiveFile) def projList = client.apiCall { api -> api.listProjects() } if (!projList*.name.contains(projectName)) { - 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 — rd-api-client: 8 boolean flags then RequestBody (no Map); see RundeckApi.importProjectArchive + // import project — rd-api-client: PUT project/{project}/import; jobUuidOption preserve; see RundeckApi.importProjectArchive File projectFile = TestUtil.createArchiveJarFile(projectName, new File("src/test/resources/project-import/" + projectName)) RequestBody body = RequestBody.create(Client.MEDIA_TYPE_ZIP, projectFile) - client.apiCall { api -> + ProjectImportStatus importStatus = client.apiCall { api -> api.importProjectArchive(projectName, "preserve", true, true, true, true, true, true, true, true, body) } + if (!importStatus.getResultSuccess()) { + throw new IllegalStateException( + "Project import failed for '${projectName}': importStatus=${importStatus.importStatus}, successful=${importStatus.successful}, " + + "errors=${importStatus.errors}, executionErrors=${importStatus.executionErrors}, aclErrors=${importStatus.aclErrors}") + } waitForNodeAvailability(projectName, nodeName) } - 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() } } From b13151ece419c3229e53f2262c847035c8787257 Mon Sep 17 00:00:00 2001 From: Rundeck CI Date: Mon, 6 Apr 2026 22:42:48 -0700 Subject: [PATCH 07/10] Richer Import Diag / Shared Setup Helpers / spec alignment --- .../functional/MultiNodeAuthSpec.groovy | 28 +------- .../base/BaseTestConfiguration.groovy | 64 ++++++++++++++----- 2 files changed, 51 insertions(+), 41 deletions(-) diff --git a/functional-test/src/test/groovy/functional/MultiNodeAuthSpec.groovy b/functional-test/src/test/groovy/functional/MultiNodeAuthSpec.groovy index e5ce0c3d..694b15cb 100644 --- a/functional-test/src/test/groovy/functional/MultiNodeAuthSpec.groovy +++ b/functional-test/src/test/groovy/functional/MultiNodeAuthSpec.groovy @@ -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 @@ -36,31 +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( - org.rundeck.client.util.Client.MEDIA_TYPE_ZIP, - projectFile - ) - client.apiCall { api -> - api.importProjectArchive( - PROJ_NAME, - "preserve", - true, 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) diff --git a/functional-test/src/test/groovy/functional/base/BaseTestConfiguration.groovy b/functional-test/src/test/groovy/functional/base/BaseTestConfiguration.groovy index 51bdbee7..c813a531 100644 --- a/functional-test/src/test/groovy/functional/base/BaseTestConfiguration.groovy +++ b/functional-test/src/test/groovy/functional/base/BaseTestConfiguration.groovy @@ -4,6 +4,7 @@ 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 @@ -119,32 +120,65 @@ class BaseTestConfiguration extends Specification{ 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 — Grails 7 expects top-level name plus config.project.name (see rundeck OSS BaseContainer.setupProjectArchiveFile) - 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 item = new ProjectItem() item.name = projectName item.config = [('project.name'): projectName] client.apiCall { api -> api.createProject(item) } } + } - // import project — rd-api-client: PUT project/{project}/import; jobUuidOption preserve; see RundeckApi.importProjectArchive - File projectFile = TestUtil.createArchiveJarFile(projectName, new File("src/test/resources/project-import/" + projectName)) + /** + * 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 = client.apiCall { api -> - api.importProjectArchive(projectName, "preserve", - true, true, true, true, true, true, true, true, - body) - } - if (!importStatus.getResultSuccess()) { - throw new IllegalStateException( - "Project import failed for '${projectName}': importStatus=${importStatus.importStatus}, successful=${importStatus.successful}, " + - "errors=${importStatus.errors}, executionErrors=${importStatus.executionErrors}, aclErrors=${importStatus.aclErrors}") + 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}") + 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) + throw new IllegalStateException(detail) } /** From cfac41aebfa64775f5a91dd68f09f851c8616c08 Mon Sep 17 00:00:00 2001 From: Rundeck CI Date: Tue, 7 Apr 2026 06:57:46 -0700 Subject: [PATCH 08/10] Still trying to get logs --- .github/workflows/gradle.yml | 21 ++++++++++++++++----- .gitignore | 3 +++ functional-test/build.gradle | 10 ++++++++++ 3 files changed, 29 insertions(+), 5 deletions(-) diff --git a/.github/workflows/gradle.yml b/.github/workflows/gradle.yml index 5571acb0..27a1beb6 100644 --- a/.github/workflows/gradle.yml +++ b/.github/workflows/gradle.yml @@ -28,17 +28,28 @@ jobs: - name: Run integration Test run: | set -eo pipefail - mkdir -p .temp - LOG=".temp/functionalTest-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}-$(date -u +%Y%m%dT%H%M%SZ).log" - echo "Full Gradle log: $LOG" - ./gradlew :functional-test:functionalTest --stacktrace 2>&1 | tee "$LOG" + # 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: | - .temp/*.log + ci-logs/ + functional-test/build/ci-reports/ functional-test/build/reports/tests/ functional-test/build/test-results/ build/reports/problems/ diff --git a/.gitignore b/.gitignore index ec781358..6c3514a0 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/functional-test/build.gradle b/functional-test/build.gradle index 27d6b07a..5aba088e 100644 --- a/functional-test/build.gradle +++ b/functional-test/build.gradle @@ -47,6 +47,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) { From 570e01ac301057ca0614438a90b5f880e4eb8700 Mon Sep 17 00:00:00 2001 From: Rundeck CI Date: Tue, 7 Apr 2026 08:55:04 -0700 Subject: [PATCH 09/10] Additional logging and fix App Version number --- functional-test/build.gradle | 2 ++ .../base/BaseTestConfiguration.groovy | 10 ++++++++ .../functional/base/RundeckCompose.groovy | 24 +++++++++++++++++++ .../groovy/functional/util/TestUtil.groovy | 5 +++- 4 files changed, 40 insertions(+), 1 deletion(-) diff --git a/functional-test/build.gradle b/functional-test/build.gradle index 5aba088e..8a3b3f95 100644 --- a/functional-test/build.gradle +++ b/functional-test/build.gradle @@ -39,6 +39,8 @@ tasks.register('functionalTest', Test) { // 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//.rd/docker.sock diff --git a/functional-test/src/test/groovy/functional/base/BaseTestConfiguration.groovy b/functional-test/src/test/groovy/functional/base/BaseTestConfiguration.groovy index c813a531..7a455dcb 100644 --- a/functional-test/src/test/groovy/functional/base/BaseTestConfiguration.groovy +++ b/functional-test/src/test/groovy/functional/base/BaseTestConfiguration.groovy @@ -159,6 +159,7 @@ class BaseTestConfiguration extends Specification{ } 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) @@ -178,9 +179,18 @@ class BaseTestConfiguration extends Specification{ 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) ---") + } + /** * Nudge resource providers (Ansible inventory, etc.) like OSS {@code BaseContainer.waitingResourceEnabled}: * PUT project/{project}/config/time then poll until the expected node appears. diff --git a/functional-test/src/test/groovy/functional/base/RundeckCompose.groovy b/functional-test/src/test/groovy/functional/base/RundeckCompose.groovy index f56fb10c..9956d55b 100644 --- a/functional-test/src/test/groovy/functional/base/RundeckCompose.groovy +++ b/functional-test/src/test/groovy/functional/base/RundeckCompose.groovy @@ -40,6 +40,30 @@ class RundeckCompose extends DockerComposeContainer { 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 diff --git a/functional-test/src/test/groovy/functional/util/TestUtil.groovy b/functional-test/src/test/groovy/functional/util/TestUtil.groovy index 44d5354d..2e95602e 100644 --- a/functional-test/src/test/groovy/functional/util/TestUtil.groovy +++ b/functional-test/src/test/groovy/functional/util/TestUtil.groovy @@ -26,7 +26,10 @@ class TestUtil { manifest.mainAttributes.putValue("Manifest-Version", "1.0") manifest.mainAttributes.putValue("Rundeck-Archive-Project-Name", name) manifest.mainAttributes.putValue("Rundeck-Archive-Format-Version", "1.0") - manifest.mainAttributes.putValue("Rundeck-Application-Version", "5.0.0") + // Must align with the Rundeck server under test; Rundeck 6 can reject archives stamped as 5.x (functionalTest sets RUNDECK_ARCHIVE_APP_VERSION). + manifest.mainAttributes.putValue( + "Rundeck-Application-Version", + System.getProperty("RUNDECK_ARCHIVE_APP_VERSION", "6.0.0")) manifest.mainAttributes.putValue( "Rundeck-Archive-Export-Date", new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssX").format(new Date()) From 2428a8474892b91f68d00451d731a1f40376015e Mon Sep 17 00:00:00 2001 From: Rundeck CI Date: Wed, 8 Apr 2026 07:17:37 -0700 Subject: [PATCH 10/10] Fix project archive directory entries for Rundeck 6 async import Rundeck 6's AsyncImportService expects explicit directory entries (with trailing /) in project archives. Without them, directories like "jobs" extract as zero-byte files, causing "Not a directory" errors during import. Changes: - Collect all parent directories from file paths - Create JarEntry objects with trailing / for each directory - Write directory entries before file entries, ordered shallow-to-deep - Normalize paths to use forward slashes This ensures archives are compatible with both Rundeck 5 and 6 import mechanisms. --- .../groovy/functional/util/TestUtil.groovy | 47 ++++++++++++++----- 1 file changed, 35 insertions(+), 12 deletions(-) diff --git a/functional-test/src/test/groovy/functional/util/TestUtil.groovy b/functional-test/src/test/groovy/functional/util/TestUtil.groovy index 2e95602e..06284911 100644 --- a/functional-test/src/test/groovy/functional/util/TestUtil.groovy +++ b/functional-test/src/test/groovy/functional/util/TestUtil.groovy @@ -4,7 +4,10 @@ import com.jcraft.jsch.JSch import com.jcraft.jsch.KeyPair import org.rundeck.client.api.model.ExecLog +import groovy.io.FileType + import java.nio.file.Files +import java.nio.file.Path import java.nio.file.attribute.PosixFilePermission import java.text.SimpleDateFormat import java.util.jar.JarEntry @@ -35,19 +38,39 @@ class TestUtil { new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssX").format(new Date()) ) + Path base = projectArchiveDirectory.toPath() + List filesOnly = [] + projectArchiveDirectory.eachFileRecurse(FileType.FILES) { f -> filesOnly.add(f) } + + // Rundeck 6 async import expects directory entries with trailing /. Otherwise "jobs" can extract as a + // zero-byte file and AsyncImportService fails with "Not a directory". + Set directoryEntries = new LinkedHashSet<>() + for (File f : filesOnly) { + String rel = base.relativize(f.toPath()).toString().replace('\\', '/') + String[] parts = rel.split('/') + for (int i = 0; i < parts.length - 1; i++) { + directoryEntries.add((parts[0..i].join('/') + '/') as String) + } + } + List dirsOrdered = new ArrayList<>(directoryEntries) + dirsOrdered.sort { a, b -> + int da = a.count('/') + int db = b.count('/') + da != db ? da <=> db : a <=> b + } + tempFile.withOutputStream { os -> - def jos = new JarOutputStream(os, manifest) - - jos.withCloseable { jarOutputStream -> - - projectArchiveDirectory.eachFileRecurse { file -> - def entry = new JarEntry(projectArchiveDirectory.toPath().relativize(file.toPath()).toString()) - jarOutputStream.putNextEntry(entry) - if (file.isFile()) { - file.withInputStream { is -> - jarOutputStream << is - } - } + JarOutputStream jos = new JarOutputStream(os, manifest) + jos.withCloseable { JarOutputStream jarOutputStream -> + for (String dir : dirsOrdered) { + jarOutputStream.putNextEntry(new JarEntry(dir)) + jarOutputStream.closeEntry() + } + for (File file : filesOnly) { + String entryName = base.relativize(file.toPath()).toString().replace('\\', '/') + jarOutputStream.putNextEntry(new JarEntry(entryName)) + file.withInputStream { is -> jarOutputStream << is } + jarOutputStream.closeEntry() } } }