diff --git a/README.md b/README.md index cb27f0852..2ee4c184a 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,7 @@ It works seamlessly across AEM on-premise, AMS, and AEMaaCS environments. - [Content scripts](#content-scripts) - [Minimal example](#minimal-example) - [Inputs example](#inputs-example) + - [Outputs example](#outputs-example) - [ACL example](#acl-example) - [Repo example](#repo-example) - [History](#history) @@ -251,10 +252,47 @@ When the script is executed, the inputs are passed to the `doRun()` method. There are many built-in input types to use handling different types of data like string, boolean, number, date, file, etc. Just check `inputs` [service](https://github.com/wttech/acm/blob/main/core/src/main/java/dev/vml/es/acm/core/code/Inputs.java) for more details. -ACM Console +ACM Content Script Inputs Be inspired by reviewing examples like [page thumbnail script](https://github.com/wttech/acm/blob/main/ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/manual/example/ACME-202_page-thumbnail.groovy) which allows user to upload a thumbnail image and set it as a page thumbnail with only a few clicks and a few lines of code. +#### Outputs example + +Scripts can generate output files that can be downloaded after execution. + +The following example of the content script demonstrates how to generate a CSV report as an output file using the `outputs` [service](https://github.com/wttech/acm/blob/main/core/src/main/java/dev/vml/es/acm/core/code/Outputs.java). + +There is no limitation on the number of output files that can be generated by a script. Each output file can have its own label, description, and download name. All outputs are persisted in the history, allowing you to review and download them later. + +```groovy +boolean canRun() { + return conditions.always() +} + +void doRun() { + log.info "Users report generation started" + + def report = outputs.make("report") { + label = "Report" + description = "Users report generated as CSV file" + downloadName = "report.csv" + } + + def users = [ + [name: "John", surname: "Doe", birth: "1991"], + [name: "Jane", surname: "Doe", birth: "1995"], + [name: "Jack", surname: "Doe", birth: "2000"] + ] + for (def user : users) { + report.out.println("${user.name},${user.surname},${user.birth}") + } + + log.info "Users report generation ended successfully" +} +``` + +ACM Content Script Outputs + #### ACL example The following example of the automatic script demonstrates how to create a user and a group, assign permissions, and add members to the group using the [ACL service](https://github.com/wttech/acm/blob/main/core/src/main/java/dev/vml/es/acm/core/acl/Acl.java) (`acl`). diff --git a/core/src/main/java/dev/vml/es/acm/core/assist/Assistancer.java b/core/src/main/java/dev/vml/es/acm/core/assist/Assistancer.java index fa3731170..785b94790 100644 --- a/core/src/main/java/dev/vml/es/acm/core/assist/Assistancer.java +++ b/core/src/main/java/dev/vml/es/acm/core/assist/Assistancer.java @@ -154,6 +154,7 @@ private synchronized void maybeUpdateVariablesCache(ResourceResolver resolver) { resolver.getUserID(), ExecutionMode.PARSE, Code.consoleMinimal(), + new InputValues(), resolver)) { context.getCodeContext().prepareRun(context); variablesCache = context.getCodeContext().getBindingVariables(); diff --git a/core/src/main/java/dev/vml/es/acm/core/code/Code.java b/core/src/main/java/dev/vml/es/acm/core/code/Code.java index 0ba28e279..d4d309376 100644 --- a/core/src/main/java/dev/vml/es/acm/core/code/Code.java +++ b/core/src/main/java/dev/vml/es/acm/core/code/Code.java @@ -1,8 +1,6 @@ package dev.vml.es.acm.core.code; import dev.vml.es.acm.core.AcmException; -import dev.vml.es.acm.core.util.JsonUtils; -import java.io.IOException; import java.util.HashMap; import java.util.Map; import org.apache.commons.lang3.builder.ToStringBuilder; @@ -18,40 +16,27 @@ public class Code implements Executable { private String content; - private InputValues inputs; - public Code() { // for deserialization } - public Code(String id, String content, InputValues inputs) { + public Code(String id, String content) { this.id = id; this.content = content; - this.inputs = inputs; } public static Map toJobProps(Executable executable) throws AcmException { - try { - Map result = new HashMap<>(); - result.put(ExecutionJob.EXECUTABLE_ID_PROP, executable.getId()); - result.put(ExecutionJob.EXECUTABLE_CONTENT_PROP, executable.getContent()); - result.put(ExecutionJob.EXECUTABLE_INPUTS_PROP, JsonUtils.writeToString(executable.getInputs())); - return result; - } catch (IOException e) { - throw new AcmException("Cannot serialize code to JSON!", e); - } + Map result = new HashMap<>(); + result.put(ExecutionJob.EXECUTABLE_ID_PROP, executable.getId()); + result.put(ExecutionJob.EXECUTABLE_CONTENT_PROP, executable.getContent()); + return result; } public static Code fromJob(Job job) { - try { - String id = job.getProperty(ExecutionJob.EXECUTABLE_ID_PROP, String.class); - String content = job.getProperty(ExecutionJob.EXECUTABLE_CONTENT_PROP, String.class); - InputValues inputs = JsonUtils.readFromString( - job.getProperty(ExecutionJob.EXECUTABLE_INPUTS_PROP, String.class), InputValues.class); - return new Code(id, content, inputs); - } catch (IOException e) { - throw new AcmException("Cannot deserialize code from JSON!", e); - } + + String id = job.getProperty(ExecutionJob.EXECUTABLE_ID_PROP, String.class); + String content = job.getProperty(ExecutionJob.EXECUTABLE_CONTENT_PROP, String.class); + return new Code(id, content); } public static Code consoleMinimal() { @@ -76,11 +61,6 @@ public String getContent() { return content; } - @Override - public InputValues getInputs() { - return inputs; - } - public String toString() { return new ToStringBuilder(this, ToStringStyle.SHORT_PREFIX_STYLE) .append("id", id) diff --git a/core/src/main/java/dev/vml/es/acm/core/code/ImmediateExecution.java b/core/src/main/java/dev/vml/es/acm/core/code/ContextualExecution.java similarity index 65% rename from core/src/main/java/dev/vml/es/acm/core/code/ImmediateExecution.java rename to core/src/main/java/dev/vml/es/acm/core/code/ContextualExecution.java index 677229dbd..0a25f8aba 100644 --- a/core/src/main/java/dev/vml/es/acm/core/code/ImmediateExecution.java +++ b/core/src/main/java/dev/vml/es/acm/core/code/ContextualExecution.java @@ -1,5 +1,6 @@ package dev.vml.es.acm.core.code; +import com.fasterxml.jackson.annotation.JsonIgnore; import dev.vml.es.acm.core.AcmException; import dev.vml.es.acm.core.util.ExceptionUtils; import java.io.InputStream; @@ -10,15 +11,10 @@ import org.apache.commons.lang3.builder.ToStringBuilder; import org.apache.commons.lang3.builder.ToStringStyle; -public class ImmediateExecution implements Execution { +public class ContextualExecution implements Execution { - private final CodeOutput codeOutput; - - private final Executable executable; - - private final String id; - - private final String userId; + @JsonIgnore + private final transient ExecutionContext context; private final ExecutionStatus status; @@ -28,37 +24,28 @@ public class ImmediateExecution implements Execution { private final String error; - private final String instance; - - public ImmediateExecution( - CodeOutput codeOutput, - Executable executable, - String id, - String userId, - ExecutionStatus status, - Date startDate, - Date endDate, - String error, - String instance) { - this.codeOutput = codeOutput; - this.executable = executable; - this.id = id; - this.userId = userId; + public ContextualExecution( + ExecutionContext context, ExecutionStatus status, Date startDate, Date endDate, String error) { + this.context = context; this.status = status; this.startDate = startDate; this.endDate = endDate; this.error = error; - this.instance = instance; + } + + @JsonIgnore + public ExecutionContext getContext() { + return context; } @Override public String getId() { - return id; + return context.getId(); } @Override public String getUserId() { - return userId; + return context.getUserId(); } @Override @@ -91,8 +78,8 @@ public String getError() { @Override public String getOutput() { - codeOutput.flush(); - try (InputStream stream = codeOutput.read()) { + context.getOutput().flush(); + try (InputStream stream = context.getOutput().read()) { return IOUtils.toString(stream, StandardCharsets.UTF_8); } catch (Exception e) { return null; @@ -101,17 +88,17 @@ public String getOutput() { @Override public String getInstance() { - return instance; + return context.getCodeContext().getOsgiContext().readInstanceState(); } public InputStream readOutput() throws AcmException { - codeOutput.flush(); - return codeOutput.read(); + context.getOutput().flush(); + return context.getOutput().read(); } @Override public Executable getExecutable() { - return executable; + return context.getExecutable(); } @Override @@ -148,18 +135,19 @@ public Builder error(Throwable e) { return this; } - public ImmediateExecution end(ExecutionStatus status) { + public ContextualExecution end(ExecutionStatus status) { Date endDate = new Date(); - return new ImmediateExecution( - context.getOutput(), - context.getExecutable(), - context.getId(), - context.getCodeContext().getResourceResolver().getUserID(), - status, - startDate, - endDate, - error, - context.getCodeContext().getOsgiContext().readInstanceState()); + return new ContextualExecution(context, status, startDate, endDate, error); } } + + @Override + public InputValues getInputs() { + return new InputValues(context.getInputs().values()); + } + + @Override + public OutputValues getOutputs() { + return new OutputValues(context.getOutputs().getDefinitions().values()); + } } diff --git a/core/src/main/java/dev/vml/es/acm/core/code/Executable.java b/core/src/main/java/dev/vml/es/acm/core/code/Executable.java index f9103d6d2..f2741ac10 100644 --- a/core/src/main/java/dev/vml/es/acm/core/code/Executable.java +++ b/core/src/main/java/dev/vml/es/acm/core/code/Executable.java @@ -10,6 +10,4 @@ public interface Executable extends Serializable { String getId(); String getContent() throws AcmException; - - InputValues getInputs(); } diff --git a/core/src/main/java/dev/vml/es/acm/core/code/Execution.java b/core/src/main/java/dev/vml/es/acm/core/code/Execution.java index aaa2bd016..faf06cd61 100644 --- a/core/src/main/java/dev/vml/es/acm/core/code/Execution.java +++ b/core/src/main/java/dev/vml/es/acm/core/code/Execution.java @@ -21,9 +21,13 @@ public interface Execution extends Serializable { long getDuration(); + String getOutput(); + String getError(); - String getOutput(); + InputValues getInputs(); + + OutputValues getOutputs(); String getInstance(); diff --git a/core/src/main/java/dev/vml/es/acm/core/code/ExecutionContext.java b/core/src/main/java/dev/vml/es/acm/core/code/ExecutionContext.java index b7dd63bba..c069d70df 100644 --- a/core/src/main/java/dev/vml/es/acm/core/code/ExecutionContext.java +++ b/core/src/main/java/dev/vml/es/acm/core/code/ExecutionContext.java @@ -31,6 +31,10 @@ public class ExecutionContext implements AutoCloseable { private final Inputs inputs; + private InputValues inputValues; + + private final Outputs outputs; + private final Schedules schedules; private final Conditions conditions; @@ -41,18 +45,21 @@ public ExecutionContext( ExecutionMode mode, Executor executor, Executable executable, + InputValues inputValues, CodeContext codeContext) { this.id = id; this.userId = userId; this.mode = mode; this.executor = executor; this.executable = executable; + this.inputValues = inputValues; this.codeContext = codeContext; this.output = determineOutput(mode, codeContext, id); this.printStream = new CodePrintStream(output.write(), String.format("%s|%s", executable.getId(), id)); this.schedules = new Schedules(); this.conditions = new Conditions(this); this.inputs = new Inputs(); + this.outputs = new Outputs(); customizeBinding(); } @@ -134,6 +141,14 @@ public Inputs getInputs() { return inputs; } + void useInputValues() { + inputs.setValues(inputValues); + } + + public Outputs getOutputs() { + return outputs; + } + public Schedules getSchedules() { return schedules; } @@ -148,6 +163,7 @@ private void customizeBinding() { binding.setVariable("schedules", schedules); binding.setVariable("arguments", inputs); // TODO deprecated binding.setVariable("inputs", inputs); + binding.setVariable("outputs", outputs); binding.setVariable("conditions", conditions); binding.setVariable("out", getOut()); binding.setVariable("log", getLogger()); diff --git a/core/src/main/java/dev/vml/es/acm/core/code/ExecutionContextOptions.java b/core/src/main/java/dev/vml/es/acm/core/code/ExecutionContextOptions.java index 17964c2ee..3d322ea2a 100644 --- a/core/src/main/java/dev/vml/es/acm/core/code/ExecutionContextOptions.java +++ b/core/src/main/java/dev/vml/es/acm/core/code/ExecutionContextOptions.java @@ -1,6 +1,8 @@ package dev.vml.es.acm.core.code; import dev.vml.es.acm.core.AcmException; +import dev.vml.es.acm.core.util.JsonUtils; +import java.io.IOException; import java.io.Serializable; import java.util.HashMap; import java.util.Map; @@ -16,22 +18,37 @@ public class ExecutionContextOptions implements Serializable { private final String userId; - public ExecutionContextOptions(ExecutionMode executionMode, String userId) { + private final InputValues inputs; + + public ExecutionContextOptions(ExecutionMode executionMode, String userId, InputValues inputs) { this.executionMode = executionMode; this.userId = Objects.requireNonNull(userId); + this.inputs = inputs; } public static Map toJobProps(ExecutionContextOptions options) throws AcmException { - Map props = new HashMap<>(); - props.put(ExecutionJob.EXECUTION_MODE_PROP, options.getExecutionMode().name()); - props.put(ExecutionJob.USER_ID_PROP, options.getUserId()); - return props; + try { + Map props = new HashMap<>(); + props.put( + ExecutionJob.EXECUTION_MODE_PROP, options.getExecutionMode().name()); + props.put(ExecutionJob.USER_ID_PROP, options.getUserId()); + props.put(ExecutionJob.INPUTS_PROP, JsonUtils.writeToString(options.getInputs())); + return props; + } catch (IOException e) { + throw new AcmException("Cannot serialize input values to JSON!", e); + } } public static ExecutionContextOptions fromJob(Job job) { - return new ExecutionContextOptions( - ExecutionMode.valueOf(job.getProperty(ExecutionJob.EXECUTION_MODE_PROP, String.class)), - job.getProperty(ExecutionJob.USER_ID_PROP, String.class)); + try { + return new ExecutionContextOptions( + ExecutionMode.valueOf(job.getProperty(ExecutionJob.EXECUTION_MODE_PROP, String.class)), + job.getProperty(ExecutionJob.USER_ID_PROP, String.class), + JsonUtils.readFromString( + job.getProperty(ExecutionJob.INPUTS_PROP, String.class), InputValues.class)); + } catch (IOException e) { + throw new AcmException("Cannot deserialize execution context values from JSON!", e); + } } public String getUserId() { @@ -41,4 +58,8 @@ public String getUserId() { public ExecutionMode getExecutionMode() { return executionMode; } + + public InputValues getInputs() { + return inputs; + } } diff --git a/core/src/main/java/dev/vml/es/acm/core/code/ExecutionHistory.java b/core/src/main/java/dev/vml/es/acm/core/code/ExecutionHistory.java index 9ddc4026c..f3071904c 100644 --- a/core/src/main/java/dev/vml/es/acm/core/code/ExecutionHistory.java +++ b/core/src/main/java/dev/vml/es/acm/core/code/ExecutionHistory.java @@ -2,14 +2,18 @@ import dev.vml.es.acm.core.AcmConstants; import dev.vml.es.acm.core.AcmException; +import dev.vml.es.acm.core.repo.Repo; +import dev.vml.es.acm.core.repo.RepoResource; import dev.vml.es.acm.core.repo.RepoUtils; import dev.vml.es.acm.core.util.StreamUtils; import java.io.Closeable; import java.io.IOException; +import java.io.InputStream; import java.util.*; import java.util.stream.Stream; import javax.jcr.query.Query; import org.apache.commons.lang3.StringUtils; +import org.apache.jackrabbit.JcrConstants; import org.apache.sling.api.resource.PersistenceException; import org.apache.sling.api.resource.Resource; import org.apache.sling.api.resource.ResourceResolver; @@ -19,6 +23,10 @@ public class ExecutionHistory { public static final String ROOT = AcmConstants.VAR_ROOT + "/execution/history"; + public static final String OUTPUT_FILES_CONTAINER_RN = "outputFiles"; + + public static final String OUTPUT_FILE_RN = "file"; + private final ResourceResolver resourceResolver; public ExecutionHistory(ResourceResolver resourceResolver) { @@ -29,16 +37,22 @@ public Stream read() { return Stream.empty(); } - public void save(ExecutionContext context, ImmediateExecution execution) throws AcmException { + public void save(ContextualExecution execution) throws AcmException { Resource root = getOrCreateRoot(); - Map props = HistoricalExecution.toMap(context, execution); + Resource entry = saveEntry(execution, root); + saveOutputs(execution, entry); + } + + private Resource saveEntry(ContextualExecution execution, Resource root) { + Map props = HistoricalExecution.toMap(execution); try { String dirPath = root.getPath() + "/" + StringUtils.substringBeforeLast(execution.getId(), "/"); Resource dir = RepoUtils.ensure(resourceResolver, dirPath, JcrResourceConstants.NT_SLING_FOLDER, true); String entryName = StringUtils.substringAfterLast(execution.getId(), "/"); - resourceResolver.create(dir, entryName, props); + Resource resource = resourceResolver.create(dir, entryName, props); resourceResolver.commit(); + return resource; } catch (PersistenceException e) { throw new AcmException(String.format("Failed to save execution '%s'", execution.getId()), e); } finally { @@ -54,6 +68,27 @@ public void save(ExecutionContext context, ImmediateExecution execution) throws } } + private void saveOutputs(ContextualExecution execution, Resource entry) { + for (Output definition : + execution.getContext().getOutputs().getDefinitions().values()) { + RepoResource container = Repo.quiet(entry.getResourceResolver()) + .get(entry.getPath()) + .child(String.format("%s/%s", OUTPUT_FILES_CONTAINER_RN, definition.getName())) + .ensure(JcrConstants.NT_UNSTRUCTURED); + RepoResource file = container.child(OUTPUT_FILE_RN); + file.saveFile(definition.getMimeType(), definition.getInputStream()); + } + } + + public InputStream readOutputByName(Execution execution, String name) { + return Repo.quiet(resourceResolver) + .get(ROOT) + .child(execution.getId()) + .child(String.format("%s/%s", OUTPUT_FILES_CONTAINER_RN, name)) + .child(OUTPUT_FILE_RN) + .readFileAsStream(); + } + public Optional read(String id) { return readResource(id).map(HistoricalExecution::new); } @@ -82,6 +117,12 @@ public Stream readAll(Collection ids) { .map(Optional::get); } + public Optional findById(String id) { + ExecutionQuery query = new ExecutionQuery(); + query.setId(id); + return findAll(query).findFirst(); + } + public Stream findAll() { return findAll(new ExecutionQuery()); } diff --git a/core/src/main/java/dev/vml/es/acm/core/code/ExecutionJob.java b/core/src/main/java/dev/vml/es/acm/core/code/ExecutionJob.java index a314c8102..2a2548e54 100644 --- a/core/src/main/java/dev/vml/es/acm/core/code/ExecutionJob.java +++ b/core/src/main/java/dev/vml/es/acm/core/code/ExecutionJob.java @@ -6,7 +6,7 @@ public final class ExecutionJob { public static final String EXECUTABLE_CONTENT_PROP = "executableContent"; - public static final String EXECUTABLE_INPUTS_PROP = "executableInputs"; + public static final String INPUTS_PROP = "inputs"; public static final String USER_ID_PROP = "userId"; diff --git a/core/src/main/java/dev/vml/es/acm/core/code/ExecutionQuery.java b/core/src/main/java/dev/vml/es/acm/core/code/ExecutionQuery.java index 9e9d44c56..dc4290ab6 100644 --- a/core/src/main/java/dev/vml/es/acm/core/code/ExecutionQuery.java +++ b/core/src/main/java/dev/vml/es/acm/core/code/ExecutionQuery.java @@ -13,6 +13,8 @@ public class ExecutionQuery { private String path = ExecutionHistory.ROOT; + private String id; + private String executableId; private String userId; @@ -48,6 +50,14 @@ public void setPath(String path) { this.path = path; } + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + public String getExecutableId() { return executableId; } @@ -107,6 +117,9 @@ protected String toSql() { if (path != null) { filters.add(String.format("ISDESCENDANTNODE(s, '%s')", path)); } + if (id != null) { + filters.add(String.format("s.[id] = '%s'", id)); + } if (executableId != null) { if (ExecutableUtils.isIdExplicit(executableId)) { filters.add(String.format("s.[executableId] = '%s'", executableId)); diff --git a/core/src/main/java/dev/vml/es/acm/core/code/ExecutionQueue.java b/core/src/main/java/dev/vml/es/acm/core/code/ExecutionQueue.java index bcf4bfac5..2f9702e2b 100644 --- a/core/src/main/java/dev/vml/es/acm/core/code/ExecutionQueue.java +++ b/core/src/main/java/dev/vml/es/acm/core/code/ExecutionQueue.java @@ -259,6 +259,7 @@ private Execution executeAsync(ExecutionContextOptions contextOptions, QueuedExe contextOptions.getUserId(), contextOptions.getExecutionMode(), execution.getExecutable(), + contextOptions.getInputs(), resolver)) { return executor.execute(context); } catch (LoginException e) { diff --git a/core/src/main/java/dev/vml/es/acm/core/code/ExecutionResolver.java b/core/src/main/java/dev/vml/es/acm/core/code/ExecutionResolver.java index e1753bd6f..1aa66dabe 100644 --- a/core/src/main/java/dev/vml/es/acm/core/code/ExecutionResolver.java +++ b/core/src/main/java/dev/vml/es/acm/core/code/ExecutionResolver.java @@ -20,7 +20,7 @@ public ExecutionResolver(ExecutionQueue queue, ResourceResolver resourceResolver this(queue, new ExecutionHistory(resourceResolver)); } - public Optional read(String id) { + public Optional resolve(String id) { Optional execution = history.read(id); if (execution.isPresent()) { return execution; @@ -29,7 +29,7 @@ public Optional read(String id) { } } - public Optional readSummary(String id) { + public Optional resolveSummary(String id) { Optional execution = history.readSummary(id); if (execution.isPresent()) { return execution; @@ -38,16 +38,16 @@ public Optional readSummary(String id) { } } - public Stream readAll(Collection ids) { + public Stream resolveAll(Collection ids) { return (ids != null ? ids.stream() : Stream.empty()) - .map(this::read) + .map(this::resolve) .filter(Optional::isPresent) .map(Optional::get); } - public Stream readAllSummaries(Collection ids) { + public Stream resolveAllSummaries(Collection ids) { return (ids != null ? ids.stream() : Stream.empty()) - .map(this::readSummary) + .map(this::resolveSummary) .filter(Optional::isPresent) .map(Optional::get); } diff --git a/core/src/main/java/dev/vml/es/acm/core/code/Executor.java b/core/src/main/java/dev/vml/es/acm/core/code/Executor.java index 10fcfe612..ae807cc05 100644 --- a/core/src/main/java/dev/vml/es/acm/core/code/Executor.java +++ b/core/src/main/java/dev/vml/es/acm/core/code/Executor.java @@ -127,9 +127,14 @@ public void onEvent(Event event) { } public ExecutionContext createContext( - String id, String userId, ExecutionMode mode, Executable executable, ResourceResolver resourceResolver) { + String id, + String userId, + ExecutionMode mode, + Executable executable, + InputValues inputs, + ResourceResolver resourceResolver) { CodeContext codeContext = new CodeContext(osgiContext, resourceResolver); - ExecutionContext result = new ExecutionContext(id, userId, mode, this, executable, codeContext); + ExecutionContext result = new ExecutionContext(id, userId, mode, this, executable, inputs, codeContext); result.setDebug(config.debug()); result.setHistory(config.history()); return result; @@ -142,6 +147,7 @@ public Execution execute(Executable executable, ExecutionContextOptions contextO contextOptions.getUserId(), contextOptions.getExecutionMode(), executable, + contextOptions.getInputs(), resolver)) { return execute(executionContext); } catch (LoginException e) { @@ -152,17 +158,17 @@ public Execution execute(Executable executable, ExecutionContextOptions contextO public Execution execute(ExecutionContext context) throws AcmException { context.getCodeContext().prepareRun(context); - ImmediateExecution execution = executeImmediately(context); + ContextualExecution execution = executeInternal(context); if (context.getMode() == ExecutionMode.RUN) { - handleHistory(context, execution); - handleNotifications(context, execution); + handleHistory(execution); + handleNotifications(execution); context.getCodeContext().completeRun(execution); } return execution; } - private ImmediateExecution executeImmediately(ExecutionContext context) { - ImmediateExecution.Builder execution = new ImmediateExecution.Builder(context).start(); + private ContextualExecution executeInternal(ExecutionContext context) { + ContextualExecution.Builder execution = new ContextualExecution.Builder(context).start(); try { statuses.put(context.getId(), ExecutionStatus.PARSING); @@ -176,7 +182,7 @@ private ImmediateExecution executeImmediately(ExecutionContext context) { statuses.put(context.getId(), ExecutionStatus.CHECKING); contentScript.describe(); - context.getInputs().setValues(context.getExecutable().getInputs()); + context.useInputValues(); boolean canRun = contentScript.canRun(); if (!canRun) { @@ -228,13 +234,14 @@ public Optional checkStatus(String executionId) { return Optional.ofNullable(statuses.get(executionId)); } - private void handleHistory(ExecutionContext context, ImmediateExecution execution) { - if (context.isHistory() && (context.isDebug() || (execution.getStatus() != ExecutionStatus.SKIPPED))) { - useHistory(resolverFactory, h -> h.save(context, execution)); + private void handleHistory(ContextualExecution execution) { + if (execution.getContext().isHistory() + && (execution.getContext().isDebug() || (execution.getStatus() != ExecutionStatus.SKIPPED))) { + useHistory(resolverFactory, h -> h.save(execution)); } } - private void handleNotifications(ExecutionContext context, ImmediateExecution execution) { + private void handleNotifications(ContextualExecution execution) { String executableId = execution.getExecutable().getId(); if (!config.notificationEnabled() || !notifier.isConfigured(config.notificationNotifierId()) @@ -244,7 +251,7 @@ private void handleNotifications(ExecutionContext context, ImmediateExecution ex } Map templateVars = new LinkedHashMap<>(); - templateVars.put("context", context); + templateVars.put("context", execution.getContext()); templateVars.put("execution", execution); templateVars.put( "statusIcon", @@ -253,7 +260,7 @@ private void handleNotifications(ExecutionContext context, ImmediateExecution ex : (execution.getStatus() == ExecutionStatus.FAILED ? "❌" : "⚠️")); templateVars.put("statusHere", execution.getStatus() == ExecutionStatus.SUCCEEDED ? "" : "@here"); TemplateFormatter templateFormatter = - context.getCodeContext().getFormatter().getTemplate(); + execution.getContext().getCodeContext().getFormatter().getTemplate(); String title = StringUtils.trim(templateFormatter.renderString(config.notificationTitle(), templateVars)); String text = StringUtils.trim(templateFormatter.renderString(config.notificationText(), templateVars)); @@ -262,7 +269,8 @@ private void handleNotifications(ExecutionContext context, ImmediateExecution ex fields.put("Time", DateUtils.humanFormat().format(new Date())); fields.put("Duration", StringUtil.formatDuration(execution.getDuration())); - InstanceInfo instanceInfo = context.getCodeContext().getOsgiContext().getInstanceInfo(); + InstanceInfo instanceInfo = + execution.getContext().getCodeContext().getOsgiContext().getInstanceInfo(); InstanceSettings instanceSettings = new InstanceSettings(instanceInfo); String instanceRoleName = instanceSettings.getRole().name().toLowerCase(); String instanceId = instanceSettings.getId(); @@ -277,7 +285,7 @@ private void handleNotifications(ExecutionContext context, ImmediateExecution ex } public Description describe(ExecutionContext context) { - ImmediateExecution.Builder execution = new ImmediateExecution.Builder(context).start(); + ContextualExecution.Builder execution = new ContextualExecution.Builder(context).start(); try { ContentScript contentScript = new ContentScript(context); contentScript.describe(); @@ -290,7 +298,7 @@ public Description describe(ExecutionContext context) { } public Execution check(ExecutionContext context) throws AcmException { - ImmediateExecution.Builder execution = new ImmediateExecution.Builder(context).start(); + ContextualExecution.Builder execution = new ContextualExecution.Builder(context).start(); try { ContentScript contentScript = new ContentScript(context); @@ -307,7 +315,7 @@ public Execution check(ExecutionContext context) throws AcmException { } public ScheduleResult schedule(ExecutionContext context) { - ImmediateExecution.Builder execution = new ImmediateExecution.Builder(context).start(); + ContextualExecution.Builder execution = new ContextualExecution.Builder(context).start(); try { ContentScript contentScript = new ContentScript(context); Schedule schedule = contentScript.schedule(); diff --git a/core/src/main/java/dev/vml/es/acm/core/code/HistoricalExecution.java b/core/src/main/java/dev/vml/es/acm/core/code/HistoricalExecution.java index e5646d89a..53da9cef3 100644 --- a/core/src/main/java/dev/vml/es/acm/core/code/HistoricalExecution.java +++ b/core/src/main/java/dev/vml/es/acm/core/code/HistoricalExecution.java @@ -38,6 +38,10 @@ public class HistoricalExecution implements Execution, Comparable toMap(ExecutionContext context, ImmediateExecution execution) { + protected static Map toMap(ContextualExecution execution) { try { Map props = new HashMap<>(); props.put("id", execution.getId()); - props.put("userId", context.getUserId()); + props.put("userId", execution.getContext().getUserId()); props.put("status", execution.getStatus().name()); props.put("startDate", DateUtils.toCalendar(execution.getStartDate())); props.put("endDate", DateUtils.toCalendar(execution.getEndDate())); props.put("duration", execution.getDuration()); - props.put("error", execution.getError()); props.put("output", execution.readOutput()); + props.put("error", execution.getError()); + props.put("inputs", JsonUtils.writeToStream(execution.getInputs())); + props.put("outputs", JsonUtils.writeToStream(execution.getOutputs())); props.put("instance", execution.getInstance()); props.put("executableId", execution.getExecutable().getId()); props.put("executableContent", execution.getExecutable().getContent()); - props.put( - "executableInputs", - JsonUtils.writeToStream(execution.getExecutable().getInputs())); props.entrySet().removeIf(e -> e.getValue() == null); props.put(JcrConstants.JCR_PRIMARYTYPE, PRIMARY_TYPE); @@ -148,6 +151,16 @@ public String getError() { return error; } + @Override + public InputValues getInputs() { + return inputs; + } + + @Override + public OutputValues getOutputs() { + return outputs; + } + @Override public Executable getExecutable() { return executable; diff --git a/core/src/main/java/dev/vml/es/acm/core/code/InputValues.java b/core/src/main/java/dev/vml/es/acm/core/code/InputValues.java index 04550ff65..14541d1b1 100644 --- a/core/src/main/java/dev/vml/es/acm/core/code/InputValues.java +++ b/core/src/main/java/dev/vml/es/acm/core/code/InputValues.java @@ -1,5 +1,27 @@ package dev.vml.es.acm.core.code; +import dev.vml.es.acm.core.AcmException; +import dev.vml.es.acm.core.util.JsonUtils; +import java.io.IOException; import java.util.HashMap; +import org.apache.sling.api.resource.ValueMap; +import org.apache.sling.event.jobs.Job; -public class InputValues extends HashMap {} +public class InputValues extends HashMap { + + public InputValues() { + // for deserialization + } + + public static InputValues fromJob(Job job) { + try { + return JsonUtils.readFromString(job.getProperty(ExecutionJob.INPUTS_PROP, String.class), InputValues.class); + } catch (IOException e) { + throw new AcmException("Cannot deserialize input values from JSON!", e); + } + } + + public InputValues(ValueMap values) { + values.forEach((key, value) -> put(key, value)); + } +} diff --git a/core/src/main/java/dev/vml/es/acm/core/code/Inputs.java b/core/src/main/java/dev/vml/es/acm/core/code/Inputs.java index 70634c1b0..801c312bc 100644 --- a/core/src/main/java/dev/vml/es/acm/core/code/Inputs.java +++ b/core/src/main/java/dev/vml/es/acm/core/code/Inputs.java @@ -18,10 +18,6 @@ public class Inputs implements Serializable { private final Map> definitions = new LinkedHashMap<>(); - public Inputs() { - super(); - } - public Input get(String name) { Input result = definitions.get(name); if (result == null) { diff --git a/core/src/main/java/dev/vml/es/acm/core/code/Output.java b/core/src/main/java/dev/vml/es/acm/core/code/Output.java new file mode 100644 index 000000000..7e005f480 --- /dev/null +++ b/core/src/main/java/dev/vml/es/acm/core/code/Output.java @@ -0,0 +1,108 @@ +package dev.vml.es.acm.core.code; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.InputStream; +import java.io.OutputStream; +import java.io.PrintStream; +import java.io.Serializable; + +public class Output implements Serializable { + + @JsonIgnore + private transient ByteArrayOutputStream dataStorage; + + @JsonIgnore + private transient PrintStream printStream; + + private String name; + + private String label; + + private String description; + + private String downloadName; + + private String mimeType = "application/octet-stream"; + + public Output() { + // for deserialization + } + + public Output(String name) { + this.name = name; + } + + public String getName() { + return name; + } + + public String getLabel() { + return label; + } + + public void setLabel(String label) { + this.label = label; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } + + public String getDownloadName() { + return downloadName; + } + + public void setDownloadName(String downloadName) { + this.downloadName = downloadName; + } + + public String getMimeType() { + return mimeType; + } + + public void setMimeType(String mimeType) { + this.mimeType = mimeType; + } + + @JsonIgnore + private ByteArrayOutputStream getDataStorage() { + if (dataStorage == null) { + dataStorage = new ByteArrayOutputStream(); + } + return dataStorage; + } + + /** + * Get a raw output stream for binary data and formatters integration (e.g. JSON/YAML writers). + */ + @JsonIgnore + public OutputStream getOutputStream() { + return getDataStorage(); + } + + /** + * Get the input stream for reading the output data e.g. for saving in the execution history. + */ + @JsonIgnore + public InputStream getInputStream() { + return new ByteArrayInputStream(getDataStorage().toByteArray()); + } + + /** + * System.out-like print stream for text operations with auto-flush. + * Use for println(), printf(), and formatted text output. + */ + @JsonIgnore + public PrintStream getOut() { + if (printStream == null) { + printStream = new PrintStream(getOutputStream(), true); + } + return printStream; + } +} diff --git a/core/src/main/java/dev/vml/es/acm/core/code/OutputValues.java b/core/src/main/java/dev/vml/es/acm/core/code/OutputValues.java new file mode 100644 index 000000000..dfd055af5 --- /dev/null +++ b/core/src/main/java/dev/vml/es/acm/core/code/OutputValues.java @@ -0,0 +1,15 @@ +package dev.vml.es.acm.core.code; + +import java.util.Collection; +import java.util.LinkedList; + +public class OutputValues extends LinkedList { + + public OutputValues() { + super(); + } + + public OutputValues(Collection values) { + super(values); + } +} diff --git a/core/src/main/java/dev/vml/es/acm/core/code/Outputs.java b/core/src/main/java/dev/vml/es/acm/core/code/Outputs.java new file mode 100644 index 000000000..2941471f8 --- /dev/null +++ b/core/src/main/java/dev/vml/es/acm/core/code/Outputs.java @@ -0,0 +1,45 @@ +package dev.vml.es.acm.core.code; + +import com.fasterxml.jackson.annotation.JsonAnyGetter; +import dev.vml.es.acm.core.util.GroovyUtils; +import groovy.lang.Closure; +import java.io.Serializable; +import java.util.LinkedHashMap; +import java.util.Map; + +public class Outputs implements Serializable { + + private final Map definitions = new LinkedHashMap<>(); + + public Output get(String name) { + Output result = definitions.get(name); + if (result == null) { + throw new IllegalArgumentException(String.format("Output '%s' is not defined!", name)); + } + return result; + } + + private void add(Output output) { + if (definitions.containsKey(output.getName())) { + throw new IllegalArgumentException( + String.format("Output with name '%s' is already defined!", output.getName())); + } + definitions.put(output.getName(), output); + } + + @JsonAnyGetter + public Map getDefinitions() { + return definitions; + } + + public Output make(String name) { + return make(name, null); + } + + public Output make(String name, Closure options) { + Output result = new Output(name); + GroovyUtils.with(result, options); + add(result); + return result; + } +} diff --git a/core/src/main/java/dev/vml/es/acm/core/code/QueuedExecution.java b/core/src/main/java/dev/vml/es/acm/core/code/QueuedExecution.java index a905b6727..17a000550 100644 --- a/core/src/main/java/dev/vml/es/acm/core/code/QueuedExecution.java +++ b/core/src/main/java/dev/vml/es/acm/core/code/QueuedExecution.java @@ -101,4 +101,14 @@ public String toString() { .append("duration", getDuration()) .toString(); } + + @Override + public InputValues getInputs() { + return InputValues.fromJob(job); + } + + @Override + public OutputValues getOutputs() { + return new OutputValues(); // not available at the moment + } } diff --git a/core/src/main/java/dev/vml/es/acm/core/instance/HealthChecker.java b/core/src/main/java/dev/vml/es/acm/core/instance/HealthChecker.java index 837ddedbc..117a6ff93 100644 --- a/core/src/main/java/dev/vml/es/acm/core/instance/HealthChecker.java +++ b/core/src/main/java/dev/vml/es/acm/core/instance/HealthChecker.java @@ -248,6 +248,7 @@ private void checkCodeExecutor(List issues, ResourceResolver resour resourceResolver.getUserID(), ExecutionMode.RUN, Code.consoleMinimal(), + new InputValues(), resourceResolver)) { context.setHistory(false); context.setLocking(false); diff --git a/core/src/main/java/dev/vml/es/acm/core/script/Script.java b/core/src/main/java/dev/vml/es/acm/core/script/Script.java index 1d6cc19ed..df8e975d1 100644 --- a/core/src/main/java/dev/vml/es/acm/core/script/Script.java +++ b/core/src/main/java/dev/vml/es/acm/core/script/Script.java @@ -3,7 +3,6 @@ import com.fasterxml.jackson.annotation.JsonIgnore; import dev.vml.es.acm.core.AcmException; import dev.vml.es.acm.core.code.Executable; -import dev.vml.es.acm.core.code.InputValues; import java.io.IOException; import java.io.InputStream; import java.nio.charset.StandardCharsets; @@ -56,11 +55,6 @@ public String getContent() throws AcmException { } } - @Override - public InputValues getInputs() { - return new InputValues(); - } - @JsonIgnore public String getPath() { return resource.getPath(); diff --git a/core/src/main/java/dev/vml/es/acm/core/script/ScriptScheduler.java b/core/src/main/java/dev/vml/es/acm/core/script/ScriptScheduler.java index a933daf8f..51385fdd7 100644 --- a/core/src/main/java/dev/vml/es/acm/core/script/ScriptScheduler.java +++ b/core/src/main/java/dev/vml/es/acm/core/script/ScriptScheduler.java @@ -234,7 +234,12 @@ public JobResult process(Job job) { private ScheduleResult determineSchedule(Script script, ResourceResolver resourceResolver) { try (ExecutionContext context = executor.createContext( - ExecutionId.generate(), resourceResolver.getUserID(), ExecutionMode.PARSE, script, resourceResolver)) { + ExecutionId.generate(), + resourceResolver.getUserID(), + ExecutionMode.PARSE, + script, + new InputValues(), + resourceResolver)) { return executor.schedule(context); } } @@ -372,7 +377,12 @@ private void cronJob(String scriptPath) { private boolean checkScript(Script script, ResourceResolver resourceResolver) { try (ExecutionContext context = executor.createContext( - ExecutionId.generate(), resourceResolver.getUserID(), ExecutionMode.PARSE, script, resourceResolver)) { + ExecutionId.generate(), + resourceResolver.getUserID(), + ExecutionMode.PARSE, + script, + new InputValues(), + resourceResolver)) { if (executor.isLocked(context)) { LOG.info("Script '{}' already locked!", script.getPath()); return false; @@ -410,7 +420,7 @@ private boolean checkScript(Script script, ResourceResolver resourceResolver) { private void queueScript(Script script) { String userId = StringUtils.defaultIfBlank(config.userImpersonationId(), ResolverUtils.Subservice.CONTENT.userId); - executionQueue.submit(script, new ExecutionContextOptions(ExecutionMode.RUN, userId)); + executionQueue.submit(script, new ExecutionContextOptions(ExecutionMode.RUN, userId, new InputValues())); } private boolean awaitInstanceHealthy(String operation, long retryMaxCount, long retryInterval) { diff --git a/core/src/main/java/dev/vml/es/acm/core/servlet/DescribeCodeServlet.java b/core/src/main/java/dev/vml/es/acm/core/servlet/DescribeCodeServlet.java index c554d2fb4..cc65feeb8 100644 --- a/core/src/main/java/dev/vml/es/acm/core/servlet/DescribeCodeServlet.java +++ b/core/src/main/java/dev/vml/es/acm/core/servlet/DescribeCodeServlet.java @@ -51,6 +51,7 @@ protected void doPost(SlingHttpServletRequest request, SlingHttpServletResponse request.getResourceResolver().getUserID(), ExecutionMode.PARSE, code, + new InputValues(), request.getResourceResolver())) { Description description = executor.describe(context); diff --git a/core/src/main/java/dev/vml/es/acm/core/servlet/ExecuteCodeInput.java b/core/src/main/java/dev/vml/es/acm/core/servlet/ExecuteCodeInput.java index 6b3326dfd..e4786419f 100644 --- a/core/src/main/java/dev/vml/es/acm/core/servlet/ExecuteCodeInput.java +++ b/core/src/main/java/dev/vml/es/acm/core/servlet/ExecuteCodeInput.java @@ -1,8 +1,8 @@ package dev.vml.es.acm.core.servlet; import dev.vml.es.acm.core.code.Code; +import dev.vml.es.acm.core.code.InputValues; import java.io.Serializable; -import java.util.Map; public class ExecuteCodeInput implements Serializable { @@ -12,8 +12,7 @@ public class ExecuteCodeInput implements Serializable { private Code code; - @SuppressWarnings("java:S1948") - private Map inputs; + private InputValues inputs; public ExecuteCodeInput() { // for deserialization @@ -31,7 +30,7 @@ public Code getCode() { return code; } - public Map getInputs() { + public InputValues getInputs() { return inputs; } } diff --git a/core/src/main/java/dev/vml/es/acm/core/servlet/ExecuteCodeServlet.java b/core/src/main/java/dev/vml/es/acm/core/servlet/ExecuteCodeServlet.java index 7848d468b..d0ef6c15a 100644 --- a/core/src/main/java/dev/vml/es/acm/core/servlet/ExecuteCodeServlet.java +++ b/core/src/main/java/dev/vml/es/acm/core/servlet/ExecuteCodeServlet.java @@ -61,6 +61,7 @@ protected void doPost(SlingHttpServletRequest request, SlingHttpServletResponse request.getResourceResolver().getUserID(), mode, code, + input.getInputs(), request.getResourceResolver())) { if (input.getHistory() != null) { context.setHistory(input.getHistory()); diff --git a/core/src/main/java/dev/vml/es/acm/core/servlet/ExecuteScriptServlet.java b/core/src/main/java/dev/vml/es/acm/core/servlet/ExecuteScriptServlet.java index 422f27e1b..77e4dc3ba 100644 --- a/core/src/main/java/dev/vml/es/acm/core/servlet/ExecuteScriptServlet.java +++ b/core/src/main/java/dev/vml/es/acm/core/servlet/ExecuteScriptServlet.java @@ -55,6 +55,7 @@ protected void doPost(SlingHttpServletRequest request, SlingHttpServletResponse request.getResourceResolver().getUserID(), ExecutionMode.RUN, script, + new InputValues(), request.getResourceResolver())) { Execution execution = executor.execute(context); diff --git a/core/src/main/java/dev/vml/es/acm/core/servlet/ExecutionOutput.java b/core/src/main/java/dev/vml/es/acm/core/servlet/ExecutionOutput.java index 82e7f4621..5e6002510 100644 --- a/core/src/main/java/dev/vml/es/acm/core/servlet/ExecutionOutput.java +++ b/core/src/main/java/dev/vml/es/acm/core/servlet/ExecutionOutput.java @@ -1,10 +1,33 @@ package dev.vml.es.acm.core.servlet; import java.io.Serializable; +import java.util.Arrays; import java.util.List; +import java.util.Optional; public class ExecutionOutput implements Serializable { + public enum Name { + ARCHIVE("acm-archive"), + CONSOLE("acm-console"); + + private final String id; + + Name(String id) { + this.id = id; + } + + public String id() { + return id; + } + + public static Optional byId(String name) { + return Arrays.stream(values()) + .filter(n -> n.id().equalsIgnoreCase(name)) + .findFirst(); + } + } + @SuppressWarnings("java:S1948") private List list; diff --git a/core/src/main/java/dev/vml/es/acm/core/servlet/ExecutionOutputServlet.java b/core/src/main/java/dev/vml/es/acm/core/servlet/ExecutionOutputServlet.java new file mode 100644 index 000000000..a7ad344af --- /dev/null +++ b/core/src/main/java/dev/vml/es/acm/core/servlet/ExecutionOutputServlet.java @@ -0,0 +1,155 @@ +package dev.vml.es.acm.core.servlet; + +import static dev.vml.es.acm.core.util.ServletResult.*; +import static dev.vml.es.acm.core.util.ServletUtils.*; + +import dev.vml.es.acm.core.code.*; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; +import javax.servlet.Servlet; +import org.apache.commons.io.IOUtils; +import org.apache.commons.lang3.StringUtils; +import org.apache.sling.api.SlingHttpServletRequest; +import org.apache.sling.api.SlingHttpServletResponse; +import org.apache.sling.api.servlets.ServletResolverConstants; +import org.apache.sling.api.servlets.SlingAllMethodsServlet; +import org.osgi.service.component.annotations.Component; +import org.osgi.service.component.annotations.Reference; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +@Component( + immediate = true, + service = Servlet.class, + property = { + ServletResolverConstants.SLING_SERVLET_METHODS + "=GET", + ServletResolverConstants.SLING_SERVLET_EXTENSIONS + "=json", + ServletResolverConstants.SLING_SERVLET_RESOURCE_TYPES + "=" + ExecutionOutputServlet.RT + }) +public class ExecutionOutputServlet extends SlingAllMethodsServlet { + + public static final String RT = "acm/api/execution-output"; + + private static final Logger LOG = LoggerFactory.getLogger(ExecutionOutputServlet.class); + + private static final String EXECUTION_ID_PARAM = "executionId"; + + private static final String NAME_PARAM = "name"; + + @Reference + private transient ExecutionQueue queue; + + @Override + protected void doGet(SlingHttpServletRequest request, SlingHttpServletResponse response) throws IOException { + String id = stringParam(request, EXECUTION_ID_PARAM); + if (id == null) { + respondJson(response, badRequest(String.format("Execution ID is not specified!", EXECUTION_ID_PARAM))); + return; + } + String name = stringParam(request, NAME_PARAM); + if (name == null) { + respondJson(response, badRequest(String.format("Execution output name is not specified!", NAME_PARAM))); + return; + } + + try { + ExecutionResolver executionResolver = new ExecutionResolver(queue, request.getResourceResolver()); + ExecutionHistory executionHistory = new ExecutionHistory(request.getResourceResolver()); + Execution execution = executionResolver.resolve(id).orElse(null); + if (execution == null) { + respondJson(response, notFound(String.format("Execution with id '%s' not found!", id))); + return; + } + + ExecutionOutput.Name outputName = ExecutionOutput.Name.byId(name).orElse(null); + if (outputName != null) { + // Predefined outputs + switch (outputName) { + case ARCHIVE: + respondArchive(response, execution, executionHistory); + break; + case CONSOLE: + respondConsole(response, execution); + break; + } + } else { + // Dynamic output + Output output = execution.getOutputs().stream() + .filter(o -> o.getName().equals(name)) + .findFirst() + .orElse(null); + if (output == null) { + respondJson( + response, + notFound(String.format("Execution output '%s' not found in execution '%s'!", name, id))); + return; + } + respondOutput(response, name, executionHistory, execution, output); + } + } catch (Exception e) { + LOG.error("Execution output '{}' cannot be read for execution '{}'", name, id, e); + respondJson( + response, + error(String.format( + "Execution output '%s' cannot be read for execution '%s'! Error: %s", + name, id, e.getMessage()) + .trim())); + } + } + + private void respondConsole(SlingHttpServletResponse response, Execution execution) throws IOException { + respondDownload( + response, "text/plain", String.format("execution-%s.console.log", executionFileName(execution))); + InputStream consoleStream = + new ByteArrayInputStream(execution.getOutput().getBytes()); + IOUtils.copy(consoleStream, response.getOutputStream()); + } + + private void respondArchive( + SlingHttpServletResponse response, Execution execution, ExecutionHistory executionHistory) + throws IOException { + respondDownload( + response, "application/zip", String.format("execution-%s.outputs.zip", executionFileName(execution))); + try (ZipOutputStream zipStream = new ZipOutputStream(response.getOutputStream())) { + // Console log + ZipEntry consoleEntry = new ZipEntry("console.log"); + zipStream.putNextEntry(consoleEntry); + zipStream.write(execution.getOutput().getBytes()); + zipStream.closeEntry(); + + // Dynamic outputs + for (Output output : execution.getOutputs()) { + ZipEntry outputEntry = new ZipEntry(output.getDownloadName()); + zipStream.putNextEntry(outputEntry); + try (InputStream outputStream = executionHistory.readOutputByName(execution, output.getName())) { + IOUtils.copy(outputStream, zipStream); + } + zipStream.closeEntry(); + } + } + } + + private void respondOutput( + SlingHttpServletResponse response, + String name, + ExecutionHistory executionHistory, + Execution execution, + Output output) + throws IOException { + respondDownload(response, output.getMimeType(), output.getDownloadName()); + InputStream inputStream = executionHistory.readOutputByName(execution, name); + IOUtils.copy(inputStream, response.getOutputStream()); + } + + private void respondDownload(SlingHttpServletResponse response, String mimeType, String fileName) { + response.setContentType(mimeType); + response.setHeader("Content-Disposition", String.format("attachment; filename=\"%s\"", fileName)); + } + + private String executionFileName(Execution execution) { + return StringUtils.replace(execution.getId(), "/", "-"); + } +} diff --git a/core/src/main/java/dev/vml/es/acm/core/servlet/ExecutionServlet.java b/core/src/main/java/dev/vml/es/acm/core/servlet/ExecutionServlet.java index f33e1d365..3dcc833ed 100644 --- a/core/src/main/java/dev/vml/es/acm/core/servlet/ExecutionServlet.java +++ b/core/src/main/java/dev/vml/es/acm/core/servlet/ExecutionServlet.java @@ -63,9 +63,9 @@ protected void doGet(SlingHttpServletRequest request, SlingHttpServletResponse r if (ids != null) { ExecutionResolver executionResolver = new ExecutionResolver(queue, request.getResourceResolver()); if (format == ExecutionFormat.FULL) { - executionStream = executionResolver.readAll(ids); + executionStream = executionResolver.resolveAll(ids); } else { - executionStream = executionResolver.readAllSummaries(ids); + executionStream = executionResolver.resolveAllSummaries(ids); } } else { ExecutionQuery criteria = ExecutionQuery.from(request); diff --git a/core/src/main/java/dev/vml/es/acm/core/servlet/QueueCodeInput.java b/core/src/main/java/dev/vml/es/acm/core/servlet/QueueCodeInput.java index 5bdaed6fa..6aa8896be 100644 --- a/core/src/main/java/dev/vml/es/acm/core/servlet/QueueCodeInput.java +++ b/core/src/main/java/dev/vml/es/acm/core/servlet/QueueCodeInput.java @@ -1,12 +1,15 @@ package dev.vml.es.acm.core.servlet; import dev.vml.es.acm.core.code.Code; +import dev.vml.es.acm.core.code.InputValues; import java.io.Serializable; public class QueueCodeInput implements Serializable { private Code code; + private InputValues inputs; + public QueueCodeInput() { // for deserialization } @@ -14,4 +17,8 @@ public QueueCodeInput() { public Code getCode() { return code; } + + public InputValues getInputs() { + return inputs; + } } diff --git a/core/src/main/java/dev/vml/es/acm/core/servlet/QueueCodeServlet.java b/core/src/main/java/dev/vml/es/acm/core/servlet/QueueCodeServlet.java index 38177cd57..2ea349710 100644 --- a/core/src/main/java/dev/vml/es/acm/core/servlet/QueueCodeServlet.java +++ b/core/src/main/java/dev/vml/es/acm/core/servlet/QueueCodeServlet.java @@ -59,6 +59,7 @@ protected void doPost(SlingHttpServletRequest request, SlingHttpServletResponse request.getResourceResolver().getUserID(), ExecutionMode.CHECK, input.getCode(), + input.getInputs(), request.getResourceResolver())) { Execution checkExecution = executor.execute(context); if (checkExecution.getStatus() == ExecutionStatus.SKIPPED) { @@ -76,7 +77,7 @@ protected void doPost(SlingHttpServletRequest request, SlingHttpServletResponse } ExecutionContextOptions contextOptions = new ExecutionContextOptions( - ExecutionMode.RUN, request.getResourceResolver().getUserID()); + ExecutionMode.RUN, request.getResourceResolver().getUserID(), input.getInputs()); Execution execution = executionQueue.submit(code, contextOptions); QueueOutput output = new QueueOutput(Collections.singletonList(execution)); @@ -103,7 +104,7 @@ protected void doGet(SlingHttpServletRequest request, SlingHttpServletResponse r } else { ExecutionResolver executionResolver = new ExecutionResolver(executionQueue, request.getResourceResolver()); - executions = executionResolver.readAll(executionIds).collect(Collectors.toList()); + executions = executionResolver.resolveAll(executionIds).collect(Collectors.toList()); if (executions.isEmpty()) { respondJson( response, diff --git a/docs/screenshot-content-script-arguments.png b/docs/screenshot-content-script-inputs.png similarity index 100% rename from docs/screenshot-content-script-arguments.png rename to docs/screenshot-content-script-inputs.png diff --git a/docs/screenshot-content-script-outputs.png b/docs/screenshot-content-script-outputs.png new file mode 100644 index 000000000..d385245bd Binary files /dev/null and b/docs/screenshot-content-script-outputs.png differ diff --git a/ui.apps/src/main/content/jcr_root/apps/acm/api/execution-output/.content.xml b/ui.apps/src/main/content/jcr_root/apps/acm/api/execution-output/.content.xml new file mode 100644 index 000000000..d7f2b54b1 --- /dev/null +++ b/ui.apps/src/main/content/jcr_root/apps/acm/api/execution-output/.content.xml @@ -0,0 +1,5 @@ + + diff --git a/ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/manual/example/ACME-203_outputs.groovy b/ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/manual/example/ACME-203_outputs.groovy new file mode 100644 index 000000000..5dbdd6416 --- /dev/null +++ b/ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/manual/example/ACME-203_outputs.groovy @@ -0,0 +1,24 @@ +boolean canRun() { + return conditions.always() +} + +void doRun() { + log.info "Users report generation started" + + def report = outputs.make("report") { + label = "Report" + description = "Users report generated as CSV file" + downloadName = "report.csv" + } + + def users = [ + [name: "John", surname: "Doe", birth: "1991"], + [name: "Jane", surname: "Doe", birth: "1995"], + [name: "Jack", surname: "Doe", birth: "2000"] + ] + for (def user : users) { + report.out.println("${user.name},${user.surname},${user.birth}") + } + + log.info "Users report generation ended successfully" +} \ No newline at end of file diff --git a/ui.content/src/main/content/jcr_root/conf/acm/settings/snippet/available/core/general/demo_processing.yml b/ui.content/src/main/content/jcr_root/conf/acm/settings/snippet/available/core/general/demo_processing.yml index 1c6bc072d..1e2e4f4dc 100644 --- a/ui.content/src/main/content/jcr_root/conf/acm/settings/snippet/available/core/general/demo_processing.yml +++ b/ui.content/src/main/content/jcr_root/conf/acm/settings/snippet/available/core/general/demo_processing.yml @@ -9,9 +9,9 @@ content: | println "Processing..." println "Updating resources..." - def max = 10 + def max = 20 for (int i = 0; i < max; i++) { - Thread.sleep(500) + Thread.sleep(1000) println "Updated (\${i + 1}/\${max})" } diff --git a/ui.frontend/src/components/ExecutionDownloadOutputsButton.tsx b/ui.frontend/src/components/ExecutionDownloadOutputsButton.tsx new file mode 100644 index 000000000..a6b0ac949 --- /dev/null +++ b/ui.frontend/src/components/ExecutionDownloadOutputsButton.tsx @@ -0,0 +1,136 @@ +import { Button, ButtonGroup, Content, Dialog, DialogContainer, Divider, Flex, Heading, Text, View, Well } from '@adobe/react-spectrum'; +import { ToastQueue } from '@react-spectrum/toast'; +import Close from '@spectrum-icons/workflow/Close'; +import Download from '@spectrum-icons/workflow/Download'; +import FolderArchive from '@spectrum-icons/workflow/FolderArchive'; +import Help from '@spectrum-icons/workflow/Help'; +import Print from '@spectrum-icons/workflow/Print'; +import React, { useState } from 'react'; +import { Execution, Output, OutputNames } from '../utils/api.types.ts'; +import { ToastTimeoutQuick } from '../utils/spectrum.ts'; + +interface ExecutionDownloadOutputsButtonProps extends Omit, 'onPress'> { + execution: Execution; +} + +const ExecutionDownloadOutputsButton: React.FC = ({ execution, ...buttonProps }) => { + const [dialogOpen, setDialogOpen] = useState(false); + + const outputs = execution.outputs || {}; + const outputEntries = Object.entries(outputs); + + const handleOpenDialog = () => { + setDialogOpen(true); + }; + + const handleCloseDialog = () => { + setDialogOpen(false); + }; + + const downloadFile = (url: string) => { + const link = document.createElement('a'); + link.href = url; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + }; + + const handleDownloadSingle = (output: Output) => { + const downloadUrl = `/apps/acm/api/execution-output.json?executionId=${encodeURIComponent(execution.id)}&name=${encodeURIComponent(output.name)}`; + downloadFile(downloadUrl); + ToastQueue.info(`Downloading ${output.label || output.name}...`, { timeout: ToastTimeoutQuick }); + handleCloseDialog(); + }; + + const handleDownloadAll = () => { + const downloadUrl = `/apps/acm/api/execution-output.json?executionId=${encodeURIComponent(execution.id)}&name=${OutputNames.ARCHIVE}`; + downloadFile(downloadUrl); + ToastQueue.info('Downloading complete archive...', { timeout: ToastTimeoutQuick }); + handleCloseDialog(); + }; + + const handleDownloadConsole = () => { + const downloadUrl = `/apps/acm/api/execution-output.json?executionId=${encodeURIComponent(execution.id)}&name=${OutputNames.CONSOLE}`; + downloadFile(downloadUrl); + ToastQueue.info('Downloading console output...', { timeout: ToastTimeoutQuick }); + handleCloseDialog(); + }; + + return ( + <> + + + {dialogOpen && ( + + Download Outputs + + + + + + + + Complete Archive + Console and generated outputs bundled as ZIP archive + + + + + + + + + Console + Execution logs and errors as text file + + + + + + {outputEntries.map(([key, output]) => ( + + + + {output.label || output.name} + {output.description && {output.description}} + + + + + ))} + {outputEntries.length === 0 && ( + + + No additional outputs generated by this execution. + + + )} + + + + + + + + )} + + + ); +}; + +export default ExecutionDownloadOutputsButton; diff --git a/ui.frontend/src/pages/ConsolePage.tsx b/ui.frontend/src/pages/ConsolePage.tsx index 777c01208..ca9e705c9 100644 --- a/ui.frontend/src/pages/ConsolePage.tsx +++ b/ui.frontend/src/pages/ConsolePage.tsx @@ -10,6 +10,7 @@ import CompilationStatus from '../components/CompilationStatus'; import ConsoleHelpButton from '../components/ConsoleHelpButton'; import ExecutionAbortButton from '../components/ExecutionAbortButton'; import ExecutionCopyOutputButton from '../components/ExecutionCopyOutputButton'; +import ExecutionDownloadOutputsButton from '../components/ExecutionDownloadOutputsButton.tsx'; import ExecutionProgressBar from '../components/ExecutionProgressBar'; import KeyboardShortcutsButton from '../components/KeyboardShortcutsButton'; import ScriptExecutorStatusLight from '../components/ScriptExecutorStatusLight'; @@ -30,8 +31,9 @@ const ConsolePage = () => { const [selectedTab, setSelectedTab] = useState<'code' | 'output'>('code'); const [code, setCode] = useState(() => localStorage.getItem(StorageKeys.EDITOR_CODE) || undefined); - const [compiling, pendingCompile, syntaxError, compileError, parseExecution] = useCompilation(code, (newCode) => localStorage.setItem(StorageKeys.EDITOR_CODE, newCode)); + const [compiling, pendingCompile, syntaxError, compileError, compileExecution] = useCompilation(code, (newCode) => localStorage.setItem(StorageKeys.EDITOR_CODE, newCode)); const [queuedExecution, setQueuedExecution] = useState(null); + const [executionType, setExecutionType] = useState<'queued' | 'compile'>('compile'); const { execution, setExecution, executing, setExecuting } = useExecutionPolling(queuedExecution?.id, appState.spaSettings.executionPollInterval); const [autoscroll, setAutoscroll] = useState(true); @@ -63,8 +65,9 @@ const ConsolePage = () => { }, [code]); useEffect(() => { - setExecution(parseExecution); - }, [parseExecution, setExecution]); + setExecutionType('compile'); + setExecution(compileExecution); + }, [compileExecution, setExecution]); useEffect(() => { if (!isExecutionPending(queuedExecution?.status)) { @@ -92,12 +95,13 @@ const ConsolePage = () => { code: { id: ExecutableIdConsole, content: code, - inputs: inputs, }, + inputs: inputs, }, }); const queuedExecution = response.data.data.executions[0]!; setQueuedExecution(queuedExecution); + setExecutionType('queued'); setExecution(queuedExecution); setSelectedTab('output'); } catch (error) { @@ -150,7 +154,12 @@ const ConsolePage = () => { - + + + + + + diff --git a/ui.frontend/src/pages/ExecutionView.tsx b/ui.frontend/src/pages/ExecutionView.tsx index 018b37c4b..8633a8fd1 100644 --- a/ui.frontend/src/pages/ExecutionView.tsx +++ b/ui.frontend/src/pages/ExecutionView.tsx @@ -14,8 +14,10 @@ import CodeEditor from '../components/CodeEditor.tsx'; import ExecutableIdValue from '../components/ExecutableIdValue'; import ExecutionAbortButton from '../components/ExecutionAbortButton'; import ExecutionCopyOutputButton from '../components/ExecutionCopyOutputButton'; +import ExecutionDownloadOutputsButton from '../components/ExecutionDownloadOutputsButton.tsx'; import ExecutionProgressBar from '../components/ExecutionProgressBar'; import ExecutionStatusBadge from '../components/ExecutionStatusBadge'; +import Toggle from '../components/Toggle.tsx'; import { useAppState } from '../hooks/app.ts'; import { useExecutionPolling } from '../hooks/execution'; import { useFormatter } from '../hooks/formatter'; @@ -87,6 +89,16 @@ const ExecutionView = () => { + + + + + + + @@ -116,14 +128,14 @@ const ExecutionView = () => {
- {Objects.isEmpty(execution.executable.inputs) ? ( + {Objects.isEmpty(execution.inputs) ? ( Not described ) : ( - + )}
@@ -152,14 +164,13 @@ const ExecutionView = () => { - + + + + + + - {isExecutableScript(execution.executable.id) && ( - - )} diff --git a/ui.frontend/src/pages/ScriptView.tsx b/ui.frontend/src/pages/ScriptView.tsx index ac2a1ec69..85237ca35 100644 --- a/ui.frontend/src/pages/ScriptView.tsx +++ b/ui.frontend/src/pages/ScriptView.tsx @@ -100,8 +100,8 @@ const ScriptView = () => { code: { id: script.id, content: script.content, - inputs: inputs, }, + inputs: inputs, }, }); const queuedExecution = response.data.data.executions[0]!; @@ -147,7 +147,7 @@ const ScriptView = () => { } > - View in history + View history )} diff --git a/ui.frontend/src/utils/api.types.ts b/ui.frontend/src/utils/api.types.ts index 9e4392c40..8176ce4d5 100644 --- a/ui.frontend/src/utils/api.types.ts +++ b/ui.frontend/src/utils/api.types.ts @@ -3,7 +3,6 @@ import { isProduction } from './node.ts'; export type Executable = { id: string; content: string; - inputs: InputValues; }; export const ScriptRoot = '/conf/acm/settings/script'; @@ -59,6 +58,23 @@ export type Input = { validator?: string; }; +export type Outputs = { + [key: string]: Output; +}; + +export type Output = { + name: string; + label: string; + description?: string; + mimeType: string; + downloadName: string; +}; + +export const OutputNames = { + ARCHIVE: 'acm-archive', + CONSOLE: 'acm-console', +} as const; + export type MinMaxInput = Input & { min: number; max: number; @@ -207,6 +223,8 @@ export type Execution = { duration: number; output: string; error: string | null; + inputs: InputValues; + outputs: Outputs; }; export const UserIdServicePrefix = 'acm-';