From 588090e84099e69510294126c5d8dedaa7f837e6 Mon Sep 17 00:00:00 2001 From: Francisco Javier Tirado Sarti <65240126+fjtirado@users.noreply.github.com> Date: Wed, 5 Nov 2025 10:05:12 +0100 Subject: [PATCH 1/9] [Fix #933] Adding timeout support (#963) Signed-off-by: fjtirado Signed-off-by: Dmitrii Tikhomirov --- .../impl/WorkflowError.java | 4 ++ .../impl/executors/AbstractTaskExecutor.java | 58 ++++++++++++++++++- .../{RetryTest.java => RetryTimeoutTest.java} | 17 +++++- .../listen-to-one-timeout.yaml | 26 +++++++++ 4 files changed, 101 insertions(+), 4 deletions(-) rename impl/test/src/test/java/io/serverlessworkflow/impl/test/{RetryTest.java => RetryTimeoutTest.java} (87%) create mode 100644 impl/test/src/test/resources/workflows-samples/listen-to-one-timeout.yaml diff --git a/impl/core/src/main/java/io/serverlessworkflow/impl/WorkflowError.java b/impl/core/src/main/java/io/serverlessworkflow/impl/WorkflowError.java index a23c51dd..bb44f3e6 100644 --- a/impl/core/src/main/java/io/serverlessworkflow/impl/WorkflowError.java +++ b/impl/core/src/main/java/io/serverlessworkflow/impl/WorkflowError.java @@ -56,6 +56,10 @@ public static Builder runtime(TaskContext context, Exception ex) { return runtime(Errors.RUNTIME.status(), context, ex); } + public static Builder timeout() { + return error(Errors.TIMEOUT.toString(), Errors.TIMEOUT.status()); + } + public static class Builder { private final String type; diff --git a/impl/core/src/main/java/io/serverlessworkflow/impl/executors/AbstractTaskExecutor.java b/impl/core/src/main/java/io/serverlessworkflow/impl/executors/AbstractTaskExecutor.java index 30956970..055cbba6 100644 --- a/impl/core/src/main/java/io/serverlessworkflow/impl/executors/AbstractTaskExecutor.java +++ b/impl/core/src/main/java/io/serverlessworkflow/impl/executors/AbstractTaskExecutor.java @@ -24,17 +24,23 @@ import io.serverlessworkflow.api.types.Input; import io.serverlessworkflow.api.types.Output; import io.serverlessworkflow.api.types.TaskBase; +import io.serverlessworkflow.api.types.TaskTimeout; +import io.serverlessworkflow.api.types.Timeout; import io.serverlessworkflow.api.types.Workflow; import io.serverlessworkflow.impl.TaskContext; import io.serverlessworkflow.impl.WorkflowApplication; import io.serverlessworkflow.impl.WorkflowContext; import io.serverlessworkflow.impl.WorkflowDefinition; +import io.serverlessworkflow.impl.WorkflowError; +import io.serverlessworkflow.impl.WorkflowException; import io.serverlessworkflow.impl.WorkflowFilter; import io.serverlessworkflow.impl.WorkflowModel; import io.serverlessworkflow.impl.WorkflowMutablePosition; import io.serverlessworkflow.impl.WorkflowPosition; import io.serverlessworkflow.impl.WorkflowPredicate; import io.serverlessworkflow.impl.WorkflowStatus; +import io.serverlessworkflow.impl.WorkflowUtils; +import io.serverlessworkflow.impl.WorkflowValueResolver; import io.serverlessworkflow.impl.lifecycle.TaskCancelledEvent; import io.serverlessworkflow.impl.lifecycle.TaskCompletedEvent; import io.serverlessworkflow.impl.lifecycle.TaskFailedEvent; @@ -42,13 +48,16 @@ import io.serverlessworkflow.impl.lifecycle.TaskStartedEvent; import io.serverlessworkflow.impl.resources.ResourceLoader; import io.serverlessworkflow.impl.schema.SchemaValidator; +import java.time.Duration; import java.time.Instant; import java.util.Iterator; import java.util.Map; +import java.util.Objects; import java.util.Optional; import java.util.concurrent.CancellationException; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionException; +import java.util.concurrent.TimeUnit; public abstract class AbstractTaskExecutor implements TaskExecutor { @@ -62,6 +71,7 @@ public abstract class AbstractTaskExecutor implements TaskEx private final Optional outputSchemaValidator; private final Optional contextSchemaValidator; private final Optional ifFilter; + private final Optional> timeout; public abstract static class AbstractTaskExecutorBuilder< T extends TaskBase, V extends AbstractTaskExecutor> @@ -80,6 +90,7 @@ public abstract static class AbstractTaskExecutorBuilder< protected final Workflow workflow; protected final ResourceLoader resourceLoader; private final WorkflowDefinition definition; + private final Optional> timeout; private V instance; @@ -113,6 +124,28 @@ protected AbstractTaskExecutorBuilder( getSchemaValidator(application.validatorFactory(), resourceLoader, export.getSchema()); } this.ifFilter = application.expressionFactory().buildIfFilter(task); + this.timeout = getTaskTimeout(); + } + + private Optional> getTaskTimeout() { + TaskTimeout timeout = task.getTimeout(); + if (timeout == null) { + return Optional.empty(); + } + Timeout timeoutDef = timeout.getTaskTimeoutDefinition(); + if (timeoutDef == null && timeout.getTaskTimeoutReference() != null) { + timeoutDef = + Objects.requireNonNull( + Objects.requireNonNull( + workflow.getUse().getTimeouts(), + "Timeout reference " + + timeout.getTaskTimeoutReference() + + " specified, but use timeout was not defined") + .getAdditionalProperties() + .get(timeout.getTaskTimeoutReference()), + "Timeout reference " + timeout.getTaskTimeoutReference() + "cannot be found"); + } + return Optional.of(WorkflowUtils.fromTimeoutAfter(application, timeoutDef.getAfter())); } protected final TransitionInfoBuilder next( @@ -171,6 +204,7 @@ protected AbstractTaskExecutor(AbstractTaskExecutorBuilder builder) { this.outputSchemaValidator = builder.outputSchemaValidator; this.contextSchemaValidator = builder.contextSchemaValidator; this.ifFilter = builder.ifFilter; + this.timeout = builder.timeout; } protected final CompletableFuture executeNext( @@ -200,7 +234,7 @@ public CompletableFuture apply( } else if (taskContext.isCompleted()) { return executeNext(completable, workflowContext); } else if (ifFilter.map(f -> f.test(workflowContext, taskContext, input)).orElse(true)) { - return executeNext( + completable = completable .thenCompose(workflowContext.instance()::suspendedCheck) .thenApply( @@ -247,8 +281,26 @@ public CompletableFuture apply( l.onTaskCompleted( new TaskCompletedEvent(workflowContext, taskContext))); return t; - }), - workflowContext); + }); + if (timeout.isPresent()) { + completable = + completable + .orTimeout( + timeout + .map(t -> t.apply(workflowContext, taskContext, input)) + .orElseThrow() + .toMillis(), + TimeUnit.MILLISECONDS) + .exceptionallyCompose( + e -> + CompletableFuture.failedFuture( + new WorkflowException( + WorkflowError.timeout() + .instance(taskContext.position().jsonPointer()) + .build(), + e))); + } + return executeNext(completable, workflowContext); } else { taskContext.transition(getSkipTransition()); return executeNext(completable, workflowContext); diff --git a/impl/test/src/test/java/io/serverlessworkflow/impl/test/RetryTest.java b/impl/test/src/test/java/io/serverlessworkflow/impl/test/RetryTimeoutTest.java similarity index 87% rename from impl/test/src/test/java/io/serverlessworkflow/impl/test/RetryTest.java rename to impl/test/src/test/java/io/serverlessworkflow/impl/test/RetryTimeoutTest.java index 76846272..586cc018 100644 --- a/impl/test/src/test/java/io/serverlessworkflow/impl/test/RetryTest.java +++ b/impl/test/src/test/java/io/serverlessworkflow/impl/test/RetryTimeoutTest.java @@ -16,7 +16,9 @@ package io.serverlessworkflow.impl.test; import static io.serverlessworkflow.api.WorkflowReader.readWorkflowFromClasspath; +import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.Assert.assertThat; import com.fasterxml.jackson.databind.JsonNode; import io.serverlessworkflow.impl.WorkflowApplication; @@ -38,7 +40,7 @@ import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.ValueSource; -public class RetryTest { +public class RetryTimeoutTest { private static WorkflowApplication app; private MockWebServer apiServer; @@ -106,4 +108,17 @@ void testRetryEnd() throws IOException { .join()) .hasCauseInstanceOf(WorkflowException.class); } + + @Test + void testTimeout() throws IOException { + Map result = + app.workflowDefinition( + readWorkflowFromClasspath("workflows-samples/listen-to-one-timeout.yaml")) + .instance(Map.of("delay", 0.01f)) + .start() + .join() + .asMap() + .orElseThrow(); + assertThat(result.get("message")).isEqualTo("Viva er Beti Balompie"); + } } diff --git a/impl/test/src/test/resources/workflows-samples/listen-to-one-timeout.yaml b/impl/test/src/test/resources/workflows-samples/listen-to-one-timeout.yaml new file mode 100644 index 00000000..2f3845a9 --- /dev/null +++ b/impl/test/src/test/resources/workflows-samples/listen-to-one-timeout.yaml @@ -0,0 +1,26 @@ +document: + dsl: '1.0.0-alpha5' + namespace: test + name: listen-to-one-timeout + version: '0.1.0' +do: + - tryListen: + try: + - waitingNotForever: + listen: + to: + one: + with: + type: neven-happening-event + timeout: + after: ${"PT\(.delay)S"} + catch: + errors: + with: + type: https://serverlessworkflow.io/spec/1.0.0/errors/timeout + status: 408 + do: + - setMessage: + set: + message: Viva er Beti Balompie + \ No newline at end of file From 83f6b680c0de4aa67e6aad5ddf30c1b9dd7f456c Mon Sep 17 00:00:00 2001 From: Francisco Javier Tirado Sarti <65240126+fjtirado@users.noreply.github.com> Date: Fri, 7 Nov 2025 17:35:30 +0100 Subject: [PATCH 2/9] [Fix #932] Workflow scheduler (#966) Signed-off-by: fjtirado Signed-off-by: Dmitrii Tikhomirov --- impl/core/pom.xml | 4 + .../impl/SchedulerListener.java | 69 +++++++++++ .../impl/WorkflowApplication.java | 12 +- .../impl/WorkflowDefinition.java | 60 ++++++++-- .../impl/scheduler/Cancellable.java | 20 ++++ .../impl/scheduler/CronResolver.java | 23 ++++ .../impl/scheduler/CronResolverFactory.java | 20 ++++ .../impl/scheduler/CronUtilsResolver.java | 36 ++++++ .../scheduler/CronUtilsResolverFactory.java | 38 ++++++ .../scheduler/DefaultWorkflowScheduler.java | 113 ++++++++++++++++-- .../scheduler/ScheduledEventConsumer.java | 20 ++-- .../scheduler/ScheduledInstanceRunnable.java | 44 +++++++ .../ScheduledServiceCancellable.java | 32 +++++ .../{ => scheduler}/WorkflowScheduler.java | 22 +++- impl/pom.xml | 6 + .../impl/test/SchedulerTest.java | 84 +++++++++++++ .../workflows-samples/after-start.yaml | 12 ++ .../workflows-samples/cron-start.yaml | 11 ++ .../workflows-samples/every-start.yaml | 12 ++ 19 files changed, 604 insertions(+), 34 deletions(-) create mode 100644 impl/core/src/main/java/io/serverlessworkflow/impl/SchedulerListener.java create mode 100644 impl/core/src/main/java/io/serverlessworkflow/impl/scheduler/Cancellable.java create mode 100644 impl/core/src/main/java/io/serverlessworkflow/impl/scheduler/CronResolver.java create mode 100644 impl/core/src/main/java/io/serverlessworkflow/impl/scheduler/CronResolverFactory.java create mode 100644 impl/core/src/main/java/io/serverlessworkflow/impl/scheduler/CronUtilsResolver.java create mode 100644 impl/core/src/main/java/io/serverlessworkflow/impl/scheduler/CronUtilsResolverFactory.java create mode 100644 impl/core/src/main/java/io/serverlessworkflow/impl/scheduler/ScheduledInstanceRunnable.java create mode 100644 impl/core/src/main/java/io/serverlessworkflow/impl/scheduler/ScheduledServiceCancellable.java rename impl/core/src/main/java/io/serverlessworkflow/impl/{ => scheduler}/WorkflowScheduler.java (54%) create mode 100644 impl/test/src/test/java/io/serverlessworkflow/impl/test/SchedulerTest.java create mode 100644 impl/test/src/test/resources/workflows-samples/after-start.yaml create mode 100644 impl/test/src/test/resources/workflows-samples/cron-start.yaml create mode 100644 impl/test/src/test/resources/workflows-samples/every-start.yaml diff --git a/impl/core/pom.xml b/impl/core/pom.xml index 0ee45fe9..6fb99f6d 100644 --- a/impl/core/pom.xml +++ b/impl/core/pom.xml @@ -24,5 +24,9 @@ de.huxhorn.sulky de.huxhorn.sulky.ulid + + com.cronutils + cron-utils + diff --git a/impl/core/src/main/java/io/serverlessworkflow/impl/SchedulerListener.java b/impl/core/src/main/java/io/serverlessworkflow/impl/SchedulerListener.java new file mode 100644 index 00000000..2aa57694 --- /dev/null +++ b/impl/core/src/main/java/io/serverlessworkflow/impl/SchedulerListener.java @@ -0,0 +1,69 @@ +/* + * Copyright 2020-Present The Serverless Workflow Specification Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.serverlessworkflow.impl; + +import io.serverlessworkflow.impl.lifecycle.WorkflowCompletedEvent; +import io.serverlessworkflow.impl.lifecycle.WorkflowExecutionListener; +import io.serverlessworkflow.impl.scheduler.Cancellable; +import io.serverlessworkflow.impl.scheduler.WorkflowScheduler; +import java.time.Duration; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +class SchedulerListener implements WorkflowExecutionListener, AutoCloseable { + + private final WorkflowScheduler scheduler; + private final Map> afterMap = + new ConcurrentHashMap<>(); + private final Map cancellableMap = new ConcurrentHashMap<>(); + + public SchedulerListener(WorkflowScheduler scheduler) { + this.scheduler = scheduler; + } + + public void addAfter(WorkflowDefinition definition, WorkflowValueResolver after) { + afterMap.put(definition, after); + } + + @Override + public void onWorkflowCompleted(WorkflowCompletedEvent ev) { + WorkflowDefinition workflowDefinition = (WorkflowDefinition) ev.workflowContext().definition(); + WorkflowValueResolver after = afterMap.get(workflowDefinition); + if (after != null) { + cancellableMap.put( + workflowDefinition, + scheduler.scheduleAfter( + workflowDefinition, + after.apply((WorkflowContext) ev.workflowContext(), null, ev.output()))); + } + } + + public void removeAfter(WorkflowDefinition definition) { + if (afterMap.remove(definition) != null) { + Cancellable cancellable = cancellableMap.remove(definition); + if (cancellable != null) { + cancellable.cancel(); + } + } + } + + @Override + public void close() { + cancellableMap.values().forEach(c -> c.cancel()); + cancellableMap.clear(); + afterMap.clear(); + } +} diff --git a/impl/core/src/main/java/io/serverlessworkflow/impl/WorkflowApplication.java b/impl/core/src/main/java/io/serverlessworkflow/impl/WorkflowApplication.java index 9865f72b..413bfbe8 100644 --- a/impl/core/src/main/java/io/serverlessworkflow/impl/WorkflowApplication.java +++ b/impl/core/src/main/java/io/serverlessworkflow/impl/WorkflowApplication.java @@ -36,6 +36,7 @@ import io.serverlessworkflow.impl.resources.ExternalResourceHandler; import io.serverlessworkflow.impl.resources.ResourceLoaderFactory; import io.serverlessworkflow.impl.scheduler.DefaultWorkflowScheduler; +import io.serverlessworkflow.impl.scheduler.WorkflowScheduler; import io.serverlessworkflow.impl.schema.SchemaValidator; import io.serverlessworkflow.impl.schema.SchemaValidatorFactory; import java.util.ArrayList; @@ -71,6 +72,7 @@ public class WorkflowApplication implements AutoCloseable { private final Map> additionalObjects; private final ConfigManager configManager; private final SecretManager secretManager; + private final SchedulerListener schedulerListener; private WorkflowApplication(Builder builder) { this.taskFactory = builder.taskFactory; @@ -81,13 +83,14 @@ private WorkflowApplication(Builder builder) { this.idFactory = builder.idFactory; this.runtimeDescriptorFactory = builder.descriptorFactory; this.executorFactory = builder.executorFactory; - this.listeners = builder.listeners != null ? builder.listeners : Collections.emptySet(); + this.listeners = builder.listeners; this.definitions = new ConcurrentHashMap<>(); this.eventConsumer = builder.eventConsumer; this.eventPublishers = builder.eventPublishers; this.lifeCycleCEPublishingEnabled = builder.lifeCycleCEPublishingEnabled; this.modelFactory = builder.modelFactory; this.scheduler = builder.scheduler; + this.schedulerListener = builder.schedulerListener; this.additionalObjects = builder.additionalObjects; this.configManager = builder.configManager; this.secretManager = builder.secretManager; @@ -169,6 +172,7 @@ public SchemaValidator getValidator(SchemaInline inline) { private Map> additionalObjects; private SecretManager secretManager; private ConfigManager configManager; + private SchedulerListener schedulerListener; private Builder() {} @@ -304,6 +308,8 @@ public WorkflowApplication build() { if (scheduler == null) { scheduler = new DefaultWorkflowScheduler(); } + schedulerListener = new SchedulerListener(scheduler); + listeners.add(schedulerListener); if (additionalObjects == null) { additionalObjects = Collections.emptyMap(); } @@ -388,6 +394,10 @@ public SecretManager secretManager() { return secretManager; } + SchedulerListener schedulerListener() { + return schedulerListener; + } + public Optional additionalObject( String name, WorkflowContext workflowContext, TaskContext taskContext) { return Optional.ofNullable(additionalObjects.get(name)) diff --git a/impl/core/src/main/java/io/serverlessworkflow/impl/WorkflowDefinition.java b/impl/core/src/main/java/io/serverlessworkflow/impl/WorkflowDefinition.java index 41c5c2ee..6fe8856a 100644 --- a/impl/core/src/main/java/io/serverlessworkflow/impl/WorkflowDefinition.java +++ b/impl/core/src/main/java/io/serverlessworkflow/impl/WorkflowDefinition.java @@ -20,7 +20,6 @@ import static io.serverlessworkflow.impl.WorkflowUtils.safeClose; import io.serverlessworkflow.api.types.Input; -import io.serverlessworkflow.api.types.ListenTo; import io.serverlessworkflow.api.types.Output; import io.serverlessworkflow.api.types.Schedule; import io.serverlessworkflow.api.types.Workflow; @@ -28,16 +27,20 @@ import io.serverlessworkflow.impl.executors.TaskExecutor; import io.serverlessworkflow.impl.executors.TaskExecutorHelper; import io.serverlessworkflow.impl.resources.ResourceLoader; +import io.serverlessworkflow.impl.scheduler.Cancellable; import io.serverlessworkflow.impl.scheduler.ScheduledEventConsumer; +import io.serverlessworkflow.impl.scheduler.WorkflowScheduler; import io.serverlessworkflow.impl.schema.SchemaValidator; import java.nio.file.Path; import java.util.HashMap; import java.util.Map; +import java.util.Objects; import java.util.Optional; public class WorkflowDefinition implements AutoCloseable, WorkflowDefinitionData { private final Workflow workflow; + private final WorkflowDefinitionId definitionId; private Optional inputSchemaValidator = Optional.empty(); private Optional outputSchemaValidator = Optional.empty(); private Optional inputFilter = Optional.empty(); @@ -47,10 +50,13 @@ public class WorkflowDefinition implements AutoCloseable, WorkflowDefinitionData private final ResourceLoader resourceLoader; private final Map> executors = new HashMap<>(); private ScheduledEventConsumer scheculedConsumer; + private Cancellable everySchedule; + private Cancellable cronSchedule; private WorkflowDefinition( WorkflowApplication application, Workflow workflow, ResourceLoader resourceLoader) { this.workflow = workflow; + this.definitionId = WorkflowDefinitionId.of(workflow); this.application = application; this.resourceLoader = resourceLoader; @@ -84,15 +90,28 @@ static WorkflowDefinition of(WorkflowApplication application, Workflow workflow, application.resourceLoaderFactory().getResourceLoader(application, path)); Schedule schedule = workflow.getSchedule(); if (schedule != null) { - ListenTo to = schedule.getOn(); - if (to != null) { + WorkflowScheduler scheduler = application.scheduler(); + if (schedule.getOn() != null) { definition.scheculedConsumer = - application - .scheduler() - .eventConsumer( - definition, - application.modelFactory()::from, - EventRegistrationBuilderInfo.from(application, to, x -> null)); + scheduler.eventConsumer( + definition, + application.modelFactory()::from, + EventRegistrationBuilderInfo.from(application, schedule.getOn(), x -> null)); + } + if (schedule.getAfter() != null) { + application + .schedulerListener() + .addAfter(definition, WorkflowUtils.fromTimeoutAfter(application, schedule.getAfter())); + } + if (schedule.getCron() != null) { + definition.cronSchedule = scheduler.scheduleCron(definition, schedule.getCron()); + } + if (schedule.getEvery() != null) { + definition.everySchedule = + scheduler.scheduleEvery( + definition, + WorkflowUtils.fromTimeoutAfter(application, schedule.getEvery()) + .apply(null, null, application.modelFactory().fromNull())); } } return definition; @@ -148,7 +167,28 @@ public void addTaskExecutor(WorkflowMutablePosition position, TaskExecutor ta @Override public void close() { - safeClose(scheculedConsumer); safeClose(resourceLoader); + safeClose(scheculedConsumer); + application.schedulerListener().removeAfter(this); + if (everySchedule != null) { + everySchedule.cancel(); + } + if (cronSchedule != null) { + cronSchedule.cancel(); + } + } + + @Override + public int hashCode() { + return Objects.hash(definitionId); + } + + @Override + public boolean equals(Object obj) { + if (this == obj) return true; + if (obj == null) return false; + if (getClass() != obj.getClass()) return false; + WorkflowDefinition other = (WorkflowDefinition) obj; + return Objects.equals(definitionId, other.definitionId); } } diff --git a/impl/core/src/main/java/io/serverlessworkflow/impl/scheduler/Cancellable.java b/impl/core/src/main/java/io/serverlessworkflow/impl/scheduler/Cancellable.java new file mode 100644 index 00000000..fef5b973 --- /dev/null +++ b/impl/core/src/main/java/io/serverlessworkflow/impl/scheduler/Cancellable.java @@ -0,0 +1,20 @@ +/* + * Copyright 2020-Present The Serverless Workflow Specification Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.serverlessworkflow.impl.scheduler; + +public interface Cancellable { + void cancel(); +} diff --git a/impl/core/src/main/java/io/serverlessworkflow/impl/scheduler/CronResolver.java b/impl/core/src/main/java/io/serverlessworkflow/impl/scheduler/CronResolver.java new file mode 100644 index 00000000..df254895 --- /dev/null +++ b/impl/core/src/main/java/io/serverlessworkflow/impl/scheduler/CronResolver.java @@ -0,0 +1,23 @@ +/* + * Copyright 2020-Present The Serverless Workflow Specification Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.serverlessworkflow.impl.scheduler; + +import java.time.Duration; +import java.util.Optional; + +public interface CronResolver { + Optional nextExecution(); +} diff --git a/impl/core/src/main/java/io/serverlessworkflow/impl/scheduler/CronResolverFactory.java b/impl/core/src/main/java/io/serverlessworkflow/impl/scheduler/CronResolverFactory.java new file mode 100644 index 00000000..4c67f062 --- /dev/null +++ b/impl/core/src/main/java/io/serverlessworkflow/impl/scheduler/CronResolverFactory.java @@ -0,0 +1,20 @@ +/* + * Copyright 2020-Present The Serverless Workflow Specification Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.serverlessworkflow.impl.scheduler; + +public interface CronResolverFactory { + CronResolver parseCron(String cron); +} diff --git a/impl/core/src/main/java/io/serverlessworkflow/impl/scheduler/CronUtilsResolver.java b/impl/core/src/main/java/io/serverlessworkflow/impl/scheduler/CronUtilsResolver.java new file mode 100644 index 00000000..fcb7086b --- /dev/null +++ b/impl/core/src/main/java/io/serverlessworkflow/impl/scheduler/CronUtilsResolver.java @@ -0,0 +1,36 @@ +/* + * Copyright 2020-Present The Serverless Workflow Specification Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.serverlessworkflow.impl.scheduler; + +import com.cronutils.model.Cron; +import com.cronutils.model.time.ExecutionTime; +import java.time.Duration; +import java.time.ZonedDateTime; +import java.util.Optional; + +class CronUtilsResolver implements CronResolver { + + private final ExecutionTime executionTime; + + public CronUtilsResolver(Cron cron) { + this.executionTime = ExecutionTime.forCron(cron); + } + + @Override + public Optional nextExecution() { + return executionTime.timeToNextExecution(ZonedDateTime.now()); + } +} diff --git a/impl/core/src/main/java/io/serverlessworkflow/impl/scheduler/CronUtilsResolverFactory.java b/impl/core/src/main/java/io/serverlessworkflow/impl/scheduler/CronUtilsResolverFactory.java new file mode 100644 index 00000000..3facca0e --- /dev/null +++ b/impl/core/src/main/java/io/serverlessworkflow/impl/scheduler/CronUtilsResolverFactory.java @@ -0,0 +1,38 @@ +/* + * Copyright 2020-Present The Serverless Workflow Specification Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.serverlessworkflow.impl.scheduler; + +import com.cronutils.model.CronType; +import com.cronutils.model.definition.CronDefinitionBuilder; +import com.cronutils.parser.CronParser; + +class CronUtilsResolverFactory implements CronResolverFactory { + + private final CronParser cronParser; + + public CronUtilsResolverFactory() { + this(CronType.UNIX); + } + + public CronUtilsResolverFactory(CronType type) { + this.cronParser = new CronParser(CronDefinitionBuilder.instanceDefinitionFor(type)); + } + + @Override + public CronResolver parseCron(String cron) { + return new CronUtilsResolver(cronParser.parse(cron)); + } +} diff --git a/impl/core/src/main/java/io/serverlessworkflow/impl/scheduler/DefaultWorkflowScheduler.java b/impl/core/src/main/java/io/serverlessworkflow/impl/scheduler/DefaultWorkflowScheduler.java index 5e3338e3..338fbca7 100644 --- a/impl/core/src/main/java/io/serverlessworkflow/impl/scheduler/DefaultWorkflowScheduler.java +++ b/impl/core/src/main/java/io/serverlessworkflow/impl/scheduler/DefaultWorkflowScheduler.java @@ -19,20 +19,39 @@ import io.serverlessworkflow.impl.WorkflowDefinition; import io.serverlessworkflow.impl.WorkflowInstance; import io.serverlessworkflow.impl.WorkflowModel; -import io.serverlessworkflow.impl.WorkflowScheduler; import io.serverlessworkflow.impl.events.EventRegistrationBuilderInfo; +import java.time.Duration; import java.util.ArrayList; import java.util.Collection; import java.util.Collections; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; import java.util.function.Function; public class DefaultWorkflowScheduler implements WorkflowScheduler { - private Map> instances = + private final Map> instances = new ConcurrentHashMap<>(); + private final ScheduledExecutorService service; + private final CronResolverFactory cronFactory; + + public DefaultWorkflowScheduler() { + this(Executors.newSingleThreadScheduledExecutor(), new CronUtilsResolverFactory()); + } + + public DefaultWorkflowScheduler( + ScheduledExecutorService service, CronResolverFactory cronFactory) { + this.service = service; + this.cronFactory = cronFactory; + } + @Override public Collection scheduledInstances(WorkflowDefinition definition) { return Collections.unmodifiableCollection(theInstances(definition)); @@ -43,15 +62,93 @@ public ScheduledEventConsumer eventConsumer( WorkflowDefinition definition, Function converter, EventRegistrationBuilderInfo builderInfo) { - return new ScheduledEventConsumer(definition, converter, builderInfo) { - @Override - protected void addScheduledInstance(WorkflowInstance instance) { - theInstances(definition).add(instance); - } - }; + return new ScheduledEventConsumer( + definition, converter, builderInfo, new DefaultScheduledInstanceRunner(definition)); + } + + @Override + public Cancellable scheduleAfter(WorkflowDefinition definition, Duration delay) { + return new ScheduledServiceCancellable( + service.schedule( + new DefaultScheduledInstanceRunner(definition), + delay.toMillis(), + TimeUnit.MILLISECONDS)); + } + + @Override + public Cancellable scheduleEvery(WorkflowDefinition definition, Duration interval) { + long delay = interval.toMillis(); + return new ScheduledServiceCancellable( + service.scheduleAtFixedRate( + new DefaultScheduledInstanceRunner(definition), delay, delay, TimeUnit.MILLISECONDS)); + } + + @Override + public Cancellable scheduleCron(WorkflowDefinition definition, String cron) { + return new CronResolverCancellable(definition, cronFactory.parseCron(cron)); } private Collection theInstances(WorkflowDefinition definition) { return instances.computeIfAbsent(definition, def -> new ArrayList<>()); } + + private class CronResolverCancellable implements Cancellable { + private final WorkflowDefinition definition; + private final CronResolver cronResolver; + + private AtomicReference> nextCron = new AtomicReference<>(); + private AtomicBoolean cancelled = new AtomicBoolean(); + + public CronResolverCancellable(WorkflowDefinition definition, CronResolver cronResolver) { + this.definition = definition; + this.cronResolver = cronResolver; + scheduleNext(); + } + + private void scheduleNext() { + cronResolver + .nextExecution() + .ifPresent( + d -> + nextCron.set( + service.schedule( + new CronResolverIntanceRunner(definition), + d.toMillis(), + TimeUnit.MILLISECONDS))); + } + + @Override + public void cancel() { + cancelled.set(true); + ScheduledFuture toBeCancel = nextCron.get(); + if (toBeCancel != null) { + toBeCancel.cancel(true); + } + } + + private class CronResolverIntanceRunner extends DefaultScheduledInstanceRunner { + protected CronResolverIntanceRunner(WorkflowDefinition definition) { + super(definition); + } + + @Override + public void accept(WorkflowModel model) { + if (!cancelled.get()) { + scheduleNext(); + super.accept(model); + } + } + } + } + + private class DefaultScheduledInstanceRunner extends ScheduledInstanceRunnable { + protected DefaultScheduledInstanceRunner(WorkflowDefinition definition) { + super(definition); + } + + @Override + protected void addScheduledInstance(WorkflowInstance instance) { + theInstances(definition).add(instance); + } + } } diff --git a/impl/core/src/main/java/io/serverlessworkflow/impl/scheduler/ScheduledEventConsumer.java b/impl/core/src/main/java/io/serverlessworkflow/impl/scheduler/ScheduledEventConsumer.java index 34746355..76e6ccb9 100644 --- a/impl/core/src/main/java/io/serverlessworkflow/impl/scheduler/ScheduledEventConsumer.java +++ b/impl/core/src/main/java/io/serverlessworkflow/impl/scheduler/ScheduledEventConsumer.java @@ -17,7 +17,6 @@ import io.cloudevents.CloudEvent; import io.serverlessworkflow.impl.WorkflowDefinition; -import io.serverlessworkflow.impl.WorkflowInstance; import io.serverlessworkflow.impl.WorkflowModel; import io.serverlessworkflow.impl.WorkflowModelCollection; import io.serverlessworkflow.impl.events.EventConsumer; @@ -31,22 +30,25 @@ import java.util.Map; import java.util.function.Function; -public abstract class ScheduledEventConsumer implements AutoCloseable { +public class ScheduledEventConsumer implements AutoCloseable { private final Function converter; private final WorkflowDefinition definition; private final EventRegistrationBuilderInfo builderInfo; private final EventConsumer eventConsumer; + private final ScheduledInstanceRunnable instanceRunner; private Map> correlatedEvents; private Collection registrations = new ArrayList<>(); protected ScheduledEventConsumer( WorkflowDefinition definition, Function converter, - EventRegistrationBuilderInfo builderInfo) { + EventRegistrationBuilderInfo builderInfo, + ScheduledInstanceRunnable instanceRunner) { this.definition = definition; this.converter = converter; this.builderInfo = builderInfo; + this.instanceRunner = instanceRunner; this.eventConsumer = definition.application().eventConsumer(); if (builderInfo.registrations().isAnd() && builderInfo.registrations().registrations().size() > 1) { @@ -100,19 +102,13 @@ private boolean satisfyCondition() { protected void start(CloudEvent ce) { WorkflowModelCollection model = definition.application().modelFactory().createCollection(); model.add(converter.apply(ce)); - start(model); + instanceRunner.accept(model); } protected void start(Collection ces) { WorkflowModelCollection model = definition.application().modelFactory().createCollection(); ces.forEach(ce -> model.add(converter.apply(ce))); - start(model); - } - - private void start(WorkflowModel model) { - WorkflowInstance instance = definition.instance(model); - addScheduledInstance(instance); - instance.start(); + instanceRunner.accept(model); } public void close() { @@ -121,6 +117,4 @@ public void close() { } registrations.forEach(eventConsumer::unregister); } - - protected abstract void addScheduledInstance(WorkflowInstance instace); } diff --git a/impl/core/src/main/java/io/serverlessworkflow/impl/scheduler/ScheduledInstanceRunnable.java b/impl/core/src/main/java/io/serverlessworkflow/impl/scheduler/ScheduledInstanceRunnable.java new file mode 100644 index 00000000..cdb98e58 --- /dev/null +++ b/impl/core/src/main/java/io/serverlessworkflow/impl/scheduler/ScheduledInstanceRunnable.java @@ -0,0 +1,44 @@ +/* + * Copyright 2020-Present The Serverless Workflow Specification Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.serverlessworkflow.impl.scheduler; + +import io.serverlessworkflow.impl.WorkflowDefinition; +import io.serverlessworkflow.impl.WorkflowInstance; +import io.serverlessworkflow.impl.WorkflowModel; +import java.util.function.Consumer; + +public abstract class ScheduledInstanceRunnable implements Runnable, Consumer { + + protected final WorkflowDefinition definition; + + protected ScheduledInstanceRunnable(WorkflowDefinition definition) { + this.definition = definition; + } + + @Override + public void run() { + accept(definition.application().modelFactory().fromNull()); + } + + @Override + public void accept(WorkflowModel model) { + WorkflowInstance instance = definition.instance(model); + addScheduledInstance(instance); + definition.application().executorService().execute(() -> instance.start()); + } + + protected abstract void addScheduledInstance(WorkflowInstance instance); +} diff --git a/impl/core/src/main/java/io/serverlessworkflow/impl/scheduler/ScheduledServiceCancellable.java b/impl/core/src/main/java/io/serverlessworkflow/impl/scheduler/ScheduledServiceCancellable.java new file mode 100644 index 00000000..2341f910 --- /dev/null +++ b/impl/core/src/main/java/io/serverlessworkflow/impl/scheduler/ScheduledServiceCancellable.java @@ -0,0 +1,32 @@ +/* + * Copyright 2020-Present The Serverless Workflow Specification Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.serverlessworkflow.impl.scheduler; + +import java.util.concurrent.ScheduledFuture; + +class ScheduledServiceCancellable implements Cancellable { + + private final ScheduledFuture cancellable; + + public ScheduledServiceCancellable(ScheduledFuture cancellable) { + this.cancellable = cancellable; + } + + @Override + public void cancel() { + cancellable.cancel(true); + } +} diff --git a/impl/core/src/main/java/io/serverlessworkflow/impl/WorkflowScheduler.java b/impl/core/src/main/java/io/serverlessworkflow/impl/scheduler/WorkflowScheduler.java similarity index 54% rename from impl/core/src/main/java/io/serverlessworkflow/impl/WorkflowScheduler.java rename to impl/core/src/main/java/io/serverlessworkflow/impl/scheduler/WorkflowScheduler.java index 84f5b913..27059f1a 100644 --- a/impl/core/src/main/java/io/serverlessworkflow/impl/WorkflowScheduler.java +++ b/impl/core/src/main/java/io/serverlessworkflow/impl/scheduler/WorkflowScheduler.java @@ -13,11 +13,14 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package io.serverlessworkflow.impl; +package io.serverlessworkflow.impl.scheduler; import io.cloudevents.CloudEvent; +import io.serverlessworkflow.impl.WorkflowDefinition; +import io.serverlessworkflow.impl.WorkflowInstance; +import io.serverlessworkflow.impl.WorkflowModel; import io.serverlessworkflow.impl.events.EventRegistrationBuilderInfo; -import io.serverlessworkflow.impl.scheduler.ScheduledEventConsumer; +import java.time.Duration; import java.util.Collection; import java.util.function.Function; @@ -28,4 +31,19 @@ ScheduledEventConsumer eventConsumer( WorkflowDefinition definition, Function converter, EventRegistrationBuilderInfo info); + + /** + * Periodically instantiate a workflow instance from the given definition at the given interval. + * It continue creating workflow instances till cancelled. + */ + Cancellable scheduleEvery(WorkflowDefinition definition, Duration interval); + + /** Creates one workflow instance after the specified delay. */ + Cancellable scheduleAfter(WorkflowDefinition definition, Duration delay); + + /** + * Creates one or more workflow instances according to the specified Cron expression. It continue + * creating workflow instances till the Cron expression indicates so or it is cancelled + */ + Cancellable scheduleCron(WorkflowDefinition definition, String cron); } diff --git a/impl/pom.xml b/impl/pom.xml index 00d900f9..e4698812 100644 --- a/impl/pom.xml +++ b/impl/pom.xml @@ -14,6 +14,7 @@ 4.0.0 1.6.0 3.1.11 + 9.2.1 @@ -124,6 +125,11 @@ h2-mvstore ${version.com.h2database} + + com.cronutils + cron-utils + ${version.com.cronutils} + diff --git a/impl/test/src/test/java/io/serverlessworkflow/impl/test/SchedulerTest.java b/impl/test/src/test/java/io/serverlessworkflow/impl/test/SchedulerTest.java new file mode 100644 index 00000000..43c19644 --- /dev/null +++ b/impl/test/src/test/java/io/serverlessworkflow/impl/test/SchedulerTest.java @@ -0,0 +1,84 @@ +/* + * Copyright 2020-Present The Serverless Workflow Specification Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.serverlessworkflow.impl.test; + +import static io.serverlessworkflow.api.WorkflowReader.readWorkflowFromClasspath; +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +import io.serverlessworkflow.impl.WorkflowApplication; +import io.serverlessworkflow.impl.WorkflowDefinition; +import java.io.IOException; +import java.time.Duration; +import java.util.Map; +import java.util.concurrent.ExecutionException; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; + +class SchedulerTest { + + private static WorkflowApplication appl; + + @BeforeAll + static void init() throws IOException { + appl = WorkflowApplication.builder().build(); + } + + @AfterAll + static void tearDown() throws IOException { + appl.close(); + } + + @Test + void testAfter() throws IOException, InterruptedException, ExecutionException { + try (WorkflowDefinition def = + appl.workflowDefinition(readWorkflowFromClasspath("workflows-samples/after-start.yaml"))) { + def.instance(Map.of()).start().join(); + assertThat(appl.scheduler().scheduledInstances(def)).isEmpty(); + await() + .pollDelay(Duration.ofMillis(50)) + .atMost(Duration.ofMillis(200)) + .until(() -> appl.scheduler().scheduledInstances(def).size() >= 1); + } + } + + @Test + void testEvery() throws IOException, InterruptedException, ExecutionException { + try (WorkflowDefinition def = + appl.workflowDefinition(readWorkflowFromClasspath("workflows-samples/every-start.yaml"))) { + await() + .pollDelay(Duration.ofMillis(20)) + .atMost(Duration.ofMillis(200)) + .until(() -> appl.scheduler().scheduledInstances(def).size() >= 5); + } + } + + @Test + @Disabled("too long test, since cron cannot be under a minute") + void testCron() throws IOException, InterruptedException, ExecutionException { + try (WorkflowDefinition def = + appl.workflowDefinition(readWorkflowFromClasspath("workflows-samples/cron-start.yaml"))) { + await() + .atMost(Duration.ofMinutes(1).plus(Duration.ofSeconds(10))) + .until(() -> appl.scheduler().scheduledInstances(def).size() == 1); + await() + .atMost(Duration.ofMinutes(1).plus(Duration.ofSeconds(10))) + .until(() -> appl.scheduler().scheduledInstances(def).size() == 2); + } + } +} diff --git a/impl/test/src/test/resources/workflows-samples/after-start.yaml b/impl/test/src/test/resources/workflows-samples/after-start.yaml new file mode 100644 index 00000000..cdefccc4 --- /dev/null +++ b/impl/test/src/test/resources/workflows-samples/after-start.yaml @@ -0,0 +1,12 @@ +document: + dsl: '1.0.1' + namespace: test + name: after-driven-schedule + version: '0.1.0' +schedule: + after: + milliseconds: 50 +do: + - recovered: + set: + recovered: true diff --git a/impl/test/src/test/resources/workflows-samples/cron-start.yaml b/impl/test/src/test/resources/workflows-samples/cron-start.yaml new file mode 100644 index 00000000..06dc2cf0 --- /dev/null +++ b/impl/test/src/test/resources/workflows-samples/cron-start.yaml @@ -0,0 +1,11 @@ +document: + dsl: '1.0.1' + namespace: test + name: cron-driven-schedule + version: '0.1.0' +schedule: + cron: "* * * * *" +do: + - recovered: + set: + recovered: true diff --git a/impl/test/src/test/resources/workflows-samples/every-start.yaml b/impl/test/src/test/resources/workflows-samples/every-start.yaml new file mode 100644 index 00000000..cf79681b --- /dev/null +++ b/impl/test/src/test/resources/workflows-samples/every-start.yaml @@ -0,0 +1,12 @@ +document: + dsl: '1.0.1' + namespace: test + name: every-driven-schedule + version: '0.1.0' +schedule: + every: + milliseconds: 10 +do: + - recovered: + set: + recovered: true From 118f172ceadc1eb86de0515b73ab37243d421445 Mon Sep 17 00:00:00 2001 From: Dmitrii Tikhomirov Date: Mon, 27 Oct 2025 18:21:31 -0700 Subject: [PATCH 3/9] Add initial RunContainer Task support Signed-off-by: Dmitrii Tikhomirov Signed-off-by: Dmitrii Tikhomirov --- impl/container/pom.xml | 33 ++++ .../executors/CommandPropertySetter.java | 33 ++++ .../ContainerEnvironmentPropertySetter.java | 49 +++++ .../executors/ContainerPropertySetter.java | 31 ++++ .../container/executors/ContainerRunner.java | 174 ++++++++++++++++++ .../executors/LifetimePropertySetter.java | 43 +++++ .../executors/NamePropertySetter.java | 34 ++++ .../executors/PortsPropertySetter.java | 51 +++++ .../executors/RunContainerExecutor.java | 51 +++++ .../executors/StringExpressionResolver.java | 47 +++++ .../executors/VolumesPropertySetter.java | 51 +++++ ...erlessworkflow.impl.executors.RunnableTask | 1 + impl/pom.xml | 17 ++ impl/test/pom.xml | 4 + .../impl/test/ContainerTest.java | 42 +++++ .../container/container.yaml | 30 +++ 16 files changed, 691 insertions(+) create mode 100644 impl/container/pom.xml create mode 100644 impl/container/src/main/java/io/serverlessworkflow/impl/container/executors/CommandPropertySetter.java create mode 100644 impl/container/src/main/java/io/serverlessworkflow/impl/container/executors/ContainerEnvironmentPropertySetter.java create mode 100644 impl/container/src/main/java/io/serverlessworkflow/impl/container/executors/ContainerPropertySetter.java create mode 100644 impl/container/src/main/java/io/serverlessworkflow/impl/container/executors/ContainerRunner.java create mode 100644 impl/container/src/main/java/io/serverlessworkflow/impl/container/executors/LifetimePropertySetter.java create mode 100644 impl/container/src/main/java/io/serverlessworkflow/impl/container/executors/NamePropertySetter.java create mode 100644 impl/container/src/main/java/io/serverlessworkflow/impl/container/executors/PortsPropertySetter.java create mode 100644 impl/container/src/main/java/io/serverlessworkflow/impl/container/executors/RunContainerExecutor.java create mode 100644 impl/container/src/main/java/io/serverlessworkflow/impl/container/executors/StringExpressionResolver.java create mode 100644 impl/container/src/main/java/io/serverlessworkflow/impl/container/executors/VolumesPropertySetter.java create mode 100644 impl/container/src/main/resources/META-INF/services/io.serverlessworkflow.impl.executors.RunnableTask create mode 100644 impl/test/src/test/java/io/serverlessworkflow/impl/test/ContainerTest.java create mode 100644 impl/test/src/test/resources/workflows-samples/container/container.yaml diff --git a/impl/container/pom.xml b/impl/container/pom.xml new file mode 100644 index 00000000..30b7bb53 --- /dev/null +++ b/impl/container/pom.xml @@ -0,0 +1,33 @@ + + + 4.0.0 + + io.serverlessworkflow + serverlessworkflow-impl + 8.0.0-SNAPSHOT + + serverlessworkflow-impl-container + Serverless Workflow :: Impl :: OpenAPI + + + + io.serverlessworkflow + serverlessworkflow-impl-core + + + io.serverlessworkflow + serverlessworkflow-types + + + com.github.docker-java + docker-java-core + + + com.github.docker-java + docker-java-transport-httpclient5 + + + + \ No newline at end of file diff --git a/impl/container/src/main/java/io/serverlessworkflow/impl/container/executors/CommandPropertySetter.java b/impl/container/src/main/java/io/serverlessworkflow/impl/container/executors/CommandPropertySetter.java new file mode 100644 index 00000000..44eb5cd4 --- /dev/null +++ b/impl/container/src/main/java/io/serverlessworkflow/impl/container/executors/CommandPropertySetter.java @@ -0,0 +1,33 @@ +/* + * Copyright 2020-Present The Serverless Workflow Specification Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.serverlessworkflow.impl.container.executors; + +import com.github.dockerjava.api.command.CreateContainerCmd; +import io.serverlessworkflow.api.types.Container; + +class CommandPropertySetter extends ContainerPropertySetter { + + CommandPropertySetter(CreateContainerCmd createContainerCmd, Container configuration) { + super(createContainerCmd, configuration); + } + + @Override + public void accept(StringExpressionResolver resolver) { + if (configuration.getCommand() != null && !configuration.getCommand().isEmpty()) { + createContainerCmd.withCmd("sh", "-c", configuration.getCommand()); + } + } +} diff --git a/impl/container/src/main/java/io/serverlessworkflow/impl/container/executors/ContainerEnvironmentPropertySetter.java b/impl/container/src/main/java/io/serverlessworkflow/impl/container/executors/ContainerEnvironmentPropertySetter.java new file mode 100644 index 00000000..a4df76f8 --- /dev/null +++ b/impl/container/src/main/java/io/serverlessworkflow/impl/container/executors/ContainerEnvironmentPropertySetter.java @@ -0,0 +1,49 @@ +/* + * Copyright 2020-Present The Serverless Workflow Specification Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.serverlessworkflow.impl.container.executors; + +import com.github.dockerjava.api.command.CreateContainerCmd; +import io.serverlessworkflow.api.types.Container; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +class ContainerEnvironmentPropertySetter extends ContainerPropertySetter { + + ContainerEnvironmentPropertySetter( + CreateContainerCmd createContainerCmd, Container configuration) { + super(createContainerCmd, configuration); + } + + @Override + public void accept(StringExpressionResolver resolver) { + if (!(configuration.getEnvironment() == null + || configuration.getEnvironment().getAdditionalProperties() == null)) { + List envs = new ArrayList<>(); + for (Map.Entry entry : + configuration.getEnvironment().getAdditionalProperties().entrySet()) { + String key = entry.getKey(); + if (entry.getValue() instanceof String value) { + String resolvedValue = resolver.resolve(value); + envs.add(key + "=" + resolvedValue); + } else { + throw new IllegalArgumentException("Environment variable values must be strings"); + } + } + createContainerCmd.withEnv(envs.toArray(new String[0])); + } + } +} diff --git a/impl/container/src/main/java/io/serverlessworkflow/impl/container/executors/ContainerPropertySetter.java b/impl/container/src/main/java/io/serverlessworkflow/impl/container/executors/ContainerPropertySetter.java new file mode 100644 index 00000000..6a5d7055 --- /dev/null +++ b/impl/container/src/main/java/io/serverlessworkflow/impl/container/executors/ContainerPropertySetter.java @@ -0,0 +1,31 @@ +/* + * Copyright 2020-Present The Serverless Workflow Specification Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.serverlessworkflow.impl.container.executors; + +import com.github.dockerjava.api.command.CreateContainerCmd; +import io.serverlessworkflow.api.types.Container; +import java.util.function.Consumer; + +abstract class ContainerPropertySetter implements Consumer { + + protected final CreateContainerCmd createContainerCmd; + protected final Container configuration; + + ContainerPropertySetter(CreateContainerCmd createContainerCmd, Container configuration) { + this.createContainerCmd = createContainerCmd; + this.configuration = configuration; + } +} diff --git a/impl/container/src/main/java/io/serverlessworkflow/impl/container/executors/ContainerRunner.java b/impl/container/src/main/java/io/serverlessworkflow/impl/container/executors/ContainerRunner.java new file mode 100644 index 00000000..cf785964 --- /dev/null +++ b/impl/container/src/main/java/io/serverlessworkflow/impl/container/executors/ContainerRunner.java @@ -0,0 +1,174 @@ +/* + * Copyright 2020-Present The Serverless Workflow Specification Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.serverlessworkflow.impl.container.executors; + +import static io.serverlessworkflow.api.types.ContainerLifetime.ContainerCleanupPolicy.*; + +import com.github.dockerjava.api.DockerClient; +import com.github.dockerjava.api.command.CreateContainerCmd; +import com.github.dockerjava.api.command.CreateContainerResponse; +import com.github.dockerjava.api.command.WaitContainerResultCallback; +import com.github.dockerjava.api.exception.DockerClientException; +import com.github.dockerjava.core.DefaultDockerClientConfig; +import com.github.dockerjava.core.DockerClientImpl; +import com.github.dockerjava.httpclient5.ApacheDockerHttpClient; +import io.serverlessworkflow.api.types.Container; +import io.serverlessworkflow.impl.TaskContext; +import io.serverlessworkflow.impl.WorkflowContext; +import io.serverlessworkflow.impl.WorkflowDefinition; +import io.serverlessworkflow.impl.WorkflowModel; +import io.serverlessworkflow.impl.WorkflowUtils; +import io.serverlessworkflow.impl.WorkflowValueResolver; +import java.io.IOException; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; + +class ContainerRunner { + + private static final DefaultDockerClientConfig DEFAULT_CONFIG = + DefaultDockerClientConfig.createDefaultConfigBuilder().build(); + + private static final DockerClient dockerClient = + DockerClientImpl.getInstance( + DEFAULT_CONFIG, + new ApacheDockerHttpClient.Builder().dockerHost(DEFAULT_CONFIG.getDockerHost()).build()); + + private final CreateContainerCmd createContainerCmd; + private final Container container; + private final List propertySetters; + private final WorkflowDefinition definition; + + private ContainerRunner( + CreateContainerCmd createContainerCmd, WorkflowDefinition definition, Container container) { + this.createContainerCmd = createContainerCmd; + this.definition = definition; + this.container = container; + this.propertySetters = new ArrayList<>(); + } + + /** + * Blocking container execution according to the lifetime policy. Returns an already completed + * CompletableFuture: - completedFuture(input) if exitCode == 0 - exceptionally completed if the + * exit code is non-zero or an error occurs. The method blocks the calling thread until the + * container finishes or the timeout expires. + */ + CompletableFuture startSync( + WorkflowContext workflowContext, TaskContext taskContext, WorkflowModel input) { + + StringExpressionResolver resolver = + new StringExpressionResolver(workflowContext, taskContext, input); + + propertySetters.forEach(setter -> setter.accept(resolver)); + + CreateContainerResponse createContainerResponse = createContainerCmd.exec(); + String containerId = createContainerResponse.getId(); + + if (containerId == null || containerId.isEmpty()) { + return failed("Container creation failed: empty container ID"); + } + + dockerClient.startContainerCmd(containerId).exec(); + + int exitCode; + try (WaitContainerResultCallback resultCallback = + dockerClient.waitContainerCmd(containerId).exec(new WaitContainerResultCallback())) { + if (container.getLifetime() != null + && container.getLifetime().getCleanup() != null + && container.getLifetime().getCleanup().equals(EVENTUALLY)) { + try { + WorkflowValueResolver durationResolver = + WorkflowUtils.fromTimeoutAfter( + definition.application(), container.getLifetime().getAfter()); + Duration timeout = durationResolver.apply(workflowContext, taskContext, input); + exitCode = resultCallback.awaitStatusCode(timeout.toMillis(), TimeUnit.MILLISECONDS); + } catch (DockerClientException e) { + return failed( + String.format("Error while waiting for container to finish: %s ", e.getMessage())); + } finally { + dockerClient.removeContainerCmd(containerId).withForce(true).exec(); + } + } else { + exitCode = resultCallback.awaitStatusCode(); + } + } catch (IOException e) { + return failed( + String.format("Error while waiting for container to finish: %s ", e.getMessage())); + } + + return switch (exitCode) { + case 0 -> CompletableFuture.completedFuture(input); + case 1 -> failed("General error (exit code 1)"); + case 2 -> failed("Shell syntax error (exit code 2)"); + case 126 -> failed("Command found but not executable (exit code 126)"); + case 127 -> failed("Command not found (exit code 127)"); + case 130 -> failed("Interrupted by SIGINT (exit code 130)"); + case 137 -> failed("Killed by SIGKILL (exit code 137)"); + case 139 -> failed("Segmentation fault (exit code 139)"); + case 143 -> failed("Terminated by SIGTERM (exit code 143)"); + default -> failed("Process exited with code " + exitCode); + }; + } + + private static CompletableFuture failed(String message) { + CompletableFuture f = new CompletableFuture<>(); + f.completeExceptionally(new RuntimeException(message)); + return f; + } + + static ContainerRunnerBuilder builder() { + return new ContainerRunnerBuilder(); + } + + public static class ContainerRunnerBuilder { + private Container container = null; + private WorkflowDefinition workflowDefinition; + + private ContainerRunnerBuilder() {} + + ContainerRunnerBuilder withContainer(Container container) { + this.container = container; + return this; + } + + public ContainerRunnerBuilder withWorkflowDefinition(WorkflowDefinition definition) { + this.workflowDefinition = definition; + return this; + } + + ContainerRunner build() { + if (container.getImage() == null || container.getImage().isEmpty()) { + throw new IllegalArgumentException("Container image must be provided"); + } + + CreateContainerCmd createContainerCmd = dockerClient.createContainerCmd(container.getImage()); + + ContainerRunner runner = + new ContainerRunner(createContainerCmd, workflowDefinition, container); + + runner.propertySetters.add(new CommandPropertySetter(createContainerCmd, container)); + runner.propertySetters.add( + new ContainerEnvironmentPropertySetter(createContainerCmd, container)); + runner.propertySetters.add(new NamePropertySetter(createContainerCmd, container)); + runner.propertySetters.add(new PortsPropertySetter(createContainerCmd, container)); + runner.propertySetters.add(new VolumesPropertySetter(createContainerCmd, container)); + runner.propertySetters.add(new LifetimePropertySetter(createContainerCmd, container)); + return runner; + } + } +} diff --git a/impl/container/src/main/java/io/serverlessworkflow/impl/container/executors/LifetimePropertySetter.java b/impl/container/src/main/java/io/serverlessworkflow/impl/container/executors/LifetimePropertySetter.java new file mode 100644 index 00000000..b254d9d2 --- /dev/null +++ b/impl/container/src/main/java/io/serverlessworkflow/impl/container/executors/LifetimePropertySetter.java @@ -0,0 +1,43 @@ +/* + * Copyright 2020-Present The Serverless Workflow Specification Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.serverlessworkflow.impl.container.executors; + +import static io.serverlessworkflow.api.types.ContainerLifetime.*; + +import com.github.dockerjava.api.command.CreateContainerCmd; +import io.serverlessworkflow.api.types.Container; +import io.serverlessworkflow.api.types.ContainerLifetime; + +class LifetimePropertySetter extends ContainerPropertySetter { + + LifetimePropertySetter(CreateContainerCmd createContainerCmd, Container configuration) { + super(createContainerCmd, configuration); + } + + @Override + public void accept(StringExpressionResolver resolver) { + // case of cleanup=eventually processed at ContainerRunner + if (configuration.getLifetime() != null) { + ContainerLifetime lifetime = configuration.getLifetime(); + ContainerCleanupPolicy cleanupPolicy = lifetime.getCleanup(); + if (cleanupPolicy.equals(ContainerCleanupPolicy.ALWAYS)) { + createContainerCmd.getHostConfig().withAutoRemove(true); + } else if (cleanupPolicy.equals(ContainerCleanupPolicy.NEVER)) { + createContainerCmd.getHostConfig().withAutoRemove(false); + } + } + } +} diff --git a/impl/container/src/main/java/io/serverlessworkflow/impl/container/executors/NamePropertySetter.java b/impl/container/src/main/java/io/serverlessworkflow/impl/container/executors/NamePropertySetter.java new file mode 100644 index 00000000..52878196 --- /dev/null +++ b/impl/container/src/main/java/io/serverlessworkflow/impl/container/executors/NamePropertySetter.java @@ -0,0 +1,34 @@ +/* + * Copyright 2020-Present The Serverless Workflow Specification Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.serverlessworkflow.impl.container.executors; + +import com.github.dockerjava.api.command.CreateContainerCmd; +import io.serverlessworkflow.api.types.Container; + +class NamePropertySetter extends ContainerPropertySetter { + + NamePropertySetter(CreateContainerCmd createContainerCmd, Container configuration) { + super(createContainerCmd, configuration); + } + + @Override + public void accept(StringExpressionResolver resolver) { + if (configuration.getName() != null && !configuration.getName().isEmpty()) { + String resolvedName = resolver.resolve(configuration.getName()); + createContainerCmd.withName(resolvedName); + } + } +} diff --git a/impl/container/src/main/java/io/serverlessworkflow/impl/container/executors/PortsPropertySetter.java b/impl/container/src/main/java/io/serverlessworkflow/impl/container/executors/PortsPropertySetter.java new file mode 100644 index 00000000..b13da8e0 --- /dev/null +++ b/impl/container/src/main/java/io/serverlessworkflow/impl/container/executors/PortsPropertySetter.java @@ -0,0 +1,51 @@ +/* + * Copyright 2020-Present The Serverless Workflow Specification Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.serverlessworkflow.impl.container.executors; + +import com.github.dockerjava.api.command.CreateContainerCmd; +import com.github.dockerjava.api.model.ExposedPort; +import com.github.dockerjava.api.model.Ports; +import io.serverlessworkflow.api.types.Container; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +class PortsPropertySetter extends ContainerPropertySetter { + + PortsPropertySetter(CreateContainerCmd createContainerCmd, Container configuration) { + super(createContainerCmd, configuration); + } + + @Override + public void accept(StringExpressionResolver resolver) { + if (configuration.getPorts() != null + && configuration.getPorts().getAdditionalProperties() != null) { + Ports portBindings = new Ports(); + List exposed = new ArrayList<>(); + + for (Map.Entry entry : + configuration.getPorts().getAdditionalProperties().entrySet()) { + int hostPort = Integer.parseInt(entry.getKey()); + int containerPort = Integer.parseInt(entry.getValue().toString()); + ExposedPort exposedPort = ExposedPort.tcp(containerPort); + portBindings.bind(exposedPort, Ports.Binding.bindPort(hostPort)); + exposed.add(exposedPort); + } + createContainerCmd.withExposedPorts(exposed.toArray(new ExposedPort[0])); + createContainerCmd.getHostConfig().withPortBindings(portBindings); + } + } +} diff --git a/impl/container/src/main/java/io/serverlessworkflow/impl/container/executors/RunContainerExecutor.java b/impl/container/src/main/java/io/serverlessworkflow/impl/container/executors/RunContainerExecutor.java new file mode 100644 index 00000000..0e40b891 --- /dev/null +++ b/impl/container/src/main/java/io/serverlessworkflow/impl/container/executors/RunContainerExecutor.java @@ -0,0 +1,51 @@ +/* + * Copyright 2020-Present The Serverless Workflow Specification Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.serverlessworkflow.impl.container.executors; + +import io.serverlessworkflow.api.types.Container; +import io.serverlessworkflow.api.types.RunContainer; +import io.serverlessworkflow.api.types.RunTaskConfiguration; +import io.serverlessworkflow.impl.TaskContext; +import io.serverlessworkflow.impl.WorkflowContext; +import io.serverlessworkflow.impl.WorkflowDefinition; +import io.serverlessworkflow.impl.WorkflowModel; +import io.serverlessworkflow.impl.executors.RunnableTask; +import java.util.concurrent.CompletableFuture; + +public class RunContainerExecutor implements RunnableTask { + + private ContainerRunner containerRunner; + + public void init(RunContainer taskConfiguration, WorkflowDefinition definition) { + Container container = taskConfiguration.getContainer(); + containerRunner = + ContainerRunner.builder() + .withContainer(container) + .withWorkflowDefinition(definition) + .build(); + } + + @Override + public CompletableFuture apply( + WorkflowContext workflowContext, TaskContext taskContext, WorkflowModel input) { + return containerRunner.startSync(workflowContext, taskContext, input); + } + + @Override + public boolean accept(Class clazz) { + return RunContainer.class.equals(clazz); + } +} diff --git a/impl/container/src/main/java/io/serverlessworkflow/impl/container/executors/StringExpressionResolver.java b/impl/container/src/main/java/io/serverlessworkflow/impl/container/executors/StringExpressionResolver.java new file mode 100644 index 00000000..ce2ee2df --- /dev/null +++ b/impl/container/src/main/java/io/serverlessworkflow/impl/container/executors/StringExpressionResolver.java @@ -0,0 +1,47 @@ +/* + * Copyright 2020-Present The Serverless Workflow Specification Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.serverlessworkflow.impl.container.executors; + +import io.serverlessworkflow.impl.TaskContext; +import io.serverlessworkflow.impl.WorkflowContext; +import io.serverlessworkflow.impl.WorkflowModel; +import io.serverlessworkflow.impl.WorkflowUtils; +import io.serverlessworkflow.impl.expressions.ExpressionUtils; + +class StringExpressionResolver { + + private final WorkflowContext workflowContext; + private final TaskContext taskContext; + private final WorkflowModel input; + + StringExpressionResolver( + WorkflowContext workflowContext, TaskContext taskContext, WorkflowModel input) { + this.workflowContext = workflowContext; + this.taskContext = taskContext; + this.input = input; + } + + String resolve(String expression) { + if (ExpressionUtils.isExpr(expression)) { + WorkflowUtils.buildStringResolver( + workflowContext.definition().application(), + expression, + taskContext.input().asJavaObject()) + .apply(workflowContext, taskContext, input); + } + return expression; + } +} diff --git a/impl/container/src/main/java/io/serverlessworkflow/impl/container/executors/VolumesPropertySetter.java b/impl/container/src/main/java/io/serverlessworkflow/impl/container/executors/VolumesPropertySetter.java new file mode 100644 index 00000000..5effe641 --- /dev/null +++ b/impl/container/src/main/java/io/serverlessworkflow/impl/container/executors/VolumesPropertySetter.java @@ -0,0 +1,51 @@ +/* + * Copyright 2020-Present The Serverless Workflow Specification Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.serverlessworkflow.impl.container.executors; + +import com.github.dockerjava.api.command.CreateContainerCmd; +import com.github.dockerjava.api.model.Bind; +import com.github.dockerjava.api.model.Volume; +import io.serverlessworkflow.api.types.Container; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +class VolumesPropertySetter extends ContainerPropertySetter { + + VolumesPropertySetter(CreateContainerCmd createContainerCmd, Container configuration) { + super(createContainerCmd, configuration); + } + + @Override + public void accept(StringExpressionResolver resolver) { + if (configuration.getVolumes() != null + && configuration.getVolumes().getAdditionalProperties() != null) { + List binds = new ArrayList<>(); + for (Map.Entry entry : + configuration.getVolumes().getAdditionalProperties().entrySet()) { + String hostPath = entry.getKey(); + if (entry.getValue() instanceof String containerPath) { + String resolvedHostPath = resolver.resolve(hostPath); + String resolvedContainerPath = resolver.resolve(containerPath); + binds.add(new Bind(resolvedHostPath, new Volume(resolvedContainerPath))); + } else { + throw new IllegalArgumentException("Volume container paths must be strings"); + } + } + createContainerCmd.getHostConfig().withBinds(binds); + } + } +} diff --git a/impl/container/src/main/resources/META-INF/services/io.serverlessworkflow.impl.executors.RunnableTask b/impl/container/src/main/resources/META-INF/services/io.serverlessworkflow.impl.executors.RunnableTask new file mode 100644 index 00000000..c1450d21 --- /dev/null +++ b/impl/container/src/main/resources/META-INF/services/io.serverlessworkflow.impl.executors.RunnableTask @@ -0,0 +1 @@ +io.serverlessworkflow.impl.container.executors.RunContainerExecutor \ No newline at end of file diff --git a/impl/pom.xml b/impl/pom.xml index e4698812..c59df5fa 100644 --- a/impl/pom.xml +++ b/impl/pom.xml @@ -15,6 +15,7 @@ 1.6.0 3.1.11 9.2.1 + 3.6.0 @@ -93,6 +94,11 @@ serverlessworkflow-impl-openapi ${project.version} + + io.serverlessworkflow + serverlessworkflow-impl-container + ${project.version} + net.thisptr jackson-jq @@ -130,6 +136,16 @@ cron-utils ${version.com.cronutils} + + com.github.docker-java + docker-java-core + ${version.docker.java} + + + com.github.docker-java + docker-java-transport-httpclient5 + ${version.docker.java} + @@ -145,6 +161,7 @@ model lifecycleevent validation + container test diff --git a/impl/test/pom.xml b/impl/test/pom.xml index 7672bbef..f4e846cb 100644 --- a/impl/test/pom.xml +++ b/impl/test/pom.xml @@ -41,6 +41,10 @@ io.serverlessworkflow serverlessworkflow-impl-openapi + + io.serverlessworkflow + serverlessworkflow-impl-container + org.glassfish.jersey.core jersey-client diff --git a/impl/test/src/test/java/io/serverlessworkflow/impl/test/ContainerTest.java b/impl/test/src/test/java/io/serverlessworkflow/impl/test/ContainerTest.java new file mode 100644 index 00000000..45bf8c28 --- /dev/null +++ b/impl/test/src/test/java/io/serverlessworkflow/impl/test/ContainerTest.java @@ -0,0 +1,42 @@ +/* + * Copyright 2020-Present The Serverless Workflow Specification Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.serverlessworkflow.impl.test; + +import static io.serverlessworkflow.api.WorkflowReader.readWorkflowFromClasspath; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import io.serverlessworkflow.api.types.Workflow; +import io.serverlessworkflow.impl.WorkflowApplication; +import java.io.IOException; +import java.util.Map; +import org.junit.jupiter.api.Test; + +public class ContainerTest { + + @Test + public void testContainer() throws IOException { + Workflow workflow = readWorkflowFromClasspath("workflows-samples/container/container.yaml"); + Map result; + try (WorkflowApplication app = WorkflowApplication.builder().build()) { + result = + app.workflowDefinition(workflow).instance(Map.of()).start().get().asMap().orElseThrow(); + } catch (Exception e) { + throw new RuntimeException("Workflow execution failed", e); + } + + assertNotNull(result); + } +} diff --git a/impl/test/src/test/resources/workflows-samples/container/container.yaml b/impl/test/src/test/resources/workflows-samples/container/container.yaml new file mode 100644 index 00000000..07a87fe1 --- /dev/null +++ b/impl/test/src/test/resources/workflows-samples/container/container.yaml @@ -0,0 +1,30 @@ +document: + dsl: '1.0.2' + namespace: test + name: run-example + version: '0.1.0' +do: + - runContainer: + run: + container: + image: alpine:latest + #command: echo Hello World + #command: printenv + #command: sleep 30 + command: "ls -la /treblereel" + name: hello-world + ports: + 8880: 8880 + 8881: 8881 + 8882: 8882 + lifetime: + #cleanup: never + cleanup: never + #cleanup: eventually + #after: + # seconds: 100 + environment: + FOO: BAR + BAR: FOO + volumes: + "/Users/treblereel/temp": "/treblereel" From a29faa6ac7e33601055f65cbc0d0707f25922def Mon Sep 17 00:00:00 2001 From: Dmitrii Tikhomirov Date: Mon, 3 Nov 2025 17:36:42 -0800 Subject: [PATCH 4/9] image pull before run Signed-off-by: Dmitrii Tikhomirov --- .../container/executors/ContainerRunner.java | 146 +++++++++++++----- 1 file changed, 110 insertions(+), 36 deletions(-) diff --git a/impl/container/src/main/java/io/serverlessworkflow/impl/container/executors/ContainerRunner.java b/impl/container/src/main/java/io/serverlessworkflow/impl/container/executors/ContainerRunner.java index cf785964..8530c9cc 100644 --- a/impl/container/src/main/java/io/serverlessworkflow/impl/container/executors/ContainerRunner.java +++ b/impl/container/src/main/java/io/serverlessworkflow/impl/container/executors/ContainerRunner.java @@ -15,15 +15,16 @@ */ package io.serverlessworkflow.impl.container.executors; -import static io.serverlessworkflow.api.types.ContainerLifetime.ContainerCleanupPolicy.*; +import static io.serverlessworkflow.api.types.ContainerLifetime.*; import com.github.dockerjava.api.DockerClient; import com.github.dockerjava.api.command.CreateContainerCmd; import com.github.dockerjava.api.command.CreateContainerResponse; +import com.github.dockerjava.api.command.PullImageResultCallback; import com.github.dockerjava.api.command.WaitContainerResultCallback; -import com.github.dockerjava.api.exception.DockerClientException; import com.github.dockerjava.core.DefaultDockerClientConfig; import com.github.dockerjava.core.DockerClientImpl; +import com.github.dockerjava.core.NameParser; import com.github.dockerjava.httpclient5.ApacheDockerHttpClient; import io.serverlessworkflow.api.types.Container; import io.serverlessworkflow.impl.TaskContext; @@ -32,7 +33,6 @@ import io.serverlessworkflow.impl.WorkflowModel; import io.serverlessworkflow.impl.WorkflowUtils; import io.serverlessworkflow.impl.WorkflowValueResolver; -import java.io.IOException; import java.time.Duration; import java.util.ArrayList; import java.util.List; @@ -71,48 +71,122 @@ private ContainerRunner( CompletableFuture startSync( WorkflowContext workflowContext, TaskContext taskContext, WorkflowModel input) { - StringExpressionResolver resolver = - new StringExpressionResolver(workflowContext, taskContext, input); + try { + var resolver = new StringExpressionResolver(workflowContext, taskContext, input); + applyPropertySetters(resolver); + pullImageIfNeeded(container.getImage()); - propertySetters.forEach(setter -> setter.accept(resolver)); + String id = createAndStartContainer(); + int exit = waitAccordingToLifetime(id, workflowContext, taskContext, input); - CreateContainerResponse createContainerResponse = createContainerCmd.exec(); - String containerId = createContainerResponse.getId(); + return mapExitCode(exit, input); + } catch (InterruptedException ie) { + Thread.currentThread().interrupt(); + return failed("Interrupted while waiting for container"); + } catch (Exception e) { + return failed("Container run failed: " + e.getMessage()); + } + } + + private void applyPropertySetters(StringExpressionResolver resolver) { + for (var setter : propertySetters) setter.accept(resolver); + } - if (containerId == null || containerId.isEmpty()) { - return failed("Container creation failed: empty container ID"); + private void pullImageIfNeeded(String imageRef) throws InterruptedException { + NameParser.ReposTag rt = NameParser.parseRepositoryTag(imageRef); + NameParser.HostnameReposName hr = NameParser.resolveRepositoryName(imageRef); + + String repository = hr.reposName; + String tag = rt.tag != null && rt.tag.isEmpty() ? rt.tag : "latest"; + dockerClient + .pullImageCmd(repository) + .withTag(tag) + .exec(new PullImageResultCallback()) + .awaitCompletion(); + } + + private String createAndStartContainer() { + CreateContainerResponse resp = createContainerCmd.exec(); + String id = resp.getId(); + if (id == null || id.isEmpty()) { + throw new IllegalStateException("Container creation failed: empty ID"); } + dockerClient.startContainerCmd(id).exec(); + return id; + } - dockerClient.startContainerCmd(containerId).exec(); - - int exitCode; - try (WaitContainerResultCallback resultCallback = - dockerClient.waitContainerCmd(containerId).exec(new WaitContainerResultCallback())) { - if (container.getLifetime() != null - && container.getLifetime().getCleanup() != null - && container.getLifetime().getCleanup().equals(EVENTUALLY)) { - try { - WorkflowValueResolver durationResolver = - WorkflowUtils.fromTimeoutAfter( - definition.application(), container.getLifetime().getAfter()); - Duration timeout = durationResolver.apply(workflowContext, taskContext, input); - exitCode = resultCallback.awaitStatusCode(timeout.toMillis(), TimeUnit.MILLISECONDS); - } catch (DockerClientException e) { - return failed( - String.format("Error while waiting for container to finish: %s ", e.getMessage())); - } finally { - dockerClient.removeContainerCmd(containerId).withForce(true).exec(); + private int waitAccordingToLifetime( + String id, WorkflowContext workflowContext, TaskContext taskContext, WorkflowModel input) + throws Exception { + + var lifetime = container.getLifetime(); + var policy = lifetime != null ? lifetime.getCleanup() : null; + + try (var cb = dockerClient.waitContainerCmd(id).exec(new WaitContainerResultCallback())) { + + if (policy == ContainerCleanupPolicy.EVENTUALLY) { + Duration timeout = resolveAfter(lifetime, workflowContext, taskContext, input); + int exit = cb.awaitStatusCode(timeout.toMillis(), TimeUnit.MILLISECONDS); + + if (isRunning(id)) { + safeStop(id, Duration.ofSeconds(10)); } + safeRemove(id); + return exit; + } else { - exitCode = resultCallback.awaitStatusCode(); + int exit = cb.awaitStatusCode(); + if (policy == ContainerCleanupPolicy.ALWAYS) { + safeRemove(id); + } + return exit; } - } catch (IOException e) { - return failed( - String.format("Error while waiting for container to finish: %s ", e.getMessage())); } + } + + private Duration resolveAfter( + io.serverlessworkflow.api.types.ContainerLifetime lifetime, + WorkflowContext workflowContext, + TaskContext taskContext, + WorkflowModel input) { + + if (lifetime == null || lifetime.getAfter() == null) { + return Duration.ZERO; + } + WorkflowValueResolver r = + WorkflowUtils.fromTimeoutAfter(definition.application(), lifetime.getAfter()); + return r.apply(workflowContext, taskContext, input); + } + + private boolean isRunning(String id) { + try { + var st = dockerClient.inspectContainerCmd(id).exec().getState(); + return st != null && Boolean.TRUE.equals(st.getRunning()); + } catch (Exception e) { + return false; // must be already removed + } + } + + private void safeStop(String id, Duration timeout) { + try { + dockerClient.stopContainerCmd(id).withTimeout((int) Math.max(1, timeout.toSeconds())).exec(); + } catch (Exception ignore) { + // we can ignore this + } + } + + // must be removed because of withAutoRemove(true), but just in case + private void safeRemove(String id) { + try { + dockerClient.removeContainerCmd(id).withForce(true).exec(); + } catch (Exception ignore) { + // we can ignore this + } + } - return switch (exitCode) { - case 0 -> CompletableFuture.completedFuture(input); + private static CompletableFuture mapExitCode(int exit, T ok) { + return switch (exit) { + case 0 -> CompletableFuture.completedFuture(ok); case 1 -> failed("General error (exit code 1)"); case 2 -> failed("Shell syntax error (exit code 2)"); case 126 -> failed("Command found but not executable (exit code 126)"); @@ -121,7 +195,7 @@ CompletableFuture startSync( case 137 -> failed("Killed by SIGKILL (exit code 137)"); case 139 -> failed("Segmentation fault (exit code 139)"); case 143 -> failed("Terminated by SIGTERM (exit code 143)"); - default -> failed("Process exited with code " + exitCode); + default -> failed("Process exited with code " + exit); }; } From f9c5d189550070716bb5a0ae8a21684aab1ad278 Mon Sep 17 00:00:00 2001 From: Dmitrii Tikhomirov Date: Tue, 4 Nov 2025 16:37:57 -0800 Subject: [PATCH 5/9] refactoring + tests Signed-off-by: Dmitrii Tikhomirov --- .../executors/CommandPropertySetter.java | 3 +- .../ContainerEnvironmentPropertySetter.java | 5 +- .../executors/ContainerPropertySetter.java | 3 +- .../container/executors/ContainerRunner.java | 86 ++++---- .../executors/LifetimePropertySetter.java | 3 +- .../executors/NamePropertySetter.java | 5 +- .../executors/PortsPropertySetter.java | 3 +- .../executors/RunContainerExecutor.java | 2 +- .../executors/StringExpressionResolver.java | 7 +- .../executors/VolumesPropertySetter.java | 7 +- .../impl/test/ContainerTest.java | 204 +++++++++++++++++- .../container/container-cleanup-default.yaml | 12 ++ .../container/container-cleanup.yaml | 14 ++ .../container/container-env.yaml | 17 ++ .../container/container-ports.yaml | 18 ++ .../container/container-test-command.yaml | 14 ++ .../container/container-timeout.yaml | 16 ++ .../container/container.yaml | 30 --- 18 files changed, 363 insertions(+), 86 deletions(-) create mode 100644 impl/test/src/test/resources/workflows-samples/container/container-cleanup-default.yaml create mode 100644 impl/test/src/test/resources/workflows-samples/container/container-cleanup.yaml create mode 100644 impl/test/src/test/resources/workflows-samples/container/container-env.yaml create mode 100644 impl/test/src/test/resources/workflows-samples/container/container-ports.yaml create mode 100644 impl/test/src/test/resources/workflows-samples/container/container-test-command.yaml create mode 100644 impl/test/src/test/resources/workflows-samples/container/container-timeout.yaml delete mode 100644 impl/test/src/test/resources/workflows-samples/container/container.yaml diff --git a/impl/container/src/main/java/io/serverlessworkflow/impl/container/executors/CommandPropertySetter.java b/impl/container/src/main/java/io/serverlessworkflow/impl/container/executors/CommandPropertySetter.java index 44eb5cd4..7c125c72 100644 --- a/impl/container/src/main/java/io/serverlessworkflow/impl/container/executors/CommandPropertySetter.java +++ b/impl/container/src/main/java/io/serverlessworkflow/impl/container/executors/CommandPropertySetter.java @@ -17,6 +17,7 @@ import com.github.dockerjava.api.command.CreateContainerCmd; import io.serverlessworkflow.api.types.Container; +import java.util.function.Function; class CommandPropertySetter extends ContainerPropertySetter { @@ -25,7 +26,7 @@ class CommandPropertySetter extends ContainerPropertySetter { } @Override - public void accept(StringExpressionResolver resolver) { + public void accept(Function resolver) { if (configuration.getCommand() != null && !configuration.getCommand().isEmpty()) { createContainerCmd.withCmd("sh", "-c", configuration.getCommand()); } diff --git a/impl/container/src/main/java/io/serverlessworkflow/impl/container/executors/ContainerEnvironmentPropertySetter.java b/impl/container/src/main/java/io/serverlessworkflow/impl/container/executors/ContainerEnvironmentPropertySetter.java index a4df76f8..18a4dc85 100644 --- a/impl/container/src/main/java/io/serverlessworkflow/impl/container/executors/ContainerEnvironmentPropertySetter.java +++ b/impl/container/src/main/java/io/serverlessworkflow/impl/container/executors/ContainerEnvironmentPropertySetter.java @@ -20,6 +20,7 @@ import java.util.ArrayList; import java.util.List; import java.util.Map; +import java.util.function.Function; class ContainerEnvironmentPropertySetter extends ContainerPropertySetter { @@ -29,7 +30,7 @@ class ContainerEnvironmentPropertySetter extends ContainerPropertySetter { } @Override - public void accept(StringExpressionResolver resolver) { + public void accept(Function resolver) { if (!(configuration.getEnvironment() == null || configuration.getEnvironment().getAdditionalProperties() == null)) { List envs = new ArrayList<>(); @@ -37,7 +38,7 @@ public void accept(StringExpressionResolver resolver) { configuration.getEnvironment().getAdditionalProperties().entrySet()) { String key = entry.getKey(); if (entry.getValue() instanceof String value) { - String resolvedValue = resolver.resolve(value); + String resolvedValue = resolver.apply(value); envs.add(key + "=" + resolvedValue); } else { throw new IllegalArgumentException("Environment variable values must be strings"); diff --git a/impl/container/src/main/java/io/serverlessworkflow/impl/container/executors/ContainerPropertySetter.java b/impl/container/src/main/java/io/serverlessworkflow/impl/container/executors/ContainerPropertySetter.java index 6a5d7055..44b62ebe 100644 --- a/impl/container/src/main/java/io/serverlessworkflow/impl/container/executors/ContainerPropertySetter.java +++ b/impl/container/src/main/java/io/serverlessworkflow/impl/container/executors/ContainerPropertySetter.java @@ -18,8 +18,9 @@ import com.github.dockerjava.api.command.CreateContainerCmd; import io.serverlessworkflow.api.types.Container; import java.util.function.Consumer; +import java.util.function.Function; -abstract class ContainerPropertySetter implements Consumer { +abstract class ContainerPropertySetter implements Consumer> { protected final CreateContainerCmd createContainerCmd; protected final Container configuration; diff --git a/impl/container/src/main/java/io/serverlessworkflow/impl/container/executors/ContainerRunner.java b/impl/container/src/main/java/io/serverlessworkflow/impl/container/executors/ContainerRunner.java index 8530c9cc..8fb8d65f 100644 --- a/impl/container/src/main/java/io/serverlessworkflow/impl/container/executors/ContainerRunner.java +++ b/impl/container/src/main/java/io/serverlessworkflow/impl/container/executors/ContainerRunner.java @@ -20,8 +20,9 @@ import com.github.dockerjava.api.DockerClient; import com.github.dockerjava.api.command.CreateContainerCmd; import com.github.dockerjava.api.command.CreateContainerResponse; -import com.github.dockerjava.api.command.PullImageResultCallback; import com.github.dockerjava.api.command.WaitContainerResultCallback; +import com.github.dockerjava.api.exception.DockerClientException; +import com.github.dockerjava.api.exception.NotFoundException; import com.github.dockerjava.core.DefaultDockerClientConfig; import com.github.dockerjava.core.DockerClientImpl; import com.github.dockerjava.core.NameParser; @@ -38,6 +39,7 @@ import java.util.List; import java.util.concurrent.CompletableFuture; import java.util.concurrent.TimeUnit; +import java.util.function.Function; class ContainerRunner { @@ -62,15 +64,15 @@ private ContainerRunner( this.propertySetters = new ArrayList<>(); } - /** - * Blocking container execution according to the lifetime policy. Returns an already completed - * CompletableFuture: - completedFuture(input) if exitCode == 0 - exceptionally completed if the - * exit code is non-zero or an error occurs. The method blocks the calling thread until the - * container finishes or the timeout expires. - */ - CompletableFuture startSync( + CompletableFuture start( WorkflowContext workflowContext, TaskContext taskContext, WorkflowModel input) { + return CompletableFuture.supplyAsync( + () -> startSync(workflowContext, taskContext, input), + definition.application().executorService()); + } + private WorkflowModel startSync( + WorkflowContext workflowContext, TaskContext taskContext, WorkflowModel input) { try { var resolver = new StringExpressionResolver(workflowContext, taskContext, input); applyPropertySetters(resolver); @@ -78,18 +80,20 @@ CompletableFuture startSync( String id = createAndStartContainer(); int exit = waitAccordingToLifetime(id, workflowContext, taskContext, input); - - return mapExitCode(exit, input); + if (exit == 0) { + return input; + } + throw mapExitCode(exit); } catch (InterruptedException ie) { Thread.currentThread().interrupt(); - return failed("Interrupted while waiting for container"); + throw failed("Container execution failed with exit code " + ie.getMessage()); } catch (Exception e) { - return failed("Container run failed: " + e.getMessage()); + throw failed("Container execution failed with exit code " + e.getMessage()); } } - private void applyPropertySetters(StringExpressionResolver resolver) { - for (var setter : propertySetters) setter.accept(resolver); + private void applyPropertySetters(Function resolver) { + propertySetters.forEach(setter -> setter.accept(resolver)); } private void pullImageIfNeeded(String imageRef) throws InterruptedException { @@ -97,12 +101,8 @@ private void pullImageIfNeeded(String imageRef) throws InterruptedException { NameParser.HostnameReposName hr = NameParser.resolveRepositoryName(imageRef); String repository = hr.reposName; - String tag = rt.tag != null && rt.tag.isEmpty() ? rt.tag : "latest"; - dockerClient - .pullImageCmd(repository) - .withTag(tag) - .exec(new PullImageResultCallback()) - .awaitCompletion(); + String tag = (rt.tag == null || rt.tag.isBlank()) ? "latest" : rt.tag; + dockerClient.pullImageCmd(repository).withTag(tag).start().awaitCompletion(); } private String createAndStartContainer() { @@ -123,25 +123,22 @@ private int waitAccordingToLifetime( var policy = lifetime != null ? lifetime.getCleanup() : null; try (var cb = dockerClient.waitContainerCmd(id).exec(new WaitContainerResultCallback())) { - if (policy == ContainerCleanupPolicy.EVENTUALLY) { Duration timeout = resolveAfter(lifetime, workflowContext, taskContext, input); - int exit = cb.awaitStatusCode(timeout.toMillis(), TimeUnit.MILLISECONDS); - - if (isRunning(id)) { - safeStop(id, Duration.ofSeconds(10)); + try { + Integer exit = cb.awaitStatusCode(timeout.toMillis(), TimeUnit.MILLISECONDS); + safeStop(id); + return exit != null ? exit : 0; + } catch (DockerClientException timeoutOrOther) { + safeStop(id); } - safeRemove(id); - return exit; - } else { - int exit = cb.awaitStatusCode(); - if (policy == ContainerCleanupPolicy.ALWAYS) { - safeRemove(id); - } - return exit; + return cb.awaitStatusCode(); } + } catch (NotFoundException e) { + // container already removed } + return 0; } private Duration resolveAfter( @@ -167,6 +164,20 @@ private boolean isRunning(String id) { } } + private void safeStop(String id) { + if (isRunning(id)) { + safeStop(id, Duration.ofSeconds(10)); + try (var cb2 = dockerClient.waitContainerCmd(id).exec(new WaitContainerResultCallback())) { + cb2.awaitStatusCode(); + safeRemove(id); + } catch (Exception ignore) { + // we can ignore this + } + } else { + safeRemove(id); + } + } + private void safeStop(String id, Duration timeout) { try { dockerClient.stopContainerCmd(id).withTimeout((int) Math.max(1, timeout.toSeconds())).exec(); @@ -184,9 +195,8 @@ private void safeRemove(String id) { } } - private static CompletableFuture mapExitCode(int exit, T ok) { + private static Exception mapExitCode(int exit) { return switch (exit) { - case 0 -> CompletableFuture.completedFuture(ok); case 1 -> failed("General error (exit code 1)"); case 2 -> failed("Shell syntax error (exit code 2)"); case 126 -> failed("Command found but not executable (exit code 126)"); @@ -199,10 +209,8 @@ private static CompletableFuture mapExitCode(int exit, T ok) { }; } - private static CompletableFuture failed(String message) { - CompletableFuture f = new CompletableFuture<>(); - f.completeExceptionally(new RuntimeException(message)); - return f; + private static RuntimeException failed(String message) { + return new RuntimeException(message); } static ContainerRunnerBuilder builder() { diff --git a/impl/container/src/main/java/io/serverlessworkflow/impl/container/executors/LifetimePropertySetter.java b/impl/container/src/main/java/io/serverlessworkflow/impl/container/executors/LifetimePropertySetter.java index b254d9d2..52454778 100644 --- a/impl/container/src/main/java/io/serverlessworkflow/impl/container/executors/LifetimePropertySetter.java +++ b/impl/container/src/main/java/io/serverlessworkflow/impl/container/executors/LifetimePropertySetter.java @@ -20,6 +20,7 @@ import com.github.dockerjava.api.command.CreateContainerCmd; import io.serverlessworkflow.api.types.Container; import io.serverlessworkflow.api.types.ContainerLifetime; +import java.util.function.Function; class LifetimePropertySetter extends ContainerPropertySetter { @@ -28,7 +29,7 @@ class LifetimePropertySetter extends ContainerPropertySetter { } @Override - public void accept(StringExpressionResolver resolver) { + public void accept(Function resolver) { // case of cleanup=eventually processed at ContainerRunner if (configuration.getLifetime() != null) { ContainerLifetime lifetime = configuration.getLifetime(); diff --git a/impl/container/src/main/java/io/serverlessworkflow/impl/container/executors/NamePropertySetter.java b/impl/container/src/main/java/io/serverlessworkflow/impl/container/executors/NamePropertySetter.java index 52878196..d8c820c5 100644 --- a/impl/container/src/main/java/io/serverlessworkflow/impl/container/executors/NamePropertySetter.java +++ b/impl/container/src/main/java/io/serverlessworkflow/impl/container/executors/NamePropertySetter.java @@ -17,6 +17,7 @@ import com.github.dockerjava.api.command.CreateContainerCmd; import io.serverlessworkflow.api.types.Container; +import java.util.function.Function; class NamePropertySetter extends ContainerPropertySetter { @@ -25,9 +26,9 @@ class NamePropertySetter extends ContainerPropertySetter { } @Override - public void accept(StringExpressionResolver resolver) { + public void accept(Function resolver) { if (configuration.getName() != null && !configuration.getName().isEmpty()) { - String resolvedName = resolver.resolve(configuration.getName()); + String resolvedName = resolver.apply(configuration.getName()); createContainerCmd.withName(resolvedName); } } diff --git a/impl/container/src/main/java/io/serverlessworkflow/impl/container/executors/PortsPropertySetter.java b/impl/container/src/main/java/io/serverlessworkflow/impl/container/executors/PortsPropertySetter.java index b13da8e0..882abfa6 100644 --- a/impl/container/src/main/java/io/serverlessworkflow/impl/container/executors/PortsPropertySetter.java +++ b/impl/container/src/main/java/io/serverlessworkflow/impl/container/executors/PortsPropertySetter.java @@ -22,6 +22,7 @@ import java.util.ArrayList; import java.util.List; import java.util.Map; +import java.util.function.Function; class PortsPropertySetter extends ContainerPropertySetter { @@ -30,7 +31,7 @@ class PortsPropertySetter extends ContainerPropertySetter { } @Override - public void accept(StringExpressionResolver resolver) { + public void accept(Function resolver) { if (configuration.getPorts() != null && configuration.getPorts().getAdditionalProperties() != null) { Ports portBindings = new Ports(); diff --git a/impl/container/src/main/java/io/serverlessworkflow/impl/container/executors/RunContainerExecutor.java b/impl/container/src/main/java/io/serverlessworkflow/impl/container/executors/RunContainerExecutor.java index 0e40b891..adf7482b 100644 --- a/impl/container/src/main/java/io/serverlessworkflow/impl/container/executors/RunContainerExecutor.java +++ b/impl/container/src/main/java/io/serverlessworkflow/impl/container/executors/RunContainerExecutor.java @@ -41,7 +41,7 @@ public void init(RunContainer taskConfiguration, WorkflowDefinition definition) @Override public CompletableFuture apply( WorkflowContext workflowContext, TaskContext taskContext, WorkflowModel input) { - return containerRunner.startSync(workflowContext, taskContext, input); + return containerRunner.start(workflowContext, taskContext, input); } @Override diff --git a/impl/container/src/main/java/io/serverlessworkflow/impl/container/executors/StringExpressionResolver.java b/impl/container/src/main/java/io/serverlessworkflow/impl/container/executors/StringExpressionResolver.java index ce2ee2df..e5848cee 100644 --- a/impl/container/src/main/java/io/serverlessworkflow/impl/container/executors/StringExpressionResolver.java +++ b/impl/container/src/main/java/io/serverlessworkflow/impl/container/executors/StringExpressionResolver.java @@ -20,8 +20,9 @@ import io.serverlessworkflow.impl.WorkflowModel; import io.serverlessworkflow.impl.WorkflowUtils; import io.serverlessworkflow.impl.expressions.ExpressionUtils; +import java.util.function.Function; -class StringExpressionResolver { +class StringExpressionResolver implements Function { private final WorkflowContext workflowContext; private final TaskContext taskContext; @@ -34,9 +35,9 @@ class StringExpressionResolver { this.input = input; } - String resolve(String expression) { + public String apply(String expression) { if (ExpressionUtils.isExpr(expression)) { - WorkflowUtils.buildStringResolver( + return WorkflowUtils.buildStringResolver( workflowContext.definition().application(), expression, taskContext.input().asJavaObject()) diff --git a/impl/container/src/main/java/io/serverlessworkflow/impl/container/executors/VolumesPropertySetter.java b/impl/container/src/main/java/io/serverlessworkflow/impl/container/executors/VolumesPropertySetter.java index 5effe641..bb7215ca 100644 --- a/impl/container/src/main/java/io/serverlessworkflow/impl/container/executors/VolumesPropertySetter.java +++ b/impl/container/src/main/java/io/serverlessworkflow/impl/container/executors/VolumesPropertySetter.java @@ -22,6 +22,7 @@ import java.util.ArrayList; import java.util.List; import java.util.Map; +import java.util.function.Function; class VolumesPropertySetter extends ContainerPropertySetter { @@ -30,7 +31,7 @@ class VolumesPropertySetter extends ContainerPropertySetter { } @Override - public void accept(StringExpressionResolver resolver) { + public void accept(Function resolver) { if (configuration.getVolumes() != null && configuration.getVolumes().getAdditionalProperties() != null) { List binds = new ArrayList<>(); @@ -38,8 +39,8 @@ public void accept(StringExpressionResolver resolver) { configuration.getVolumes().getAdditionalProperties().entrySet()) { String hostPath = entry.getKey(); if (entry.getValue() instanceof String containerPath) { - String resolvedHostPath = resolver.resolve(hostPath); - String resolvedContainerPath = resolver.resolve(containerPath); + String resolvedHostPath = resolver.apply(hostPath); + String resolvedContainerPath = resolver.apply(containerPath); binds.add(new Bind(resolvedHostPath, new Volume(resolvedContainerPath))); } else { throw new IllegalArgumentException("Volume container paths must be strings"); diff --git a/impl/test/src/test/java/io/serverlessworkflow/impl/test/ContainerTest.java b/impl/test/src/test/java/io/serverlessworkflow/impl/test/ContainerTest.java index 45bf8c28..a30c3fab 100644 --- a/impl/test/src/test/java/io/serverlessworkflow/impl/test/ContainerTest.java +++ b/impl/test/src/test/java/io/serverlessworkflow/impl/test/ContainerTest.java @@ -16,19 +16,155 @@ package io.serverlessworkflow.impl.test; import static io.serverlessworkflow.api.WorkflowReader.readWorkflowFromClasspath; +import static org.awaitility.Awaitility.await; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import com.github.dockerjava.api.DockerClient; +import com.github.dockerjava.api.command.InspectContainerResponse; +import com.github.dockerjava.api.model.Container; +import com.github.dockerjava.api.model.ExposedPort; +import com.github.dockerjava.api.model.Frame; +import com.github.dockerjava.api.model.Ports; +import com.github.dockerjava.core.DefaultDockerClientConfig; +import com.github.dockerjava.core.DockerClientImpl; +import com.github.dockerjava.core.command.LogContainerResultCallback; +import com.github.dockerjava.httpclient5.ApacheDockerHttpClient; import io.serverlessworkflow.api.types.Workflow; import io.serverlessworkflow.impl.WorkflowApplication; +import java.io.ByteArrayOutputStream; import java.io.IOException; +import java.time.Duration; +import java.util.Arrays; import java.util.Map; import org.junit.jupiter.api.Test; public class ContainerTest { + private static final DefaultDockerClientConfig DEFAULT_CONFIG = + DefaultDockerClientConfig.createDefaultConfigBuilder().build(); + + private static final DockerClient dockerClient = + DockerClientImpl.getInstance( + DEFAULT_CONFIG, + new ApacheDockerHttpClient.Builder().dockerHost(DEFAULT_CONFIG.getDockerHost()).build()); + + @Test + public void testContainer() throws IOException, InterruptedException { + Workflow workflow = + readWorkflowFromClasspath("workflows-samples/container/container-test-command.yaml"); + Map result; + try (WorkflowApplication app = WorkflowApplication.builder().build()) { + result = + app.workflowDefinition(workflow).instance(Map.of()).start().get().asMap().orElseThrow(); + } catch (Exception e) { + throw new RuntimeException("Workflow execution failed", e); + } + + String containerName = "hello-world"; + String containerId = findContainerIdByName(containerName); + ByteArrayOutputStream output = new ByteArrayOutputStream(); + + dockerClient + .logContainerCmd(containerId) + .withStdOut(true) + .withStdErr(true) + .withTimestamps(true) + .exec( + new LogContainerResultCallback() { + @Override + public void onNext(Frame frame) { + output.writeBytes(frame.getPayload()); + } + }) + .awaitCompletion(); + + assertTrue(output.toString().contains("Hello World")); + assertNotNull(result); + dockerClient.removeContainerCmd(containerId).withForce(true).exec(); + } + + @Test + public void testContainerEnv() throws IOException, InterruptedException { + Workflow workflow = readWorkflowFromClasspath("workflows-samples/container/container-env.yaml"); + + Map input = Map.of("someValue", "Tested"); + + Map result; + try (WorkflowApplication app = WorkflowApplication.builder().build()) { + result = app.workflowDefinition(workflow).instance(input).start().get().asMap().orElseThrow(); + } catch (Exception e) { + throw new RuntimeException("Workflow execution failed", e); + } + + String containerName = "hello-world-envs"; + ByteArrayOutputStream output = new ByteArrayOutputStream(); + + dockerClient + .logContainerCmd(findContainerIdByName(containerName)) + .withStdOut(true) + .withStdErr(true) + .withTimestamps(true) + .exec( + new LogContainerResultCallback() { + @Override + public void onNext(Frame frame) { + output.writeBytes(frame.getPayload()); + } + }) + .awaitCompletion(); + assertTrue(output.toString().contains("BAR=FOO")); + assertTrue(output.toString().contains("FOO=Tested")); + assertNotNull(result); + String containerId = findContainerIdByName(containerName); + dockerClient.removeContainerCmd(containerId).withForce(true).exec(); + } + + @Test + public void testContainerTimeout() throws IOException { + Workflow workflow = + readWorkflowFromClasspath("workflows-samples/container/container-timeout.yaml"); + + Map result; + try (WorkflowApplication app = WorkflowApplication.builder().build()) { + result = + app.workflowDefinition(workflow).instance(Map.of()).start().get().asMap().orElseThrow(); + } catch (Exception e) { + throw new RuntimeException("Workflow execution failed", e); + } + + String containerName = "hello-world-timeout"; + String containerId = findContainerIdByName(containerName); + + assertTrue(isContainerGone(containerId)); + assertNotNull(result); + } + + @Test + public void testContainerCleanup() throws IOException { + Workflow workflow = + readWorkflowFromClasspath("workflows-samples/container/container-cleanup.yaml"); + + Map result; + try (WorkflowApplication app = WorkflowApplication.builder().build()) { + result = + app.workflowDefinition(workflow).instance(Map.of()).start().get().asMap().orElseThrow(); + } catch (Exception e) { + throw new RuntimeException("Workflow execution failed", e); + } + + String containerName = "hello-world-cleanup"; + String containerId = findContainerIdByName(containerName); + assertTrue(isContainerGone(containerId)); + assertNotNull(result); + } + @Test - public void testContainer() throws IOException { - Workflow workflow = readWorkflowFromClasspath("workflows-samples/container/container.yaml"); + public void testContainerCleanupDefault() throws IOException { + Workflow workflow = + readWorkflowFromClasspath("workflows-samples/container/container-cleanup-default.yaml"); + Map result; try (WorkflowApplication app = WorkflowApplication.builder().build()) { result = @@ -37,6 +173,70 @@ public void testContainer() throws IOException { throw new RuntimeException("Workflow execution failed", e); } + String containerName = "hello-world-cleanup-default"; + String containerId = findContainerIdByName(containerName); + assertFalse(isContainerGone(containerId)); assertNotNull(result); + + dockerClient.removeContainerCmd(containerId).withForce(true).exec(); + } + + @Test + void testPortBindings() throws Exception { + Workflow workflow = + readWorkflowFromClasspath("workflows-samples/container/container-ports.yaml"); + + new Thread( + () -> { + try (WorkflowApplication app = WorkflowApplication.builder().build()) { + app.workflowDefinition(workflow) + .instance(Map.of()) + .start() + .get() + .asMap() + .orElseThrow(); + } catch (Exception e) { + // we can ignore exceptions here, as the workflow will end when the container is + // removed + } + }) + .start(); + + String containerName = "hello-world-ports"; + await() + .pollInterval(Duration.ofSeconds(1)) + .atMost(Duration.ofSeconds(10)) + .until(() -> findContainerIdByName(containerName) != null); + + String containerId = findContainerIdByName(containerName); + InspectContainerResponse inspect = dockerClient.inspectContainerCmd(containerId).exec(); + Map ports = inspect.getNetworkSettings().getPorts().getBindings(); + + assertTrue(ports.containsKey(ExposedPort.tcp(8880))); + assertTrue(ports.containsKey(ExposedPort.tcp(8881))); + assertTrue(ports.containsKey(ExposedPort.tcp(8882))); + + dockerClient.removeContainerCmd(containerId).withForce(true).exec(); + } + + private static String findContainerIdByName(String containerName) { + var containers = dockerClient.listContainersCmd().withShowAll(true).exec(); + + return containers.stream() + .filter( + c -> + c.getNames() != null + && Arrays.stream(c.getNames()).anyMatch(n -> n.equals("/" + containerName))) + .map(Container::getId) + .findFirst() + .orElse(null); + } + + private static boolean isContainerGone(String id) { + if (id == null) { + return true; + } + var containers = dockerClient.listContainersCmd().withShowAll(true).exec(); + return containers.stream().noneMatch(c -> c.getId().startsWith(id)); } } diff --git a/impl/test/src/test/resources/workflows-samples/container/container-cleanup-default.yaml b/impl/test/src/test/resources/workflows-samples/container/container-cleanup-default.yaml new file mode 100644 index 00000000..208b1faa --- /dev/null +++ b/impl/test/src/test/resources/workflows-samples/container/container-cleanup-default.yaml @@ -0,0 +1,12 @@ +document: + dsl: '1.0.2' + namespace: test + name: run-example + version: '0.1.0' +do: + - runContainer: + run: + container: + image: alpine:latest + command: echo Hello World + name: hello-world-cleanup-default diff --git a/impl/test/src/test/resources/workflows-samples/container/container-cleanup.yaml b/impl/test/src/test/resources/workflows-samples/container/container-cleanup.yaml new file mode 100644 index 00000000..b7501a75 --- /dev/null +++ b/impl/test/src/test/resources/workflows-samples/container/container-cleanup.yaml @@ -0,0 +1,14 @@ +document: + dsl: '1.0.2' + namespace: test + name: run-example + version: '0.1.0' +do: + - runContainer: + run: + container: + image: alpine:latest + command: echo Hello World + name: hello-world-cleanup + lifetime: + cleanup: always \ No newline at end of file diff --git a/impl/test/src/test/resources/workflows-samples/container/container-env.yaml b/impl/test/src/test/resources/workflows-samples/container/container-env.yaml new file mode 100644 index 00000000..9876480b --- /dev/null +++ b/impl/test/src/test/resources/workflows-samples/container/container-env.yaml @@ -0,0 +1,17 @@ +document: + dsl: '1.0.2' + namespace: test + name: run-example + version: '0.1.0' +do: + - runContainer: + run: + container: + image: alpine:latest + command: printenv + name: hello-world-envs + lifetime: + cleanup: never + environment: + FOO: ${ .someValue } + BAR: FOO diff --git a/impl/test/src/test/resources/workflows-samples/container/container-ports.yaml b/impl/test/src/test/resources/workflows-samples/container/container-ports.yaml new file mode 100644 index 00000000..bc330610 --- /dev/null +++ b/impl/test/src/test/resources/workflows-samples/container/container-ports.yaml @@ -0,0 +1,18 @@ +document: + dsl: '1.0.2' + namespace: test + name: run-example + version: '0.1.0' +do: + - runContainer: + run: + container: + image: alpine:latest + command: sleep 300 + name: hello-world-ports + ports: + 8880: 8880 + 8881: 8881 + 8882: 8882 + lifetime: + cleanup: never \ No newline at end of file diff --git a/impl/test/src/test/resources/workflows-samples/container/container-test-command.yaml b/impl/test/src/test/resources/workflows-samples/container/container-test-command.yaml new file mode 100644 index 00000000..068137fc --- /dev/null +++ b/impl/test/src/test/resources/workflows-samples/container/container-test-command.yaml @@ -0,0 +1,14 @@ +document: + dsl: '1.0.2' + namespace: test + name: run-example + version: '0.1.0' +do: + - runContainer: + run: + container: + image: alpine:3.20 + command: echo Hello World + name: hello-world + lifetime: + cleanup: never diff --git a/impl/test/src/test/resources/workflows-samples/container/container-timeout.yaml b/impl/test/src/test/resources/workflows-samples/container/container-timeout.yaml new file mode 100644 index 00000000..b06c0bb8 --- /dev/null +++ b/impl/test/src/test/resources/workflows-samples/container/container-timeout.yaml @@ -0,0 +1,16 @@ +document: + dsl: '1.0.2' + namespace: test + name: run-example + version: '0.1.0' +do: + - runContainer: + run: + container: + image: alpine:latest + command: sleep 300 + name: hello-world-timeout + lifetime: + cleanup: eventually + after: + seconds: 1 \ No newline at end of file diff --git a/impl/test/src/test/resources/workflows-samples/container/container.yaml b/impl/test/src/test/resources/workflows-samples/container/container.yaml deleted file mode 100644 index 07a87fe1..00000000 --- a/impl/test/src/test/resources/workflows-samples/container/container.yaml +++ /dev/null @@ -1,30 +0,0 @@ -document: - dsl: '1.0.2' - namespace: test - name: run-example - version: '0.1.0' -do: - - runContainer: - run: - container: - image: alpine:latest - #command: echo Hello World - #command: printenv - #command: sleep 30 - command: "ls -la /treblereel" - name: hello-world - ports: - 8880: 8880 - 8881: 8881 - 8882: 8882 - lifetime: - #cleanup: never - cleanup: never - #cleanup: eventually - #after: - # seconds: 100 - environment: - FOO: BAR - BAR: FOO - volumes: - "/Users/treblereel/temp": "/treblereel" From a908e3e5b294b75fb075782bd243c72831572409 Mon Sep 17 00:00:00 2001 From: fjtirado Date: Wed, 5 Nov 2025 14:08:34 +0100 Subject: [PATCH 6/9] Review comments Signed-off-by: fjtirado Signed-off-by: Dmitrii Tikhomirov --- .../executors/CommandPropertySetter.java | 34 +++- .../ContainerEnvironmentPropertySetter.java | 55 +++--- .../executors/ContainerPropertySetter.java | 21 +- .../container/executors/ContainerRunner.java | 182 +++++++++--------- .../executors/LifetimePropertySetter.java | 33 ++-- .../executors/NamePropertySetter.java | 35 +++- .../executors/PortsPropertySetter.java | 49 +++-- .../executors/StringExpressionResolver.java | 48 ----- .../executors/VolumesPropertySetter.java | 58 ++++-- .../impl/WorkflowUtils.java | 4 + .../impl/test/ContainerTest.java | 88 ++++----- .../container/container-cleanup-default.yaml | 2 +- .../container/container-cleanup.yaml | 2 +- .../container/container-env.yaml | 2 +- .../container/container-ports.yaml | 2 +- .../container/container-test-command.yaml | 2 +- .../container/container-timeout.yaml | 2 +- 17 files changed, 326 insertions(+), 293 deletions(-) delete mode 100644 impl/container/src/main/java/io/serverlessworkflow/impl/container/executors/StringExpressionResolver.java diff --git a/impl/container/src/main/java/io/serverlessworkflow/impl/container/executors/CommandPropertySetter.java b/impl/container/src/main/java/io/serverlessworkflow/impl/container/executors/CommandPropertySetter.java index 7c125c72..b0af3191 100644 --- a/impl/container/src/main/java/io/serverlessworkflow/impl/container/executors/CommandPropertySetter.java +++ b/impl/container/src/main/java/io/serverlessworkflow/impl/container/executors/CommandPropertySetter.java @@ -15,20 +15,38 @@ */ package io.serverlessworkflow.impl.container.executors; +import static io.serverlessworkflow.impl.WorkflowUtils.isValid; + import com.github.dockerjava.api.command.CreateContainerCmd; import io.serverlessworkflow.api.types.Container; -import java.util.function.Function; +import io.serverlessworkflow.impl.TaskContext; +import io.serverlessworkflow.impl.WorkflowContext; +import io.serverlessworkflow.impl.WorkflowDefinition; +import io.serverlessworkflow.impl.WorkflowModel; +import io.serverlessworkflow.impl.WorkflowUtils; +import io.serverlessworkflow.impl.WorkflowValueResolver; +import java.util.Optional; + +class CommandPropertySetter implements ContainerPropertySetter { -class CommandPropertySetter extends ContainerPropertySetter { + private Optional> command; - CommandPropertySetter(CreateContainerCmd createContainerCmd, Container configuration) { - super(createContainerCmd, configuration); + CommandPropertySetter(WorkflowDefinition definition, Container configuration) { + String commandName = configuration.getCommand(); + command = + isValid(commandName) + ? Optional.of(WorkflowUtils.buildStringFilter(definition.application(), commandName)) + : Optional.empty(); } @Override - public void accept(Function resolver) { - if (configuration.getCommand() != null && !configuration.getCommand().isEmpty()) { - createContainerCmd.withCmd("sh", "-c", configuration.getCommand()); - } + public void accept( + CreateContainerCmd containerCmd, + WorkflowContext workflowContext, + TaskContext taskContext, + WorkflowModel model) { + command + .map(c -> c.apply(workflowContext, taskContext, model)) + .ifPresent(c -> containerCmd.withCmd("sh", "-c", c)); } } diff --git a/impl/container/src/main/java/io/serverlessworkflow/impl/container/executors/ContainerEnvironmentPropertySetter.java b/impl/container/src/main/java/io/serverlessworkflow/impl/container/executors/ContainerEnvironmentPropertySetter.java index 18a4dc85..c4f143a3 100644 --- a/impl/container/src/main/java/io/serverlessworkflow/impl/container/executors/ContainerEnvironmentPropertySetter.java +++ b/impl/container/src/main/java/io/serverlessworkflow/impl/container/executors/ContainerEnvironmentPropertySetter.java @@ -17,34 +17,43 @@ import com.github.dockerjava.api.command.CreateContainerCmd; import io.serverlessworkflow.api.types.Container; -import java.util.ArrayList; -import java.util.List; +import io.serverlessworkflow.impl.TaskContext; +import io.serverlessworkflow.impl.WorkflowContext; +import io.serverlessworkflow.impl.WorkflowDefinition; +import io.serverlessworkflow.impl.WorkflowModel; +import io.serverlessworkflow.impl.WorkflowUtils; +import io.serverlessworkflow.impl.WorkflowValueResolver; import java.util.Map; -import java.util.function.Function; +import java.util.Optional; -class ContainerEnvironmentPropertySetter extends ContainerPropertySetter { +class ContainerEnvironmentPropertySetter implements ContainerPropertySetter { - ContainerEnvironmentPropertySetter( - CreateContainerCmd createContainerCmd, Container configuration) { - super(createContainerCmd, configuration); + private final Optional>> envResolver; + + ContainerEnvironmentPropertySetter(WorkflowDefinition definition, Container configuration) { + + this.envResolver = + configuration.getEnvironment() != null + && configuration.getEnvironment().getAdditionalProperties() != null + ? Optional.of( + WorkflowUtils.buildMapResolver( + definition.application(), + null, + configuration.getEnvironment().getAdditionalProperties())) + : Optional.empty(); } @Override - public void accept(Function resolver) { - if (!(configuration.getEnvironment() == null - || configuration.getEnvironment().getAdditionalProperties() == null)) { - List envs = new ArrayList<>(); - for (Map.Entry entry : - configuration.getEnvironment().getAdditionalProperties().entrySet()) { - String key = entry.getKey(); - if (entry.getValue() instanceof String value) { - String resolvedValue = resolver.apply(value); - envs.add(key + "=" + resolvedValue); - } else { - throw new IllegalArgumentException("Environment variable values must be strings"); - } - } - createContainerCmd.withEnv(envs.toArray(new String[0])); - } + public void accept( + CreateContainerCmd command, + WorkflowContext workflowContext, + TaskContext taskContext, + WorkflowModel model) { + envResolver + .map(env -> env.apply(workflowContext, taskContext, model)) + .ifPresent( + envs -> + command.withEnv( + envs.entrySet().stream().map(e -> e.getKey() + "=" + e.getValue()).toList())); } } diff --git a/impl/container/src/main/java/io/serverlessworkflow/impl/container/executors/ContainerPropertySetter.java b/impl/container/src/main/java/io/serverlessworkflow/impl/container/executors/ContainerPropertySetter.java index 44b62ebe..9524a92a 100644 --- a/impl/container/src/main/java/io/serverlessworkflow/impl/container/executors/ContainerPropertySetter.java +++ b/impl/container/src/main/java/io/serverlessworkflow/impl/container/executors/ContainerPropertySetter.java @@ -16,17 +16,14 @@ package io.serverlessworkflow.impl.container.executors; import com.github.dockerjava.api.command.CreateContainerCmd; -import io.serverlessworkflow.api.types.Container; -import java.util.function.Consumer; -import java.util.function.Function; +import io.serverlessworkflow.impl.TaskContext; +import io.serverlessworkflow.impl.WorkflowContext; +import io.serverlessworkflow.impl.WorkflowModel; -abstract class ContainerPropertySetter implements Consumer> { - - protected final CreateContainerCmd createContainerCmd; - protected final Container configuration; - - ContainerPropertySetter(CreateContainerCmd createContainerCmd, Container configuration) { - this.createContainerCmd = createContainerCmd; - this.configuration = configuration; - } +interface ContainerPropertySetter { + abstract void accept( + CreateContainerCmd command, + WorkflowContext workflowContext, + TaskContext taskContext, + WorkflowModel model); } diff --git a/impl/container/src/main/java/io/serverlessworkflow/impl/container/executors/ContainerRunner.java b/impl/container/src/main/java/io/serverlessworkflow/impl/container/executors/ContainerRunner.java index 8fb8d65f..5c14a3ac 100644 --- a/impl/container/src/main/java/io/serverlessworkflow/impl/container/executors/ContainerRunner.java +++ b/impl/container/src/main/java/io/serverlessworkflow/impl/container/executors/ContainerRunner.java @@ -16,6 +16,7 @@ package io.serverlessworkflow.impl.container.executors; import static io.serverlessworkflow.api.types.ContainerLifetime.*; +import static io.serverlessworkflow.impl.WorkflowUtils.isValid; import com.github.dockerjava.api.DockerClient; import com.github.dockerjava.api.command.CreateContainerCmd; @@ -28,107 +29,117 @@ import com.github.dockerjava.core.NameParser; import com.github.dockerjava.httpclient5.ApacheDockerHttpClient; import io.serverlessworkflow.api.types.Container; +import io.serverlessworkflow.api.types.ContainerLifetime; +import io.serverlessworkflow.api.types.TimeoutAfter; import io.serverlessworkflow.impl.TaskContext; import io.serverlessworkflow.impl.WorkflowContext; import io.serverlessworkflow.impl.WorkflowDefinition; import io.serverlessworkflow.impl.WorkflowModel; import io.serverlessworkflow.impl.WorkflowUtils; import io.serverlessworkflow.impl.WorkflowValueResolver; +import java.io.IOException; import java.time.Duration; import java.util.ArrayList; -import java.util.List; +import java.util.Collection; +import java.util.Optional; import java.util.concurrent.CompletableFuture; import java.util.concurrent.TimeUnit; -import java.util.function.Function; class ContainerRunner { private static final DefaultDockerClientConfig DEFAULT_CONFIG = DefaultDockerClientConfig.createDefaultConfigBuilder().build(); - private static final DockerClient dockerClient = - DockerClientImpl.getInstance( - DEFAULT_CONFIG, - new ApacheDockerHttpClient.Builder().dockerHost(DEFAULT_CONFIG.getDockerHost()).build()); + private static class DockerClientHolder { + private static final DockerClient dockerClient = + DockerClientImpl.getInstance( + DEFAULT_CONFIG, + new ApacheDockerHttpClient.Builder() + .dockerHost(DEFAULT_CONFIG.getDockerHost()) + .build()); + } - private final CreateContainerCmd createContainerCmd; - private final Container container; - private final List propertySetters; - private final WorkflowDefinition definition; + private final Collection propertySetters; + private final Optional> timeout; + private final ContainerCleanupPolicy policy; + private final String containerImage; - private ContainerRunner( - CreateContainerCmd createContainerCmd, WorkflowDefinition definition, Container container) { - this.createContainerCmd = createContainerCmd; - this.definition = definition; - this.container = container; - this.propertySetters = new ArrayList<>(); + private ContainerRunner(ContainerRunnerBuilder builder) { + this.propertySetters = builder.propertySetters; + this.timeout = Optional.ofNullable(builder.timeout); + this.policy = builder.policy; + this.containerImage = builder.containerImage; } CompletableFuture start( WorkflowContext workflowContext, TaskContext taskContext, WorkflowModel input) { return CompletableFuture.supplyAsync( () -> startSync(workflowContext, taskContext, input), - definition.application().executorService()); + workflowContext.definition().application().executorService()); } private WorkflowModel startSync( WorkflowContext workflowContext, TaskContext taskContext, WorkflowModel input) { - try { - var resolver = new StringExpressionResolver(workflowContext, taskContext, input); - applyPropertySetters(resolver); - pullImageIfNeeded(container.getImage()); - - String id = createAndStartContainer(); - int exit = waitAccordingToLifetime(id, workflowContext, taskContext, input); - if (exit == 0) { - return input; - } + Integer exit = executeContainer(workflowContext, taskContext, input); + if (exit == null || exit == 0) { + return input; + } else { throw mapExitCode(exit); + } + } + + private Integer executeContainer( + WorkflowContext workflowContext, TaskContext taskContext, WorkflowModel input) { + try { + pullImageIfNeeded(containerImage); + CreateContainerCmd containerCommand = + DockerClientHolder.dockerClient.createContainerCmd(containerImage); + propertySetters.forEach(p -> p.accept(containerCommand, workflowContext, taskContext, input)); + return waitAccordingToLifetime( + createAndStartContainer(containerCommand), workflowContext, taskContext, input); } catch (InterruptedException ie) { Thread.currentThread().interrupt(); throw failed("Container execution failed with exit code " + ie.getMessage()); - } catch (Exception e) { + } catch (IOException e) { throw failed("Container execution failed with exit code " + e.getMessage()); } } - private void applyPropertySetters(Function resolver) { - propertySetters.forEach(setter -> setter.accept(resolver)); - } - private void pullImageIfNeeded(String imageRef) throws InterruptedException { NameParser.ReposTag rt = NameParser.parseRepositoryTag(imageRef); - NameParser.HostnameReposName hr = NameParser.resolveRepositoryName(imageRef); - - String repository = hr.reposName; - String tag = (rt.tag == null || rt.tag.isBlank()) ? "latest" : rt.tag; - dockerClient.pullImageCmd(repository).withTag(tag).start().awaitCompletion(); + DockerClientHolder.dockerClient + .pullImageCmd(NameParser.resolveRepositoryName(imageRef).reposName) + .withTag(WorkflowUtils.isValid(rt.tag) ? rt.tag : "latest") + .start() + .awaitCompletion(); } - private String createAndStartContainer() { - CreateContainerResponse resp = createContainerCmd.exec(); + private String createAndStartContainer(CreateContainerCmd containerCommand) { + CreateContainerResponse resp = containerCommand.exec(); String id = resp.getId(); - if (id == null || id.isEmpty()) { + if (!isValid(id)) { throw new IllegalStateException("Container creation failed: empty ID"); } - dockerClient.startContainerCmd(id).exec(); + DockerClientHolder.dockerClient.startContainerCmd(id).exec(); return id; } - private int waitAccordingToLifetime( + private Integer waitAccordingToLifetime( String id, WorkflowContext workflowContext, TaskContext taskContext, WorkflowModel input) - throws Exception { - - var lifetime = container.getLifetime(); - var policy = lifetime != null ? lifetime.getCleanup() : null; - - try (var cb = dockerClient.waitContainerCmd(id).exec(new WaitContainerResultCallback())) { + throws IOException { + try (var cb = + DockerClientHolder.dockerClient + .waitContainerCmd(id) + .exec(new WaitContainerResultCallback())) { if (policy == ContainerCleanupPolicy.EVENTUALLY) { - Duration timeout = resolveAfter(lifetime, workflowContext, taskContext, input); + Duration timeout = + this.timeout + .map(t -> t.apply(workflowContext, taskContext, input)) + .orElse(Duration.ZERO); try { Integer exit = cb.awaitStatusCode(timeout.toMillis(), TimeUnit.MILLISECONDS); safeStop(id); - return exit != null ? exit : 0; + return exit; } catch (DockerClientException timeoutOrOther) { safeStop(id); } @@ -141,23 +152,9 @@ private int waitAccordingToLifetime( return 0; } - private Duration resolveAfter( - io.serverlessworkflow.api.types.ContainerLifetime lifetime, - WorkflowContext workflowContext, - TaskContext taskContext, - WorkflowModel input) { - - if (lifetime == null || lifetime.getAfter() == null) { - return Duration.ZERO; - } - WorkflowValueResolver r = - WorkflowUtils.fromTimeoutAfter(definition.application(), lifetime.getAfter()); - return r.apply(workflowContext, taskContext, input); - } - private boolean isRunning(String id) { try { - var st = dockerClient.inspectContainerCmd(id).exec().getState(); + var st = DockerClientHolder.dockerClient.inspectContainerCmd(id).exec().getState(); return st != null && Boolean.TRUE.equals(st.getRunning()); } catch (Exception e) { return false; // must be already removed @@ -167,7 +164,10 @@ private boolean isRunning(String id) { private void safeStop(String id) { if (isRunning(id)) { safeStop(id, Duration.ofSeconds(10)); - try (var cb2 = dockerClient.waitContainerCmd(id).exec(new WaitContainerResultCallback())) { + try (var cb2 = + DockerClientHolder.dockerClient + .waitContainerCmd(id) + .exec(new WaitContainerResultCallback())) { cb2.awaitStatusCode(); safeRemove(id); } catch (Exception ignore) { @@ -180,7 +180,10 @@ private void safeStop(String id) { private void safeStop(String id, Duration timeout) { try { - dockerClient.stopContainerCmd(id).withTimeout((int) Math.max(1, timeout.toSeconds())).exec(); + DockerClientHolder.dockerClient + .stopContainerCmd(id) + .withTimeout((int) Math.max(1, timeout.toSeconds())) + .exec(); } catch (Exception ignore) { // we can ignore this } @@ -189,13 +192,13 @@ private void safeStop(String id, Duration timeout) { // must be removed because of withAutoRemove(true), but just in case private void safeRemove(String id) { try { - dockerClient.removeContainerCmd(id).withForce(true).exec(); + DockerClientHolder.dockerClient.removeContainerCmd(id).withForce(true).exec(); } catch (Exception ignore) { // we can ignore this } } - private static Exception mapExitCode(int exit) { + private static RuntimeException mapExitCode(int exit) { return switch (exit) { case 1 -> failed("General error (exit code 1)"); case 2 -> failed("Shell syntax error (exit code 2)"); @@ -218,8 +221,12 @@ static ContainerRunnerBuilder builder() { } public static class ContainerRunnerBuilder { - private Container container = null; - private WorkflowDefinition workflowDefinition; + private Container container; + private WorkflowDefinition definition; + private WorkflowValueResolver timeout; + private ContainerCleanupPolicy policy; + private String containerImage; + private Collection propertySetters = new ArrayList<>(); private ContainerRunnerBuilder() {} @@ -229,28 +236,31 @@ ContainerRunnerBuilder withContainer(Container container) { } public ContainerRunnerBuilder withWorkflowDefinition(WorkflowDefinition definition) { - this.workflowDefinition = definition; + this.definition = definition; return this; } ContainerRunner build() { - if (container.getImage() == null || container.getImage().isEmpty()) { + propertySetters.add(new NamePropertySetter(definition, container)); + propertySetters.add(new CommandPropertySetter(definition, container)); + propertySetters.add(new ContainerEnvironmentPropertySetter(definition, container)); + propertySetters.add(new LifetimePropertySetter(container)); + propertySetters.add(new PortsPropertySetter(container)); + propertySetters.add(new VolumesPropertySetter(definition, container)); + + containerImage = container.getImage(); + if (containerImage == null || container.getImage().isBlank()) { throw new IllegalArgumentException("Container image must be provided"); } + ContainerLifetime lifetime = container.getLifetime(); + if (lifetime != null) { + policy = lifetime.getCleanup(); + TimeoutAfter afterTimeout = lifetime.getAfter(); + if (afterTimeout != null) + timeout = WorkflowUtils.fromTimeoutAfter(definition.application(), afterTimeout); + } - CreateContainerCmd createContainerCmd = dockerClient.createContainerCmd(container.getImage()); - - ContainerRunner runner = - new ContainerRunner(createContainerCmd, workflowDefinition, container); - - runner.propertySetters.add(new CommandPropertySetter(createContainerCmd, container)); - runner.propertySetters.add( - new ContainerEnvironmentPropertySetter(createContainerCmd, container)); - runner.propertySetters.add(new NamePropertySetter(createContainerCmd, container)); - runner.propertySetters.add(new PortsPropertySetter(createContainerCmd, container)); - runner.propertySetters.add(new VolumesPropertySetter(createContainerCmd, container)); - runner.propertySetters.add(new LifetimePropertySetter(createContainerCmd, container)); - return runner; + return new ContainerRunner(this); } } } diff --git a/impl/container/src/main/java/io/serverlessworkflow/impl/container/executors/LifetimePropertySetter.java b/impl/container/src/main/java/io/serverlessworkflow/impl/container/executors/LifetimePropertySetter.java index 52454778..3606ae93 100644 --- a/impl/container/src/main/java/io/serverlessworkflow/impl/container/executors/LifetimePropertySetter.java +++ b/impl/container/src/main/java/io/serverlessworkflow/impl/container/executors/LifetimePropertySetter.java @@ -19,26 +19,29 @@ import com.github.dockerjava.api.command.CreateContainerCmd; import io.serverlessworkflow.api.types.Container; -import io.serverlessworkflow.api.types.ContainerLifetime; -import java.util.function.Function; +import io.serverlessworkflow.impl.TaskContext; +import io.serverlessworkflow.impl.WorkflowContext; +import io.serverlessworkflow.impl.WorkflowModel; -class LifetimePropertySetter extends ContainerPropertySetter { +class LifetimePropertySetter implements ContainerPropertySetter { - LifetimePropertySetter(CreateContainerCmd createContainerCmd, Container configuration) { - super(createContainerCmd, configuration); + private final ContainerCleanupPolicy cleanupPolicy; + + LifetimePropertySetter(Container configuration) { + this.cleanupPolicy = + configuration.getLifetime() != null ? configuration.getLifetime().getCleanup() : null; } @Override - public void accept(Function resolver) { - // case of cleanup=eventually processed at ContainerRunner - if (configuration.getLifetime() != null) { - ContainerLifetime lifetime = configuration.getLifetime(); - ContainerCleanupPolicy cleanupPolicy = lifetime.getCleanup(); - if (cleanupPolicy.equals(ContainerCleanupPolicy.ALWAYS)) { - createContainerCmd.getHostConfig().withAutoRemove(true); - } else if (cleanupPolicy.equals(ContainerCleanupPolicy.NEVER)) { - createContainerCmd.getHostConfig().withAutoRemove(false); - } + public void accept( + CreateContainerCmd command, + WorkflowContext workflowContext, + TaskContext taskContext, + WorkflowModel model) { + if (ContainerCleanupPolicy.ALWAYS.equals(cleanupPolicy)) { + command.getHostConfig().withAutoRemove(true); + } else if (ContainerCleanupPolicy.NEVER.equals(cleanupPolicy)) { + command.getHostConfig().withAutoRemove(false); } } } diff --git a/impl/container/src/main/java/io/serverlessworkflow/impl/container/executors/NamePropertySetter.java b/impl/container/src/main/java/io/serverlessworkflow/impl/container/executors/NamePropertySetter.java index d8c820c5..70bc8d83 100644 --- a/impl/container/src/main/java/io/serverlessworkflow/impl/container/executors/NamePropertySetter.java +++ b/impl/container/src/main/java/io/serverlessworkflow/impl/container/executors/NamePropertySetter.java @@ -15,21 +15,38 @@ */ package io.serverlessworkflow.impl.container.executors; +import static io.serverlessworkflow.impl.WorkflowUtils.isValid; + import com.github.dockerjava.api.command.CreateContainerCmd; import io.serverlessworkflow.api.types.Container; -import java.util.function.Function; +import io.serverlessworkflow.impl.TaskContext; +import io.serverlessworkflow.impl.WorkflowContext; +import io.serverlessworkflow.impl.WorkflowDefinition; +import io.serverlessworkflow.impl.WorkflowModel; +import io.serverlessworkflow.impl.WorkflowUtils; +import io.serverlessworkflow.impl.WorkflowValueResolver; +import java.util.Optional; + +class NamePropertySetter implements ContainerPropertySetter { -class NamePropertySetter extends ContainerPropertySetter { + private final Optional> containerName; - NamePropertySetter(CreateContainerCmd createContainerCmd, Container configuration) { - super(createContainerCmd, configuration); + NamePropertySetter(WorkflowDefinition definition, Container container) { + String containerName = container.getName(); + this.containerName = + isValid(containerName) + ? Optional.of(WorkflowUtils.buildStringFilter(definition.application(), containerName)) + : Optional.empty(); } @Override - public void accept(Function resolver) { - if (configuration.getName() != null && !configuration.getName().isEmpty()) { - String resolvedName = resolver.apply(configuration.getName()); - createContainerCmd.withName(resolvedName); - } + public void accept( + CreateContainerCmd createContainerCmd, + WorkflowContext workflowContext, + TaskContext taskContext, + WorkflowModel model) { + containerName + .map(c -> c.apply(workflowContext, taskContext, model)) + .ifPresent(createContainerCmd::withName); } } diff --git a/impl/container/src/main/java/io/serverlessworkflow/impl/container/executors/PortsPropertySetter.java b/impl/container/src/main/java/io/serverlessworkflow/impl/container/executors/PortsPropertySetter.java index 882abfa6..b176b110 100644 --- a/impl/container/src/main/java/io/serverlessworkflow/impl/container/executors/PortsPropertySetter.java +++ b/impl/container/src/main/java/io/serverlessworkflow/impl/container/executors/PortsPropertySetter.java @@ -19,34 +19,51 @@ import com.github.dockerjava.api.model.ExposedPort; import com.github.dockerjava.api.model.Ports; import io.serverlessworkflow.api.types.Container; +import io.serverlessworkflow.impl.TaskContext; +import io.serverlessworkflow.impl.WorkflowContext; +import io.serverlessworkflow.impl.WorkflowModel; import java.util.ArrayList; import java.util.List; import java.util.Map; -import java.util.function.Function; -class PortsPropertySetter extends ContainerPropertySetter { +class PortsPropertySetter implements ContainerPropertySetter { - PortsPropertySetter(CreateContainerCmd createContainerCmd, Container configuration) { - super(createContainerCmd, configuration); - } + private Ports portBindings = new Ports(); + private List exposed = new ArrayList<>(); - @Override - public void accept(Function resolver) { + PortsPropertySetter(Container configuration) { if (configuration.getPorts() != null && configuration.getPorts().getAdditionalProperties() != null) { - Ports portBindings = new Ports(); - List exposed = new ArrayList<>(); - for (Map.Entry entry : configuration.getPorts().getAdditionalProperties().entrySet()) { - int hostPort = Integer.parseInt(entry.getKey()); - int containerPort = Integer.parseInt(entry.getValue().toString()); - ExposedPort exposedPort = ExposedPort.tcp(containerPort); - portBindings.bind(exposedPort, Ports.Binding.bindPort(hostPort)); + ExposedPort exposedPort = ExposedPort.tcp(Integer.parseInt(entry.getKey())); exposed.add(exposedPort); + portBindings.bind(exposedPort, Ports.Binding.bindPort(from(entry.getValue()))); } - createContainerCmd.withExposedPorts(exposed.toArray(new ExposedPort[0])); - createContainerCmd.getHostConfig().withPortBindings(portBindings); + } + } + + @Override + public void accept( + CreateContainerCmd command, + WorkflowContext workflowContext, + TaskContext taskContext, + WorkflowModel model) { + command.withExposedPorts(exposed); + command.getHostConfig().withPortBindings(portBindings); + } + + private static Integer from(Object obj) { + if (obj instanceof Integer number) { + return number; + } else if (obj instanceof Number number) { + return number.intValue(); + } else if (obj instanceof String str) { + return Integer.parseInt(str); + } else if (obj != null) { + return Integer.parseInt(obj.toString()); + } else { + throw new IllegalArgumentException("Null value for port key"); } } } diff --git a/impl/container/src/main/java/io/serverlessworkflow/impl/container/executors/StringExpressionResolver.java b/impl/container/src/main/java/io/serverlessworkflow/impl/container/executors/StringExpressionResolver.java deleted file mode 100644 index e5848cee..00000000 --- a/impl/container/src/main/java/io/serverlessworkflow/impl/container/executors/StringExpressionResolver.java +++ /dev/null @@ -1,48 +0,0 @@ -/* - * Copyright 2020-Present The Serverless Workflow Specification Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package io.serverlessworkflow.impl.container.executors; - -import io.serverlessworkflow.impl.TaskContext; -import io.serverlessworkflow.impl.WorkflowContext; -import io.serverlessworkflow.impl.WorkflowModel; -import io.serverlessworkflow.impl.WorkflowUtils; -import io.serverlessworkflow.impl.expressions.ExpressionUtils; -import java.util.function.Function; - -class StringExpressionResolver implements Function { - - private final WorkflowContext workflowContext; - private final TaskContext taskContext; - private final WorkflowModel input; - - StringExpressionResolver( - WorkflowContext workflowContext, TaskContext taskContext, WorkflowModel input) { - this.workflowContext = workflowContext; - this.taskContext = taskContext; - this.input = input; - } - - public String apply(String expression) { - if (ExpressionUtils.isExpr(expression)) { - return WorkflowUtils.buildStringResolver( - workflowContext.definition().application(), - expression, - taskContext.input().asJavaObject()) - .apply(workflowContext, taskContext, input); - } - return expression; - } -} diff --git a/impl/container/src/main/java/io/serverlessworkflow/impl/container/executors/VolumesPropertySetter.java b/impl/container/src/main/java/io/serverlessworkflow/impl/container/executors/VolumesPropertySetter.java index bb7215ca..63830f34 100644 --- a/impl/container/src/main/java/io/serverlessworkflow/impl/container/executors/VolumesPropertySetter.java +++ b/impl/container/src/main/java/io/serverlessworkflow/impl/container/executors/VolumesPropertySetter.java @@ -19,34 +19,56 @@ import com.github.dockerjava.api.model.Bind; import com.github.dockerjava.api.model.Volume; import io.serverlessworkflow.api.types.Container; +import io.serverlessworkflow.impl.TaskContext; +import io.serverlessworkflow.impl.WorkflowContext; +import io.serverlessworkflow.impl.WorkflowDefinition; +import io.serverlessworkflow.impl.WorkflowModel; +import io.serverlessworkflow.impl.WorkflowUtils; +import io.serverlessworkflow.impl.WorkflowValueResolver; import java.util.ArrayList; -import java.util.List; +import java.util.Collection; import java.util.Map; -import java.util.function.Function; +import java.util.Objects; -class VolumesPropertySetter extends ContainerPropertySetter { +class VolumesPropertySetter implements ContainerPropertySetter { - VolumesPropertySetter(CreateContainerCmd createContainerCmd, Container configuration) { - super(createContainerCmd, configuration); - } + private static record HostContainer( + WorkflowValueResolver host, WorkflowValueResolver container) {} - @Override - public void accept(Function resolver) { + private final Collection binds = new ArrayList<>(); + + VolumesPropertySetter(WorkflowDefinition definition, Container configuration) { if (configuration.getVolumes() != null && configuration.getVolumes().getAdditionalProperties() != null) { - List binds = new ArrayList<>(); for (Map.Entry entry : configuration.getVolumes().getAdditionalProperties().entrySet()) { - String hostPath = entry.getKey(); - if (entry.getValue() instanceof String containerPath) { - String resolvedHostPath = resolver.apply(hostPath); - String resolvedContainerPath = resolver.apply(containerPath); - binds.add(new Bind(resolvedHostPath, new Volume(resolvedContainerPath))); - } else { - throw new IllegalArgumentException("Volume container paths must be strings"); - } + binds.add( + new HostContainer( + WorkflowUtils.buildStringFilter(definition.application(), entry.getKey()), + WorkflowUtils.buildStringFilter( + definition.application(), + Objects.requireNonNull( + entry.getValue(), "Volume value must be a not null string") + .toString()))); } - createContainerCmd.getHostConfig().withBinds(binds); } } + + @Override + public void accept( + CreateContainerCmd command, + WorkflowContext workflowContext, + TaskContext taskContext, + WorkflowModel model) { + command + .getHostConfig() + .withBinds( + binds.stream() + .map( + r -> + new Bind( + r.host().apply(workflowContext, taskContext, model), + new Volume(r.container.apply(workflowContext, taskContext, model)))) + .toList()); + } } diff --git a/impl/core/src/main/java/io/serverlessworkflow/impl/WorkflowUtils.java b/impl/core/src/main/java/io/serverlessworkflow/impl/WorkflowUtils.java index 7b6b3403..6fef5a85 100644 --- a/impl/core/src/main/java/io/serverlessworkflow/impl/WorkflowUtils.java +++ b/impl/core/src/main/java/io/serverlessworkflow/impl/WorkflowUtils.java @@ -59,6 +59,10 @@ public static Optional getSchemaValidator( return Optional.empty(); } + public static boolean isValid(String str) { + return str != null && !str.isBlank(); + } + public static Optional buildWorkflowFilter( WorkflowApplication app, InputFrom from) { return from != null diff --git a/impl/test/src/test/java/io/serverlessworkflow/impl/test/ContainerTest.java b/impl/test/src/test/java/io/serverlessworkflow/impl/test/ContainerTest.java index a30c3fab..a4d25419 100644 --- a/impl/test/src/test/java/io/serverlessworkflow/impl/test/ContainerTest.java +++ b/impl/test/src/test/java/io/serverlessworkflow/impl/test/ContainerTest.java @@ -38,29 +38,38 @@ import java.time.Duration; import java.util.Arrays; import java.util.Map; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; public class ContainerTest { - private static final DefaultDockerClientConfig DEFAULT_CONFIG = - DefaultDockerClientConfig.createDefaultConfigBuilder().build(); + private static DockerClient dockerClient; + private static WorkflowApplication app; + + @BeforeAll + static void init() { + DefaultDockerClientConfig defaultConfig = + DefaultDockerClientConfig.createDefaultConfigBuilder().build(); + dockerClient = + DockerClientImpl.getInstance( + defaultConfig, + new ApacheDockerHttpClient.Builder().dockerHost(defaultConfig.getDockerHost()).build()); + app = WorkflowApplication.builder().build(); + } - private static final DockerClient dockerClient = - DockerClientImpl.getInstance( - DEFAULT_CONFIG, - new ApacheDockerHttpClient.Builder().dockerHost(DEFAULT_CONFIG.getDockerHost()).build()); + @AfterAll + static void cleanup() throws IOException { + dockerClient.close(); + app.close(); + } @Test public void testContainer() throws IOException, InterruptedException { Workflow workflow = readWorkflowFromClasspath("workflows-samples/container/container-test-command.yaml"); - Map result; - try (WorkflowApplication app = WorkflowApplication.builder().build()) { - result = - app.workflowDefinition(workflow).instance(Map.of()).start().get().asMap().orElseThrow(); - } catch (Exception e) { - throw new RuntimeException("Workflow execution failed", e); - } + Map result = + app.workflowDefinition(workflow).instance(Map.of()).start().join().asMap().orElseThrow(); String containerName = "hello-world"; String containerId = findContainerIdByName(containerName); @@ -91,12 +100,8 @@ public void testContainerEnv() throws IOException, InterruptedException { Map input = Map.of("someValue", "Tested"); - Map result; - try (WorkflowApplication app = WorkflowApplication.builder().build()) { - result = app.workflowDefinition(workflow).instance(input).start().get().asMap().orElseThrow(); - } catch (Exception e) { - throw new RuntimeException("Workflow execution failed", e); - } + Map result = + app.workflowDefinition(workflow).instance(input).start().join().asMap().orElseThrow(); String containerName = "hello-world-envs"; ByteArrayOutputStream output = new ByteArrayOutputStream(); @@ -126,13 +131,8 @@ public void testContainerTimeout() throws IOException { Workflow workflow = readWorkflowFromClasspath("workflows-samples/container/container-timeout.yaml"); - Map result; - try (WorkflowApplication app = WorkflowApplication.builder().build()) { - result = - app.workflowDefinition(workflow).instance(Map.of()).start().get().asMap().orElseThrow(); - } catch (Exception e) { - throw new RuntimeException("Workflow execution failed", e); - } + Map result = + app.workflowDefinition(workflow).instance(Map.of()).start().join().asMap().orElseThrow(); String containerName = "hello-world-timeout"; String containerId = findContainerIdByName(containerName); @@ -146,13 +146,8 @@ public void testContainerCleanup() throws IOException { Workflow workflow = readWorkflowFromClasspath("workflows-samples/container/container-cleanup.yaml"); - Map result; - try (WorkflowApplication app = WorkflowApplication.builder().build()) { - result = - app.workflowDefinition(workflow).instance(Map.of()).start().get().asMap().orElseThrow(); - } catch (Exception e) { - throw new RuntimeException("Workflow execution failed", e); - } + Map result = + app.workflowDefinition(workflow).instance(Map.of()).start().join().asMap().orElseThrow(); String containerName = "hello-world-cleanup"; String containerId = findContainerIdByName(containerName); @@ -165,14 +160,8 @@ public void testContainerCleanupDefault() throws IOException { Workflow workflow = readWorkflowFromClasspath("workflows-samples/container/container-cleanup-default.yaml"); - Map result; - try (WorkflowApplication app = WorkflowApplication.builder().build()) { - result = - app.workflowDefinition(workflow).instance(Map.of()).start().get().asMap().orElseThrow(); - } catch (Exception e) { - throw new RuntimeException("Workflow execution failed", e); - } - + Map result = + app.workflowDefinition(workflow).instance(Map.of()).start().join().asMap().orElseThrow(); String containerName = "hello-world-cleanup-default"; String containerId = findContainerIdByName(containerName); assertFalse(isContainerGone(containerId)); @@ -188,17 +177,12 @@ void testPortBindings() throws Exception { new Thread( () -> { - try (WorkflowApplication app = WorkflowApplication.builder().build()) { - app.workflowDefinition(workflow) - .instance(Map.of()) - .start() - .get() - .asMap() - .orElseThrow(); - } catch (Exception e) { - // we can ignore exceptions here, as the workflow will end when the container is - // removed - } + app.workflowDefinition(workflow) + .instance(Map.of()) + .start() + .join() + .asMap() + .orElseThrow(); }) .start(); diff --git a/impl/test/src/test/resources/workflows-samples/container/container-cleanup-default.yaml b/impl/test/src/test/resources/workflows-samples/container/container-cleanup-default.yaml index 208b1faa..2a6cea6d 100644 --- a/impl/test/src/test/resources/workflows-samples/container/container-cleanup-default.yaml +++ b/impl/test/src/test/resources/workflows-samples/container/container-cleanup-default.yaml @@ -1,7 +1,7 @@ document: dsl: '1.0.2' namespace: test - name: run-example + name: container-cleanup-default version: '0.1.0' do: - runContainer: diff --git a/impl/test/src/test/resources/workflows-samples/container/container-cleanup.yaml b/impl/test/src/test/resources/workflows-samples/container/container-cleanup.yaml index b7501a75..968cc34c 100644 --- a/impl/test/src/test/resources/workflows-samples/container/container-cleanup.yaml +++ b/impl/test/src/test/resources/workflows-samples/container/container-cleanup.yaml @@ -1,7 +1,7 @@ document: dsl: '1.0.2' namespace: test - name: run-example + name: cointaner-cleanup version: '0.1.0' do: - runContainer: diff --git a/impl/test/src/test/resources/workflows-samples/container/container-env.yaml b/impl/test/src/test/resources/workflows-samples/container/container-env.yaml index 9876480b..71988b84 100644 --- a/impl/test/src/test/resources/workflows-samples/container/container-env.yaml +++ b/impl/test/src/test/resources/workflows-samples/container/container-env.yaml @@ -1,7 +1,7 @@ document: dsl: '1.0.2' namespace: test - name: run-example + name: container-env version: '0.1.0' do: - runContainer: diff --git a/impl/test/src/test/resources/workflows-samples/container/container-ports.yaml b/impl/test/src/test/resources/workflows-samples/container/container-ports.yaml index bc330610..0639e326 100644 --- a/impl/test/src/test/resources/workflows-samples/container/container-ports.yaml +++ b/impl/test/src/test/resources/workflows-samples/container/container-ports.yaml @@ -1,7 +1,7 @@ document: dsl: '1.0.2' namespace: test - name: run-example + name: container-ports version: '0.1.0' do: - runContainer: diff --git a/impl/test/src/test/resources/workflows-samples/container/container-test-command.yaml b/impl/test/src/test/resources/workflows-samples/container/container-test-command.yaml index 068137fc..874c9fbf 100644 --- a/impl/test/src/test/resources/workflows-samples/container/container-test-command.yaml +++ b/impl/test/src/test/resources/workflows-samples/container/container-test-command.yaml @@ -1,7 +1,7 @@ document: dsl: '1.0.2' namespace: test - name: run-example + name: container-test-command version: '0.1.0' do: - runContainer: diff --git a/impl/test/src/test/resources/workflows-samples/container/container-timeout.yaml b/impl/test/src/test/resources/workflows-samples/container/container-timeout.yaml index b06c0bb8..b8a3b5e0 100644 --- a/impl/test/src/test/resources/workflows-samples/container/container-timeout.yaml +++ b/impl/test/src/test/resources/workflows-samples/container/container-timeout.yaml @@ -1,7 +1,7 @@ document: dsl: '1.0.2' namespace: test - name: run-example + name: container-timeout version: '0.1.0' do: - runContainer: From 5408621492d43bf83b53fe91c26945b96d982d00 Mon Sep 17 00:00:00 2001 From: fjtirado Date: Wed, 5 Nov 2025 16:58:58 +0100 Subject: [PATCH 7/9] Disable test if docker is not Signed-off-by: fjtirado Signed-off-by: Dmitrii Tikhomirov --- .../impl/test/ContainerTest.java | 27 ++++++++++++++++--- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/impl/test/src/test/java/io/serverlessworkflow/impl/test/ContainerTest.java b/impl/test/src/test/java/io/serverlessworkflow/impl/test/ContainerTest.java index a4d25419..ed5d803e 100644 --- a/impl/test/src/test/java/io/serverlessworkflow/impl/test/ContainerTest.java +++ b/impl/test/src/test/java/io/serverlessworkflow/impl/test/ContainerTest.java @@ -41,26 +41,45 @@ import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.DisabledIf; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +@DisabledIf("checkDocker") public class ContainerTest { private static DockerClient dockerClient; - private static WorkflowApplication app; + private static Logger logger = LoggerFactory.getLogger(ContainerTest.class); - @BeforeAll - static void init() { + { DefaultDockerClientConfig defaultConfig = DefaultDockerClientConfig.createDefaultConfigBuilder().build(); dockerClient = DockerClientImpl.getInstance( defaultConfig, new ApacheDockerHttpClient.Builder().dockerHost(defaultConfig.getDockerHost()).build()); + } + + @SuppressWarnings("unused") + private static boolean checkDocker() { + try { + dockerClient.pingCmd().exec(); + return false; + } catch (Exception ex) { + logger.warn("Docker is not running, disabling container test"); + return true; + } + } + + private static WorkflowApplication app; + + @BeforeAll + static void init() { app = WorkflowApplication.builder().build(); } @AfterAll static void cleanup() throws IOException { - dockerClient.close(); app.close(); } From 59ef9c62d6a884eb092196fb1d0fbbca020f28be Mon Sep 17 00:00:00 2001 From: Francisco Javier Tirado Sarti <65240126+fjtirado@users.noreply.github.com> Date: Fri, 7 Nov 2025 18:29:28 +0100 Subject: [PATCH 8/9] Update impl/container/pom.xml Co-authored-by: Ricardo Zanini <1538000+ricardozanini@users.noreply.github.com> Signed-off-by: Dmitrii Tikhomirov --- impl/container/pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/impl/container/pom.xml b/impl/container/pom.xml index 30b7bb53..51ced574 100644 --- a/impl/container/pom.xml +++ b/impl/container/pom.xml @@ -9,7 +9,7 @@ 8.0.0-SNAPSHOT serverlessworkflow-impl-container - Serverless Workflow :: Impl :: OpenAPI + Serverless Workflow :: Impl :: Container From f8c70c631c157b37a8ac5ad5439e95aa7ddd0a72 Mon Sep 17 00:00:00 2001 From: Dmitrii Tikhomirov Date: Fri, 7 Nov 2025 11:58:09 -0800 Subject: [PATCH 9/9] post review Signed-off-by: Dmitrii Tikhomirov --- .../impl/test/ContainerTest.java | 214 ++++++++++-------- .../container/container-cleanup-default.yaml | 2 +- .../container/container-cleanup.yaml | 2 +- .../container/container-env.yaml | 2 +- .../container/container-ports.yaml | 2 +- .../container/container-test-command.yaml | 2 +- .../container/container-timeout.yaml | 2 +- 7 files changed, 122 insertions(+), 104 deletions(-) diff --git a/impl/test/src/test/java/io/serverlessworkflow/impl/test/ContainerTest.java b/impl/test/src/test/java/io/serverlessworkflow/impl/test/ContainerTest.java index ed5d803e..989ae671 100644 --- a/impl/test/src/test/java/io/serverlessworkflow/impl/test/ContainerTest.java +++ b/impl/test/src/test/java/io/serverlessworkflow/impl/test/ContainerTest.java @@ -87,139 +87,157 @@ static void cleanup() throws IOException { public void testContainer() throws IOException, InterruptedException { Workflow workflow = readWorkflowFromClasspath("workflows-samples/container/container-test-command.yaml"); - Map result = - app.workflowDefinition(workflow).instance(Map.of()).start().join().asMap().orElseThrow(); - String containerName = "hello-world"; - String containerId = findContainerIdByName(containerName); - ByteArrayOutputStream output = new ByteArrayOutputStream(); - - dockerClient - .logContainerCmd(containerId) - .withStdOut(true) - .withStdErr(true) - .withTimestamps(true) - .exec( - new LogContainerResultCallback() { - @Override - public void onNext(Frame frame) { - output.writeBytes(frame.getPayload()); - } - }) - .awaitCompletion(); - - assertTrue(output.toString().contains("Hello World")); - assertNotNull(result); - dockerClient.removeContainerCmd(containerId).withForce(true).exec(); + try { + Map result = + app.workflowDefinition(workflow).instance(Map.of()).start().join().asMap().orElseThrow(); + + String containerId = findContainerIdByName(containerName); + ByteArrayOutputStream output = new ByteArrayOutputStream(); + + dockerClient + .logContainerCmd(containerId) + .withStdOut(true) + .withStdErr(true) + .withTimestamps(true) + .exec( + new LogContainerResultCallback() { + @Override + public void onNext(Frame frame) { + output.writeBytes(frame.getPayload()); + } + }) + .awaitCompletion(); + + assertTrue(output.toString().contains("Hello World")); + assertNotNull(result); + } finally { + dockerClient.removeContainerCmd(findContainerIdByName(containerName)).withForce(true).exec(); + } } @Test public void testContainerEnv() throws IOException, InterruptedException { Workflow workflow = readWorkflowFromClasspath("workflows-samples/container/container-env.yaml"); + String containerName = "hello-world-envs"; Map input = Map.of("someValue", "Tested"); - Map result = - app.workflowDefinition(workflow).instance(input).start().join().asMap().orElseThrow(); - - String containerName = "hello-world-envs"; - ByteArrayOutputStream output = new ByteArrayOutputStream(); - - dockerClient - .logContainerCmd(findContainerIdByName(containerName)) - .withStdOut(true) - .withStdErr(true) - .withTimestamps(true) - .exec( - new LogContainerResultCallback() { - @Override - public void onNext(Frame frame) { - output.writeBytes(frame.getPayload()); - } - }) - .awaitCompletion(); - assertTrue(output.toString().contains("BAR=FOO")); - assertTrue(output.toString().contains("FOO=Tested")); - assertNotNull(result); - String containerId = findContainerIdByName(containerName); - dockerClient.removeContainerCmd(containerId).withForce(true).exec(); + try { + Map result = + app.workflowDefinition(workflow).instance(input).start().join().asMap().orElseThrow(); + + ByteArrayOutputStream output = new ByteArrayOutputStream(); + + dockerClient + .logContainerCmd(findContainerIdByName(containerName)) + .withStdOut(true) + .withStdErr(true) + .withTimestamps(true) + .exec( + new LogContainerResultCallback() { + @Override + public void onNext(Frame frame) { + output.writeBytes(frame.getPayload()); + } + }) + .awaitCompletion(); + assertTrue(output.toString().contains("BAR=FOO")); + assertTrue(output.toString().contains("FOO=Tested")); + assertNotNull(result); + } finally { + dockerClient.removeContainerCmd(findContainerIdByName(containerName)).withForce(true).exec(); + } } @Test public void testContainerTimeout() throws IOException { - Workflow workflow = - readWorkflowFromClasspath("workflows-samples/container/container-timeout.yaml"); + String containerName = "hello-world-timeout"; + try { + Workflow workflow = + readWorkflowFromClasspath("workflows-samples/container/container-timeout.yaml"); - Map result = - app.workflowDefinition(workflow).instance(Map.of()).start().join().asMap().orElseThrow(); + Map result = + app.workflowDefinition(workflow).instance(Map.of()).start().join().asMap().orElseThrow(); - String containerName = "hello-world-timeout"; - String containerId = findContainerIdByName(containerName); + String containerId = findContainerIdByName(containerName); - assertTrue(isContainerGone(containerId)); - assertNotNull(result); + assertTrue(isContainerGone(containerId)); + assertNotNull(result); + } finally { + dockerClient.removeContainerCmd(findContainerIdByName(containerName)).withForce(true).exec(); + } } @Test public void testContainerCleanup() throws IOException { - Workflow workflow = - readWorkflowFromClasspath("workflows-samples/container/container-cleanup.yaml"); + String containerName = "hello-world-cleanup"; + try { + Workflow workflow = + readWorkflowFromClasspath("workflows-samples/container/container-cleanup.yaml"); - Map result = - app.workflowDefinition(workflow).instance(Map.of()).start().join().asMap().orElseThrow(); + Map result = + app.workflowDefinition(workflow).instance(Map.of()).start().join().asMap().orElseThrow(); - String containerName = "hello-world-cleanup"; - String containerId = findContainerIdByName(containerName); - assertTrue(isContainerGone(containerId)); - assertNotNull(result); + String containerId = findContainerIdByName(containerName); + assertTrue(isContainerGone(containerId)); + assertNotNull(result); + } finally { + dockerClient.removeContainerCmd(findContainerIdByName(containerName)).withForce(true).exec(); + } } @Test public void testContainerCleanupDefault() throws IOException { - Workflow workflow = - readWorkflowFromClasspath("workflows-samples/container/container-cleanup-default.yaml"); - - Map result = - app.workflowDefinition(workflow).instance(Map.of()).start().join().asMap().orElseThrow(); String containerName = "hello-world-cleanup-default"; - String containerId = findContainerIdByName(containerName); - assertFalse(isContainerGone(containerId)); - assertNotNull(result); - - dockerClient.removeContainerCmd(containerId).withForce(true).exec(); + try { + Workflow workflow = + readWorkflowFromClasspath("workflows-samples/container/container-cleanup-default.yaml"); + + Map result = + app.workflowDefinition(workflow).instance(Map.of()).start().join().asMap().orElseThrow(); + String containerId = findContainerIdByName(containerName); + assertFalse(isContainerGone(containerId)); + assertNotNull(result); + } finally { + dockerClient.removeContainerCmd(findContainerIdByName(containerName)).withForce(true).exec(); + } } @Test void testPortBindings() throws Exception { Workflow workflow = readWorkflowFromClasspath("workflows-samples/container/container-ports.yaml"); - - new Thread( - () -> { - app.workflowDefinition(workflow) - .instance(Map.of()) - .start() - .join() - .asMap() - .orElseThrow(); - }) - .start(); - String containerName = "hello-world-ports"; - await() - .pollInterval(Duration.ofSeconds(1)) - .atMost(Duration.ofSeconds(10)) - .until(() -> findContainerIdByName(containerName) != null); - - String containerId = findContainerIdByName(containerName); - InspectContainerResponse inspect = dockerClient.inspectContainerCmd(containerId).exec(); - Map ports = inspect.getNetworkSettings().getPorts().getBindings(); - assertTrue(ports.containsKey(ExposedPort.tcp(8880))); - assertTrue(ports.containsKey(ExposedPort.tcp(8881))); - assertTrue(ports.containsKey(ExposedPort.tcp(8882))); - - dockerClient.removeContainerCmd(containerId).withForce(true).exec(); + try { + new Thread( + () -> { + app.workflowDefinition(workflow) + .instance(Map.of()) + .start() + .join() + .asMap() + .orElseThrow(); + }) + .start(); + + await() + .pollInterval(Duration.ofSeconds(1)) + .atMost(Duration.ofSeconds(10)) + .until(() -> findContainerIdByName(containerName) != null); + + String containerId = findContainerIdByName(containerName); + InspectContainerResponse inspect = dockerClient.inspectContainerCmd(containerId).exec(); + Map ports = + inspect.getNetworkSettings().getPorts().getBindings(); + + assertTrue(ports.containsKey(ExposedPort.tcp(8880))); + assertTrue(ports.containsKey(ExposedPort.tcp(8881))); + assertTrue(ports.containsKey(ExposedPort.tcp(8882))); + } finally { + dockerClient.removeContainerCmd(findContainerIdByName(containerName)).withForce(true).exec(); + } } private static String findContainerIdByName(String containerName) { diff --git a/impl/test/src/test/resources/workflows-samples/container/container-cleanup-default.yaml b/impl/test/src/test/resources/workflows-samples/container/container-cleanup-default.yaml index 2a6cea6d..a1a10b80 100644 --- a/impl/test/src/test/resources/workflows-samples/container/container-cleanup-default.yaml +++ b/impl/test/src/test/resources/workflows-samples/container/container-cleanup-default.yaml @@ -7,6 +7,6 @@ do: - runContainer: run: container: - image: alpine:latest + image: busybox:latest command: echo Hello World name: hello-world-cleanup-default diff --git a/impl/test/src/test/resources/workflows-samples/container/container-cleanup.yaml b/impl/test/src/test/resources/workflows-samples/container/container-cleanup.yaml index 968cc34c..aa5680f4 100644 --- a/impl/test/src/test/resources/workflows-samples/container/container-cleanup.yaml +++ b/impl/test/src/test/resources/workflows-samples/container/container-cleanup.yaml @@ -7,7 +7,7 @@ do: - runContainer: run: container: - image: alpine:latest + image: busybox:latest command: echo Hello World name: hello-world-cleanup lifetime: diff --git a/impl/test/src/test/resources/workflows-samples/container/container-env.yaml b/impl/test/src/test/resources/workflows-samples/container/container-env.yaml index 71988b84..8d50dbab 100644 --- a/impl/test/src/test/resources/workflows-samples/container/container-env.yaml +++ b/impl/test/src/test/resources/workflows-samples/container/container-env.yaml @@ -7,7 +7,7 @@ do: - runContainer: run: container: - image: alpine:latest + image: busybox:latest command: printenv name: hello-world-envs lifetime: diff --git a/impl/test/src/test/resources/workflows-samples/container/container-ports.yaml b/impl/test/src/test/resources/workflows-samples/container/container-ports.yaml index 0639e326..3840b876 100644 --- a/impl/test/src/test/resources/workflows-samples/container/container-ports.yaml +++ b/impl/test/src/test/resources/workflows-samples/container/container-ports.yaml @@ -7,7 +7,7 @@ do: - runContainer: run: container: - image: alpine:latest + image: busybox:latest command: sleep 300 name: hello-world-ports ports: diff --git a/impl/test/src/test/resources/workflows-samples/container/container-test-command.yaml b/impl/test/src/test/resources/workflows-samples/container/container-test-command.yaml index 874c9fbf..54887ecb 100644 --- a/impl/test/src/test/resources/workflows-samples/container/container-test-command.yaml +++ b/impl/test/src/test/resources/workflows-samples/container/container-test-command.yaml @@ -7,7 +7,7 @@ do: - runContainer: run: container: - image: alpine:3.20 + image: busybox:3.20 command: echo Hello World name: hello-world lifetime: diff --git a/impl/test/src/test/resources/workflows-samples/container/container-timeout.yaml b/impl/test/src/test/resources/workflows-samples/container/container-timeout.yaml index b8a3b5e0..82f18891 100644 --- a/impl/test/src/test/resources/workflows-samples/container/container-timeout.yaml +++ b/impl/test/src/test/resources/workflows-samples/container/container-timeout.yaml @@ -7,7 +7,7 @@ do: - runContainer: run: container: - image: alpine:latest + image: busybox:latest command: sleep 300 name: hello-world-timeout lifetime: