|
| 1 | +/* |
| 2 | + * Licensed to the Apache Software Foundation (ASF) under one |
| 3 | + * or more contributor license agreements. See the NOTICE file |
| 4 | + * distributed with this work for additional information |
| 5 | + * regarding copyright ownership. The ASF licenses this file |
| 6 | + * to you under the Apache License, Version 2.0 (the |
| 7 | + * "License"); you may not use this file except in compliance |
| 8 | + * with the License. You may obtain a copy of the License at |
| 9 | + * |
| 10 | + * http://www.apache.org/licenses/LICENSE-2.0 |
| 11 | + * |
| 12 | + * Unless required by applicable law or agreed to in writing, |
| 13 | + * software distributed under the License is distributed on an |
| 14 | + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY |
| 15 | + * KIND, either express or implied. See the License for the |
| 16 | + * specific language governing permissions and limitations |
| 17 | + * under the License. |
| 18 | + */ |
| 19 | + |
| 20 | +package org.apache.airflow.sdk.plugin |
| 21 | + |
| 22 | +import groovy.json.JsonOutput |
| 23 | +import groovy.json.JsonSlurper |
| 24 | +import org.gradle.api.DefaultTask |
| 25 | +import org.gradle.api.file.ConfigurableFileCollection |
| 26 | +import org.gradle.api.file.DirectoryProperty |
| 27 | +import org.gradle.api.file.RegularFileProperty |
| 28 | +import org.gradle.api.provider.Property |
| 29 | +import org.gradle.api.tasks.CacheableTask |
| 30 | +import org.gradle.api.tasks.Classpath |
| 31 | +import org.gradle.api.tasks.Input |
| 32 | +import org.gradle.api.tasks.InputFiles |
| 33 | +import org.gradle.api.tasks.Nested |
| 34 | +import org.gradle.api.tasks.Optional |
| 35 | +import org.gradle.api.tasks.OutputDirectory |
| 36 | +import org.gradle.api.tasks.OutputFile |
| 37 | +import org.gradle.api.tasks.PathSensitive |
| 38 | +import org.gradle.api.tasks.PathSensitivity |
| 39 | +import org.gradle.api.tasks.TaskAction |
| 40 | +import org.gradle.jvm.toolchain.JavaLauncher |
| 41 | +import org.gradle.process.ExecOperations |
| 42 | +import java.io.ByteArrayOutputStream |
| 43 | +import java.io.File |
| 44 | +import javax.inject.Inject |
| 45 | + |
| 46 | +internal const val SOURCES_MANIFEST_ATTRIBUTE = "Airflow-Java-SDK-Sources" |
| 47 | +internal const val SOURCES_JSON_PATH = "META-INF/airflow/sources.json" |
| 48 | +internal const val SOURCES_DIR_PATH = "META-INF/airflow/sources" |
| 49 | + |
| 50 | +/** |
| 51 | + * Finds the source file of a class by its `SourceFile` attribute: the class |
| 52 | + * file's package path plus that name, looked up in each source directory. |
| 53 | + */ |
| 54 | +internal class SourceLocator( |
| 55 | + private val classesDirs: Iterable<File>, |
| 56 | + private val sourceDirs: Iterable<File>, |
| 57 | +) { |
| 58 | + /** Path of the source file relative to its source directory, or `null` if it cannot be found. */ |
| 59 | + fun locate(className: String): String? { |
| 60 | + val packagePath = className.substringBeforeLast('.', "").replace('.', '/') |
| 61 | + val classFile = |
| 62 | + classesDirs |
| 63 | + .map { File(it, className.replace('.', '/') + ".class") } |
| 64 | + .firstOrNull { it.isFile } ?: return null |
| 65 | + val sourceFile = readSourceFile(classFile.readBytes())?.takeIf { it.isNotEmpty() && '/' !in it && '\\' !in it } |
| 66 | + val relative = listOfNotNull(packagePath.ifEmpty { null }, sourceFile ?: return null).joinToString("/") |
| 67 | + return relative.takeIf { path -> sourceDirs.any { File(it, path).isFile } } |
| 68 | + } |
| 69 | + |
| 70 | + fun file(relativePath: String): File = sourceDirs.map { File(it, relativePath) }.first { it.isFile } |
| 71 | +} |
| 72 | + |
| 73 | +internal fun sourcesJson( |
| 74 | + entrypointPath: String?, |
| 75 | + dagSourcePaths: Map<String, String>, |
| 76 | +): String = |
| 77 | + JsonOutput.prettyPrint( |
| 78 | + JsonOutput.toJson( |
| 79 | + linkedMapOf<String, Any>().apply { |
| 80 | + entrypointPath?.let { put("entrypoint_path", it) } |
| 81 | + put("dag_source_paths", dagSourcePaths) |
| 82 | + }, |
| 83 | + ), |
| 84 | + ) |
| 85 | + |
| 86 | +/** |
| 87 | + * Runs the bundle's `mainClass` in describe mode to learn which class declared |
| 88 | + * each Dag, then collects those classes' source files, and the entry point's, |
| 89 | + * for the bundle JAR to carry. |
| 90 | + */ |
| 91 | +@CacheableTask |
| 92 | +abstract class PackDagSources : DefaultTask() { |
| 93 | + @get:Inject |
| 94 | + abstract val execOperations: ExecOperations |
| 95 | + |
| 96 | + @get:Input |
| 97 | + @get:Optional |
| 98 | + abstract val mainClass: Property<String> |
| 99 | + |
| 100 | + @get:Classpath |
| 101 | + abstract val classesDirs: ConfigurableFileCollection |
| 102 | + |
| 103 | + @get:Classpath |
| 104 | + abstract val runtimeClasspath: ConfigurableFileCollection |
| 105 | + |
| 106 | + @get:InputFiles |
| 107 | + @get:PathSensitive(PathSensitivity.RELATIVE) |
| 108 | + abstract val sourceDirs: ConfigurableFileCollection |
| 109 | + |
| 110 | + @get:Nested |
| 111 | + @get:Optional |
| 112 | + abstract val launcher: Property<JavaLauncher> |
| 113 | + |
| 114 | + @get:OutputFile |
| 115 | + abstract val describeFile: RegularFileProperty |
| 116 | + |
| 117 | + @get:OutputDirectory |
| 118 | + abstract val sourcesDir: DirectoryProperty |
| 119 | + |
| 120 | + private var describeFailed = false |
| 121 | + |
| 122 | + init { |
| 123 | + outputs.doNotCacheIf("the Dag describe run failed") { describeFailed } |
| 124 | + } |
| 125 | + |
| 126 | + @TaskAction |
| 127 | + fun pack() { |
| 128 | + val describe = describeFile.get().asFile |
| 129 | + val root = sourcesDir.get().asFile |
| 130 | + describe.delete() |
| 131 | + root.deleteRecursively() |
| 132 | + describe.parentFile.mkdirs() |
| 133 | + |
| 134 | + val main = mainClass.get() |
| 135 | + val declaringClasses = describeDags(main, describe) |
| 136 | + val locator = SourceLocator(classesDirs.files, sourceDirs.files) |
| 137 | + |
| 138 | + val entrypoint = locator.locate(main) |
| 139 | + val dagPaths = linkedMapOf<String, String>() |
| 140 | + declaringClasses.forEach { (dagId, className) -> |
| 141 | + val path = locator.locate(className) |
| 142 | + if (path == null) { |
| 143 | + logger.info("No source file found for class {} of Dag '{}'; it falls back to the entrypoint", className, dagId) |
| 144 | + } else { |
| 145 | + dagPaths[dagId] = path |
| 146 | + } |
| 147 | + } |
| 148 | + |
| 149 | + (listOfNotNull(entrypoint) + dagPaths.values).distinct().forEach { path -> |
| 150 | + val target = File(root, "$SOURCES_DIR_PATH/$path") |
| 151 | + target.parentFile.mkdirs() |
| 152 | + locator.file(path).copyTo(target) |
| 153 | + } |
| 154 | + File(root, SOURCES_JSON_PATH).apply { |
| 155 | + parentFile.mkdirs() |
| 156 | + writeText(sourcesJson(entrypoint, dagPaths) + "\n") |
| 157 | + } |
| 158 | + } |
| 159 | + |
| 160 | + private fun describeDags( |
| 161 | + main: String, |
| 162 | + describe: File, |
| 163 | + ): Map<String, String> { |
| 164 | + val output = ByteArrayOutputStream() |
| 165 | + val failure = |
| 166 | + try { |
| 167 | + val result = |
| 168 | + execOperations.javaexec { spec -> |
| 169 | + launcher.orNull?.let { spec.executable(it.executablePath.asFile.absolutePath) } |
| 170 | + spec.classpath(classesDirs, runtimeClasspath) |
| 171 | + spec.mainClass.set(main) |
| 172 | + spec.args("--describe-sources", describe.absolutePath) |
| 173 | + spec.isIgnoreExitValue = true |
| 174 | + spec.standardOutput = output |
| 175 | + spec.errorOutput = output |
| 176 | + } |
| 177 | + if (result.exitValue != 0) "exit code ${result.exitValue}" else null |
| 178 | + } catch (e: Exception) { |
| 179 | + e.message ?: e.javaClass.simpleName |
| 180 | + } |
| 181 | + if (failure == null && describe.isFile) { |
| 182 | + @Suppress("UNCHECKED_CAST") |
| 183 | + (runCatching { JsonSlurper().parse(describe) }.getOrNull() as? Map<String, Any?>)?.let { parsed -> |
| 184 | + return parsed.mapNotNull { (id, cls) -> (cls as? String)?.let { id to it } }.toMap(linkedMapOf()) |
| 185 | + } |
| 186 | + } |
| 187 | + describeFailed = true |
| 188 | + val why = failure ?: "it wrote no valid --describe-sources file; does main call Server.serve?" |
| 189 | + val log = output.toString().trim() |
| 190 | + logger.warn( |
| 191 | + "Could not read each Dag's source from '{}' ({}); only its entrypoint source is packed.{}", |
| 192 | + main, |
| 193 | + why, |
| 194 | + if (log.isEmpty()) "" else "\n$log", |
| 195 | + ) |
| 196 | + return emptyMap() |
| 197 | + } |
| 198 | +} |
0 commit comments