001/* 002 * JDrupes Builder 003 * Copyright (C) 2026 Michael N. Lipp 004 * 005 * This program is free software: you can redistribute it and/or modify 006 * it under the terms of the GNU Affero General Public License as 007 * published by the Free Software Foundation, either version 3 of the 008 * License, or (at your option) any later version. 009 * 010 * This program is distributed in the hope that it will be useful, 011 * but WITHOUT ANY WARRANTY; without even the implied warranty of 012 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 013 * GNU Affero General Public License for more details. 014 * 015 * You should have received a copy of the GNU Affero General Public License 016 * along with this program. If not, see <https://www.gnu.org/licenses/>. 017 */ 018 019package org.jdrupes.builder.core; 020 021import com.google.common.flogger.FluentLogger; 022import java.io.IOException; 023import java.io.InputStream; 024import java.io.OutputStream; 025import java.lang.ProcessBuilder.Redirect; 026import java.nio.file.Path; 027import java.time.Instant; 028import java.util.ArrayList; 029import java.util.Arrays; 030import java.util.Collection; 031import java.util.Collections; 032import java.util.List; 033import java.util.Objects; 034import java.util.function.Function; 035import java.util.stream.Collectors; 036import java.util.stream.Stream; 037import org.jdrupes.builder.api.BuildException; 038import org.jdrupes.builder.api.Cleanliness; 039import org.jdrupes.builder.api.ConfigurationException; 040import org.jdrupes.builder.api.ExecResult; 041import org.jdrupes.builder.api.FileResource; 042import org.jdrupes.builder.api.FileTree; 043import org.jdrupes.builder.api.Project; 044import org.jdrupes.builder.api.Renamable; 045import org.jdrupes.builder.api.RequiredResourceSupport; 046import org.jdrupes.builder.api.Resource; 047import org.jdrupes.builder.api.ResourceProvider; 048import org.jdrupes.builder.api.ResourceRequest; 049import org.jdrupes.builder.api.ResourceType; 050import static org.jdrupes.builder.api.ResourceType.*; 051import org.jdrupes.builder.api.Resources; 052 053/// A provider of [execution results][ExecResult] from invoking a script 054/// executed by a configurable [interpreter][#interpreter(Path)]. 055/// The provider generates resources in response to requests for 056/// [ExecResult] whose [name][ResourceRequest#name()] matches this 057/// [provider's name][ResourceProvider#name()]. 058/// 059/// * The working directory is the project directory. 060/// 061/// * The provider retrieves all resources added by [#required]. While 062/// the provider itself does not process these resources, it is assumed 063/// that they are processed by the script and therefore need to be 064/// available. 065/// 066/// * The provider invokes the function configured with [#output] and 067/// collects all resources. If the generated resources exist and no 068/// resource from `required` is newer than the generated resources found, 069/// the provider returns a result that indicates successful invocation. 070/// The date of the result is set to the newest date from the generated 071/// resources and the (existing) resources are attached. 072/// 073/// * Else, the provider executes the script, calls the function set with 074/// [#output] again and adds the result to the [ExecResult] that 075/// it returns. 076/// 077/// The generated resources can also be provided directly (i.e. not as part 078/// of an [ExecResult]) in response to a configurable resource request, see 079/// [#provideResources(ResourceRequest, Function)]. 080/// 081/// The provider also uses the function set with [#output] to determine 082/// the resources to be removed when it is invoked with a request for 083/// [Cleanliness]. 084/// 085public class ScriptExecutor extends AbstractProvider 086 implements Renamable, RequiredResourceSupport { 087 private static final FluentLogger logger = FluentLogger.forEnclosingClass(); 088 private final Project project; 089 private final List<String> interpreterFlags = new ArrayList<>(); 090 private Path interpreter = Path.of("/usr/bin/bash"); 091 private Path scriptFile; 092 private String scriptArgumentFlag = "-c"; 093 private String script; 094 private final StreamCollector<String> arguments 095 = new StreamCollector<>(true); 096 private final StreamCollector<Resource> requiredResources 097 = new StreamCollector<>(true); 098 private Function<Project, Stream<Resource>> getOutput 099 = _ -> Stream.empty(); 100 private ResourceRequest<?> requestForGenerated; 101 102 /// Initializes a new script executor. 103 /// 104 /// @param project the project 105 /// 106 public ScriptExecutor(Project project) { 107 this.project = project; 108 rename(ScriptExecutor.class.getSimpleName() + " in " + project); 109 } 110 111 /// Returns the project that this provider belongs to. 112 /// 113 /// @return the project 114 /// 115 public Project project() { 116 return project; 117 } 118 119 @Override 120 public ScriptExecutor name(String name) { 121 rename(name); 122 return this; 123 } 124 125 /// Sets the path to the interpreter that is to be invoked. 126 /// 127 /// @param interpreter the interpreter 128 /// @return the script executor 129 /// 130 public ScriptExecutor interpreter(Path interpreter) { 131 this.interpreter = interpreter; 132 return this; 133 } 134 135 /// Adds the given flags to the interpreter invocation. 136 /// 137 /// @param flags the flags 138 /// @return the script executor 139 /// 140 public ScriptExecutor interpreterFlags(String... flags) { 141 interpreterFlags.addAll(Arrays.asList(flags)); 142 return this; 143 } 144 145 /// Sets the script file to be executed. 146 /// 147 /// @param script the script 148 /// @return the script executor 149 /// 150 public ScriptExecutor scriptFile(Path script) { 151 if (this.script != null) { 152 throw new ConfigurationException().from(this).message( 153 "Either script or scriptFile may be set"); 154 } 155 this.scriptFile = project().directory().resolve(script); 156 return this; 157 } 158 159 /// Sets the flag that precedes a script passed to the interpreter 160 /// on the command line. Defaults to "-c". 161 /// 162 /// @param flag the flag 163 /// @return the script executor 164 /// 165 public ScriptExecutor scriptArgumentFlag(String flag) { 166 scriptArgumentFlag = flag; 167 return this; 168 } 169 170 /// Sets the script to be executed. 171 /// 172 /// @param script the script 173 /// @return the script executor 174 /// 175 public ScriptExecutor script(String script) { 176 if (scriptFile != null) { 177 throw new ConfigurationException().from(this).message( 178 "Either script or scriptFile may be set"); 179 } 180 this.script = script; 181 return this; 182 } 183 184 /// Add the given arguments as arguments of the script. Note that 185 /// the absolute path of the script or, when using [#script(String)], 186 /// this provider's name is automatically added as the first 187 /// argument, before the arguments specified by this method. 188 /// 189 /// @param args the arguments 190 /// @return the script executor 191 /// 192 public ScriptExecutor args(String... args) { 193 arguments.add(Arrays.asList(args).stream()); 194 return this; 195 } 196 197 /// Add the strings from the stream as arguments of the script, 198 /// see [#args(String...)]. 199 /// 200 /// @param args the args 201 /// @return the script executor 202 /// 203 public ScriptExecutor args(Stream<String> args) { 204 arguments.add(args); 205 return this; 206 } 207 208 /// Required. 209 /// 210 /// @param resources the resources 211 /// @return the script executor 212 /// 213 @Override 214 public ScriptExecutor required(Stream<? extends Resource> resources) { 215 requiredResources.add(resources); 216 return this; 217 } 218 219 @Override 220 public ScriptExecutor required(Path root, String pattern) { 221 requiredResources 222 .add(Stream.of(FileTree.of(project, root, pattern))); 223 return this; 224 } 225 226 @Override 227 public ScriptExecutor required(Path file) { 228 requiredResources.add( 229 Stream.of(FileResource.of(project.directory().resolve(file)))); 230 return this; 231 } 232 233 /// Sets the function used to determine the resources generated by this 234 /// provider. The function is evaluated both for incremental 235 /// up-to-date checks, for determining the resources returned 236 /// after script execution, and for determining the resources to be 237 /// cleaned. 238 /// 239 /// @param resources the function that provides the results as resources 240 /// @return the script executor 241 /// 242 public ScriptExecutor output( 243 Function<Project, Stream<Resource>> resources) { 244 this.getOutput = resources; 245 return this; 246 } 247 248 /// Provide the generated resources directly in response to a requests 249 /// like the given prototype request. Invoking this method implies a 250 /// call to [#output(Function)] with `resources`. 251 /// 252 /// @param proto defines the kind of request that the script executor 253 /// should respond to with the generated resources 254 /// @param resources the function that provides the results as resources 255 /// @return the script executor 256 /// 257 public ScriptExecutor provideResources(ResourceRequest<?> proto, 258 Function<Project, Stream<Resource>> resources) { 259 requestForGenerated = proto; 260 return output(resources); 261 } 262 263 @Override 264 protected <T extends Resource> Collection<T> 265 doProvide(ResourceRequest<T> request) { 266 if (request.accepts(CleanlinessType)) { 267 getOutput.apply(project).forEach(Resource::cleanup); 268 return Collections.emptyList(); 269 } 270 271 // Handle request for generated resources 272 if (requestForGenerated != null 273 && request.accepts(requestForGenerated.type()) 274 && (requestForGenerated.name().isEmpty() 275 || Objects.equals(requestForGenerated.name().get(), 276 request.name().orElse(null)))) { 277 // No need to evaluate for most special type because 278 // everything is derived from the exec result 279 return provideGenerated(); 280 } 281 282 // Check for and handle request for execution result 283 if (!request.accepts(ExecResultType) 284 || !name().equals(request.name().orElse(null))) { 285 return Collections.emptyList(); 286 } 287 // Always evaluate for the most special type 288 if (!request.type().equals(ExecResultType)) { 289 @SuppressWarnings({ "unchecked", "PMD.AvoidDuplicateLiterals" }) 290 var result = (Collection<T>) resources(of(ExecResultType) 291 .withName(name())).toList(); 292 return result; 293 } 294 295 // Make sure that the required resources are retrieved and exist 296 var required = Resources.of(new ResourceType<Resources<Resource>>() {}); 297 required.addAll(requiredResources.stream()); 298 299 // Get (previously) provided and check if up-to-date 300 var existing = Resources.of(new ResourceType<Resources<Resource>>() {}); 301 existing.addAll(getOutput.apply(project)); 302 if (required.asOf().isPresent() && existing.asOf().isPresent() 303 && !required.asOf().get().isAfter(existing.asOf().get())) { 304 logger.atFine().log("Output from %s is up to date", this); 305 @SuppressWarnings("unchecked") 306 var result = (T) ExecResult.of(this, 307 "existing " + existing.stream().map(Resource::toString) 308 .collect(Collectors.joining(", ")), 309 0, existing.stream()).asOf(existing.asOf().get()); 310 return List.of(result); 311 } 312 return runScript(project); 313 } 314 315 private <T extends Resource> Collection<T> runScript(Project project) { 316 logger.atFine().log("Running %s with %s", this, interpreter); 317 List<String> command 318 = new ArrayList<>(List.of(interpreter.toString())); 319 command.addAll(interpreterFlags); 320 if (scriptFile != null) { 321 command.add(scriptFile.toString()); 322 } 323 if (script != null) { 324 command.add(scriptArgumentFlag); 325 command.add(script); 326 command.add(name()); 327 } 328 arguments.stream().forEach(command::add); 329 ProcessBuilder processBuilder = new ProcessBuilder(command) 330 .directory(project.directory().toFile()) 331 .redirectInput(Redirect.INHERIT); 332 try { 333 Process process = processBuilder.start(); 334 copyData(process.getInputStream(), context().out()); 335 copyData(process.getErrorStream(), context().error()); 336 int exitValue = process.waitFor(); 337 if (exitValue != 0) { 338 throw new BuildException().from(this) 339 .message("Interpreter exited with %d", exitValue); 340 } 341 @SuppressWarnings("unchecked") 342 var result = (Collection<T>) List.of(ExecResult.of(this, 343 "[" + project.name() + "]$ ... " 344 + arguments.stream().collect(Collectors.joining(" ")), 345 exitValue, getOutput.apply(project)) 346 .asOf(Instant.now())); 347 return result; 348 } catch (IOException | InterruptedException e) { 349 throw new BuildException().from(this).cause(e); 350 } 351 } 352 353 private void copyData(InputStream source, OutputStream sink) { 354 Thread.startVirtualThread(() -> { 355 try (source) { 356 source.transferTo(sink); 357 } catch (IOException e) { 358 throw new BuildException().from(this).cause(e); 359 } 360 }); 361 } 362 363 private <T extends Resource> Collection<T> provideGenerated() { 364 // Request execution result 365 var execResult = resources(of(ExecResultType).withName(name())); 366 @SuppressWarnings("unchecked") 367 368 // Extract generated 369 var generated = execResult.map(r -> (Stream<T>) r.resources()) 370 .flatMap(s -> s).toList(); 371 return generated; 372 } 373 374 /// To string. 375 /// 376 /// @return the string 377 /// 378 @Override 379 public String toString() { 380 return super.toString() + "[" + project().name() + "]"; 381 } 382}