diff --git a/.github/workflows/gradle.yml b/.github/workflows/gradle.yml index ee954de0..27a1beb6 100644 --- a/.github/workflows/gradle.yml +++ b/.github/workflows/gradle.yml @@ -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 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/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..8a3b3f95 100644 --- a/functional-test/build.gradle +++ b/functional-test/build.gradle @@ -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//.rd/docker.sock @@ -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) { diff --git a/functional-test/src/test/groovy/functional/MultiNodeAuthSpec.groovy b/functional-test/src/test/groovy/functional/MultiNodeAuthSpec.groovy index a9f4e787..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,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) @@ -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) @@ -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) diff --git a/functional-test/src/test/groovy/functional/base/BaseTestConfiguration.groovy b/functional-test/src/test/groovy/functional/base/BaseTestConfiguration.groovy index 810df540..7a455dcb 100644 --- a/functional-test/src/test/groovy/functional/base/BaseTestConfiguration.groovy +++ b/functional-test/src/test/groovy/functional/base/BaseTestConfiguration.groovy @@ -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 client @@ -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() } } 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..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 @@ -26,25 +29,48 @@ 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()) ) + 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() } } } 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