From 0ee0e34d0164ada8e9a282737773ff390368e7a6 Mon Sep 17 00:00:00 2001 From: Sebatian Rath Date: Sun, 1 Feb 2026 21:42:18 -0500 Subject: [PATCH 1/8] Add docker client dependencies to go.mod --- go.mod | 12 ++++++++++++ go.sum | 44 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+) diff --git a/go.mod b/go.mod index d0a5076..475584b 100644 --- a/go.mod +++ b/go.mod @@ -10,6 +10,8 @@ require ( github.com/aws/aws-sdk-go-v2/credentials v1.19.6 github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.20.18 github.com/aws/aws-sdk-go-v2/service/s3 v1.95.0 + github.com/containerd/errdefs v1.0.0 + github.com/docker/docker v28.5.2+incompatible github.com/fatih/color v1.18.0 github.com/go-git/go-git/v5 v5.16.4 github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 @@ -67,9 +69,13 @@ require ( github.com/clipperhouse/stringish v0.1.1 // indirect github.com/clipperhouse/uax29/v2 v2.3.0 // indirect github.com/cloudflare/circl v1.6.2 // indirect + github.com/containerd/errdefs/pkg v0.3.0 // indirect github.com/cyphar/filepath-securejoin v0.6.1 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/distribution/reference v0.6.0 // indirect github.com/dlclark/regexp2 v1.11.5 // indirect + github.com/docker/go-connections v0.5.0 // indirect + github.com/docker/go-units v0.5.0 // indirect github.com/emirpasic/gods v1.18.1 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/gage-technologies/mistral-go v1.1.0 // indirect @@ -91,7 +97,11 @@ require ( github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-runewidth v0.0.19 // indirect github.com/mattn/go-shellwords v1.0.12 // indirect + github.com/moby/docker-image-spec v1.3.1 // indirect + github.com/moby/sys/atomicwriter v0.1.0 // indirect github.com/montanaflynn/stats v0.7.1 // indirect + github.com/opencontainers/go-digest v1.0.0 // indirect + github.com/opencontainers/image-spec v1.1.1 // indirect github.com/pjbgf/sha1cd v0.5.0 // indirect github.com/pkoukk/tiktoken-go v0.1.8 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect @@ -108,6 +118,7 @@ require ( go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.64.0 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0 // indirect go.opentelemetry.io/otel v1.39.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.39.0 // indirect go.opentelemetry.io/otel/metric v1.39.0 // indirect go.opentelemetry.io/otel/trace v1.39.0 // indirect golang.org/x/oauth2 v0.34.0 // indirect @@ -121,4 +132,5 @@ require ( google.golang.org/protobuf v1.36.11 // indirect gopkg.in/warnings.v0 v0.1.2 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect + gotest.tools/v3 v3.5.2 // indirect ) diff --git a/go.sum b/go.sum index e0ed857..fef506b 100644 --- a/go.sum +++ b/go.sum @@ -18,6 +18,8 @@ cloud.google.com/go/vertexai v0.15.0 h1:FRVdUsm07qX9P/19SMDd/RZVwLR9sCm3HN0Ze7wS cloud.google.com/go/vertexai v0.15.0/go.mod h1:YTy1fUT3yH57nClxotpyY29T0MhnNUHIyysef8u69ow= dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= @@ -73,6 +75,9 @@ github.com/aws/smithy-go v1.24.0 h1:LpilSUItNPFr1eY85RYgTIg5eIEPtvFbskaFcmmIUnk= github.com/aws/smithy-go v1.24.0/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0= github.com/bmatcuk/doublestar/v4 v4.9.1 h1:X8jg9rRZmJd4yRy7ZeNDRnM+T3ZfHv15JiBJ/avrEXE= github.com/bmatcuk/doublestar/v4 v4.9.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= +github.com/cenkalti/backoff v2.2.1+incompatible h1:tNowT99t7UNflLxfYYSlKYsBpXdEet03Pg2g16Swow4= +github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= +github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= @@ -83,6 +88,12 @@ github.com/cloudflare/circl v1.6.2 h1:hL7VBpHHKzrV5WTfHCaBsgx/HGbBYlgrwvNXEVDYYs github.com/cloudflare/circl v1.6.2/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4= github.com/cncf/xds/go v0.0.0-20251022180443-0feb69152e9f h1:Y8xYupdHxryycyPlc9Y+bSQAYZnetRJ70VMVKm5CKI0= github.com/cncf/xds/go v0.0.0-20251022180443-0feb69152e9f/go.mod h1:HlzOvOjVBOfTGSRXRyY0OiCS/3J1akRGQQpRO/7zyF4= +github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= +github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= +github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= +github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= +github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= +github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/cyphar/filepath-securejoin v0.6.1 h1:5CeZ1jPXEiYt3+Z6zqprSAgSWiggmpVyciv8syjIpVE= github.com/cyphar/filepath-securejoin v0.6.1/go.mod h1:A8hd4EnAeyujCJRrICiOWqjS1AX0a9kM5XL+NwKoYSc= @@ -90,8 +101,16 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= +github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/docker/docker v28.5.2+incompatible h1:DBX0Y0zAjZbSrm1uzOkdr1onVghKaftjlSWt4AFexzM= +github.com/docker/docker v28.5.2+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c= +github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc= +github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= +github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o= github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE= github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= @@ -144,6 +163,9 @@ github.com/googleapis/gax-go/v2 v2.16.0 h1:iHbQmKLLZrexmb0OSsNGTeSTS0HO4YvFOG8g5 github.com/googleapis/gax-go/v2 v2.16.0/go.mod h1:o1vfQjjNZn4+dPnRdl/4ZD7S9414Y4xA+a/6Icj6l14= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/grpc-ecosystem/grpc-gateway v1.16.0 h1:gmcG1KaJ57LophUzW0Hy8NmPhnMZb4M0+kPpLofRdBo= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 h1:NmZ1PKzSTQbuGHw9DGPFomqkkLWMC+vZCkfs+FHv1Vg= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3/go.mod h1:zQrxl1YP88HQlA6i9c63DSVPFklWpGX4OWAc9bFuaH4= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A= @@ -171,10 +193,24 @@ github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byF github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/mattn/go-shellwords v1.0.12 h1:M2zGm7EW6UQJvDeQxo4T51eKPurbeFbe8WtebGE2xrk= github.com/mattn/go-shellwords v1.0.12/go.mod h1:EZzvwXDESEeg03EKmM+RmDnNOPKG4lLtQsUlTZDWQ8Y= +github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= +github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= +github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw= +github.com/moby/sys/atomicwriter v0.1.0/go.mod h1:Ul8oqv2ZMNHOceF643P6FKPXeCmYtlQMvpizfsSoaWs= +github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU= +github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko= +github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ= +github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc= github.com/montanaflynn/stats v0.7.1 h1:etflOAAHORrCC44V+aR6Ftzort912ZU+YLiSTuV8eaE= github.com/montanaflynn/stats v0.7.1/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow= +github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= +github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k= github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= +github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= github.com/pjbgf/sha1cd v0.5.0 h1:a+UkboSi1znleCDUNT3M5YxjOnN1fz2FhN48FlwCxs0= github.com/pjbgf/sha1cd v0.5.0/go.mod h1:lhpGlyHLpQZoxMv8HcgXvZEhcGs0PG/vsZnEJ7H0iCM= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= @@ -238,6 +274,10 @@ go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0 h1:ssfIgGN go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0/go.mod h1:GQ/474YrbE4Jx8gZ4q5I4hrhUzM6UPzyrqJYV2AqPoQ= go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48= go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.39.0 h1:f0cb2XPmrqn4XMy9PNliTgRKJgS5WcL/u0/WRYGz4t0= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.39.0/go.mod h1:vnakAaFckOMiMtOIhFI2MNH4FYrZzXCYxmb1LlhoGz8= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.39.0 h1:Ckwye2FpXkYgiHX7fyVrN1uA/UYd9ounqqTuSNAv0k4= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.39.0/go.mod h1:teIFJh5pW2y+AN7riv6IBPX2DuesS3HgP39mwOspKwU= go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0= go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs= go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18= @@ -246,6 +286,8 @@ go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2W go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew= go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI= go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA= +go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A= +go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= go.yaml.in/yaml/v4 v4.0.0-rc.3 h1:3h1fjsh1CTAPjW7q/EMe+C8shx5d8ctzZTrLcs/j8Go= go.yaml.in/yaml/v4 v4.0.0-rc.3/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0= @@ -326,5 +368,7 @@ gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= +gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= sigs.k8s.io/yaml v1.3.0 h1:a2VclLzOGrwOHDiV8EfBGhvjHvP46CtW5j6POvhYGGo= sigs.k8s.io/yaml v1.3.0/go.mod h1:GeOyir5tyXNByN85N/dRIT9es5UQNerPYEKK56eTBm8= From a3d0b3ff422446e9897c78e70d3edf0b010239d5 Mon Sep 17 00:00:00 2001 From: Sebatian Rath Date: Sun, 1 Feb 2026 21:57:33 -0500 Subject: [PATCH 2/8] Add Docker client wrapper for container operations --- core/docker.go | 477 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 477 insertions(+) create mode 100644 core/docker.go diff --git a/core/docker.go b/core/docker.go new file mode 100644 index 0000000..a975b8b --- /dev/null +++ b/core/docker.go @@ -0,0 +1,477 @@ +package core + +import ( + "archive/tar" + "context" + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + "runtime" + "strings" + + "github.com/actionforge/actrun-cli/utils" + "github.com/containerd/errdefs" + "github.com/docker/docker/api/types" + "github.com/docker/docker/api/types/build" + "github.com/docker/docker/api/types/container" + "github.com/docker/docker/api/types/image" + "github.com/docker/docker/api/types/mount" + "github.com/docker/docker/api/types/network" + "github.com/docker/docker/client" + "github.com/docker/docker/pkg/stdcopy" +) + +type DockerClient struct { + cli *client.Client +} + +func getDockerHost() string { + if host := os.Getenv("DOCKER_HOST"); host != "" { + return host + } + + // On macOS, docker desktop uses a socet in my home folder and not in /var/run/docker.sock. + if runtime.GOOS == "darwin" { + home, err := os.UserHomeDir() + if err == nil { + macosSocket := filepath.Join(home, ".docker", "run", "docker.sock") + if _, err := os.Stat(macosSocket); err == nil { + return "unix://" + macosSocket + } + } + } + + // On Windows, docker uses the named pipe + if runtime.GOOS == "windows" { + return "npipe:////./pipe/docker_engine" + } + + return "" // whatever Docker decides +} + +func NewDockerClient() (*DockerClient, error) { + opts := []client.Opt{client.WithAPIVersionNegotiation()} + + if host := getDockerHost(); host != "" { + opts = append(opts, client.WithHost(host)) + } else { + opts = append(opts, client.FromEnv) + } + + cli, err := client.NewClientWithOpts(opts...) + if err != nil { + return nil, fmt.Errorf("failed to create docker client: %w", err) + } + return &DockerClient{cli: cli}, nil +} + +func (d *DockerClient) Close() error { + return d.cli.Close() +} + +type DockerRunConfig struct { + Image string + Name string + Entrypoint []string + Cmd []string + Env map[string]string + WorkingDir string + Volumes []Volume + Network string + AutoRemove bool + Labels map[string]string + AttachStdio bool +} + +func (d *DockerClient) ImageExists(ctx context.Context, imageRef string) (bool, error) { + _, err := d.cli.ImageInspect(ctx, imageRef) + if err != nil { + if errdefs.IsNotFound(err) { + return false, nil + } + return false, err + } + return true, nil +} + +func (d *DockerClient) PullImage(ctx context.Context, imageRef string) error { + utils.LogOut.Infof("%sPulling image '%s'\n", utils.LogGhStartGroup, imageRef) + defer utils.LogOut.Infof(utils.LogGhEndGroup) + + reader, err := d.cli.ImagePull(ctx, imageRef, image.PullOptions{}) + if err != nil { + return fmt.Errorf("failed to pull image %s: %w", imageRef, err) + } + defer reader.Close() + + decoder := json.NewDecoder(reader) + for { + var event map[string]any + if err := decoder.Decode(&event); err != nil { + if err == io.EOF { + break + } + return err + } + + if status, ok := event["status"].(string); ok { + if progress, ok := event["progress"].(string); ok { + utils.LogOut.Infof(" %s %s\n", status, progress) + } else { + utils.LogOut.Infof(" %s\n", status) + } + } + } + + return nil +} + +func (d *DockerClient) BuildImage(ctx context.Context, dockerfilePath, contextPath, tag string) error { + utils.LogOut.Infof("%sBuilding image '%s' from %s\n", utils.LogGhStartGroup, tag, dockerfilePath) + defer utils.LogOut.Infof(utils.LogGhEndGroup) + + // Create a tar archive of the build context + buildContext, err := createBuildContext(contextPath) + if err != nil { + return fmt.Errorf("failed to create build context: %w", err) + } + + // Get the relative path of the Dockerfile within the context + dockerfileRelPath, err := filepath.Rel(contextPath, dockerfilePath) + if err != nil { + dockerfileRelPath = filepath.Base(dockerfilePath) + } + + resp, err := d.cli.ImageBuild(ctx, buildContext, build.ImageBuildOptions{ + Dockerfile: dockerfileRelPath, + Tags: []string{tag}, + Remove: true, + }) + if err != nil { + return fmt.Errorf("failed to build image: %w", err) + } + defer resp.Body.Close() + + // parse and display build output + decoder := json.NewDecoder(resp.Body) + for { + var event map[string]any + if err := decoder.Decode(&event); err != nil { + if err == io.EOF { + break + } + return err + } + + if stream, ok := event["stream"].(string); ok { + utils.LogOut.Infof("%s", stream) + } + if errMsg, ok := event["error"].(string); ok { + return fmt.Errorf("build error: %s", errMsg) + } + } + + return nil +} + +func (d *DockerClient) RunContainer(ctx context.Context, config DockerRunConfig) (int64, error) { + var envSlice []string + for k, v := range config.Env { + envSlice = append(envSlice, fmt.Sprintf("%s=%s", k, v)) + } + + var mounts []mount.Mount + for _, v := range config.Volumes { + m := mount.Mount{ + Type: mount.TypeBind, + Source: v.SourceVolumePath, + Target: v.TargetVolumePath, + } + if v.ReadOnly { + m.ReadOnly = true + } + mounts = append(mounts, m) + } + + containerConfig := &container.Config{ + Image: config.Image, + Env: envSlice, + WorkingDir: config.WorkingDir, + Labels: config.Labels, + Tty: false, + } + + if len(config.Entrypoint) > 0 { + containerConfig.Entrypoint = config.Entrypoint + } + + if len(config.Cmd) > 0 { + containerConfig.Cmd = config.Cmd + } + + if config.AttachStdio { + containerConfig.AttachStdout = true + containerConfig.AttachStderr = true + } + + hostConfig := &container.HostConfig{ + Mounts: mounts, + AutoRemove: config.AutoRemove, + } + + if config.Network != "" { + hostConfig.NetworkMode = container.NetworkMode(config.Network) + } + + networkConfig := &network.NetworkingConfig{} + + resp, err := d.cli.ContainerCreate(ctx, containerConfig, hostConfig, networkConfig, nil, config.Name) + if err != nil { + return -1, fmt.Errorf("failed to create container: %w", err) + } + + containerID := resp.ID + + var attachResp types.HijackedResponse + if config.AttachStdio { + attachResp, err = d.cli.ContainerAttach(ctx, containerID, container.AttachOptions{ + Stream: true, + Stdout: true, + Stderr: true, + }) + if err != nil { + return -1, fmt.Errorf("failed to attach to container: %w", err) + } + defer attachResp.Close() + } + + // set up wait BEFORE starting, needed when AutoRemove is true, + // otherwise the container may be removed before we can wait for it + // Use WaitConditionRemoved when AutoRemove is true, as the container will be deleted immediately + waitCondition := container.WaitConditionNotRunning + if config.AutoRemove { + waitCondition = container.WaitConditionRemoved + } + statusCh, errCh := d.cli.ContainerWait(ctx, containerID, waitCondition) + + if err := d.cli.ContainerStart(ctx, containerID, container.StartOptions{}); err != nil { + return -1, fmt.Errorf("failed to start container: %w", err) + } + + var outputDone chan struct{} + if config.AttachStdio { + outputDone = make(chan struct{}) + go func() { + _, _ = stdcopy.StdCopy(utils.LogOut.Out, utils.LogErr.Out, attachResp.Reader) + close(outputDone) + }() + } + + // Wait for output to be copied first (this blocks until container finishes) + if outputDone != nil { + <-outputDone + } + + // Now wait for the container to finish and get exit code + var exitCode int64 + select { + case err := <-errCh: + if err != nil { + return -1, fmt.Errorf("error waiting for container: %w", err) + } + // errCh received nil, still need to get exit code from statusCh + status := <-statusCh + exitCode = status.StatusCode + case status := <-statusCh: + exitCode = status.StatusCode + } + + return exitCode, nil +} + +func (d *DockerClient) RemoveContainer(ctx context.Context, containerID string, force bool) error { + return d.cli.ContainerRemove(ctx, containerID, container.RemoveOptions{Force: force}) +} + +// createBuildContext creates a tar archive for the Docker build context +func createBuildContext(contextPath string) (io.Reader, error) { + pr, pw := io.Pipe() + + go func() { + tw := tar.NewWriter(pw) + defer tw.Close() + defer pw.Close() + + err := filepath.Walk(contextPath, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + + if info.IsDir() { + return nil + } + + // Get relative path + relPath, err := filepath.Rel(contextPath, path) + if err != nil { + return err + } + + // Create tar header + header, err := tar.FileInfoHeader(info, "") + if err != nil { + return err + } + header.Name = relPath + + if err := tw.WriteHeader(header); err != nil { + return err + } + + // Write file content + file, err := os.Open(path) + if err != nil { + return err + } + defer file.Close() + + _, err = io.Copy(tw, file) + return err + }) + + if err != nil { + pw.CloseWithError(err) + } + }() + + return pr, nil +} + +// Helper function to parse command string into args +func ParseCommandArgs(cmdStr string) []string { + if cmdStr == "" { + return nil + } + + var args []string + var current strings.Builder + inQuote := false + quoteChar := rune(0) + + for _, r := range cmdStr { + switch { + case r == '"' || r == '\'': + if !inQuote { + inQuote = true + quoteChar = r + } else if r == quoteChar { + inQuote = false + quoteChar = 0 + } else { + current.WriteRune(r) + } + case r == ' ' && !inQuote: + if current.Len() > 0 { + args = append(args, current.String()) + current.Reset() + } + default: + current.WriteRune(r) + } + } + + if current.Len() > 0 { + args = append(args, current.String()) + } + + return args +} + +// StreamLogs streams container logs to stdout/stderr +func (d *DockerClient) StreamLogs(ctx context.Context, containerID string) error { + out, err := d.cli.ContainerLogs(ctx, containerID, container.LogsOptions{ + ShowStdout: true, + ShowStderr: true, + Follow: true, + }) + if err != nil { + return err + } + defer out.Close() + + _, err = stdcopy.StdCopy(utils.LogOut.Out, utils.LogErr.Out, out) + return err +} + +// ContainerInfo2DockerRunConfig converts ContainerInfo to DockerRunConfig for backward compatibility +func ContainerInfo2DockerRunConfig(ci ContainerInfo) DockerRunConfig { + var entrypoint []string + if ci.ContainerEntryPoint != "" { + entrypoint = []string{ci.ContainerEntryPoint} + } + + var cmd []string + if ci.ContainerEntryPointArgs != "" { + cmd = ParseCommandArgs(ci.ContainerEntryPointArgs) + } + + return DockerRunConfig{ + Image: ci.ContainerImage, + Name: ci.ContainerDisplayName, + Entrypoint: entrypoint, + Cmd: cmd, + Env: ci.ContainerEnvironmentVariables, + WorkingDir: ci.ContainerWorkDirectory, + Volumes: ci.MountVolumes, + Network: ci.ContainerNetwork, + AutoRemove: true, + AttachStdio: true, + Labels: map[string]string{"actionforge": "true"}, + } +} + +// SDKDockerRun runs a container using the Docker SDK (replacement for CLI-based DockerRun) +func SDKDockerRun(ctx context.Context, config DockerRunConfig) (int64, error) { + dockerClient, err := NewDockerClient() + if err != nil { + return -1, err + } + defer dockerClient.Close() + + return dockerClient.RunContainer(ctx, config) +} + +// SDKDockerPull pulls an image using the Docker SDK +func SDKDockerPull(ctx context.Context, imageRef string) error { + dockerClient, err := NewDockerClient() + if err != nil { + return err + } + defer dockerClient.Close() + + return dockerClient.PullImage(ctx, imageRef) +} + +// SDKDockerBuild builds an image using the Docker SDK +func SDKDockerBuild(ctx context.Context, dockerfilePath, contextPath, tag string) error { + dockerClient, err := NewDockerClient() + if err != nil { + return err + } + defer dockerClient.Close() + + return dockerClient.BuildImage(ctx, dockerfilePath, contextPath, tag) +} + +// SDKDockerImageExists checks if an image exists using the Docker SDK +func SDKDockerImageExists(ctx context.Context, imageRef string) (bool, error) { + dockerClient, err := NewDockerClient() + if err != nil { + return false, err + } + defer dockerClient.Close() + + return dockerClient.ImageExists(ctx, imageRef) +} From 110557319a93f4e2ee3c107b56c143a3585c7801 Mon Sep 17 00:00:00 2001 From: Sebatian Rath Date: Sun, 1 Feb 2026 22:14:07 -0500 Subject: [PATCH 3/8] Add node interface definitions for docker-run --- .../interface_core_docker_run_v1.go | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 node_interfaces/interface_core_docker_run_v1.go diff --git a/node_interfaces/interface_core_docker_run_v1.go b/node_interfaces/interface_core_docker_run_v1.go new file mode 100644 index 0000000..bb2391b --- /dev/null +++ b/node_interfaces/interface_core_docker_run_v1.go @@ -0,0 +1,33 @@ +// Code generated by actrun. DO NOT EDIT. + +package node_interfaces + +import "github.com/actionforge/actrun-cli/core" // Run a Docker container from an image or Dockerfile. + +// ==> (o) Inputs + +// Arguments to pass to the container entrypoint. +const Core_docker_run_v1_Input_args core.InputId = "args" +// Override the default entrypoint of the container. +const Core_docker_run_v1_Input_entrypoint core.InputId = "entrypoint" +// Environment variables to pass to the container. +const Core_docker_run_v1_Input_env core.InputId = "env" +const Core_docker_run_v1_Input_exec core.InputId = "exec" +// Docker image URL or Dockerfile path. +const Core_docker_run_v1_Input_image core.InputId = "image" +// Docker network to connect the container to. +const Core_docker_run_v1_Input_network core.InputId = "network" +// When to pull the image from the registry. +const Core_docker_run_v1_Input_pull core.InputId = "pull" +// Mount the host's Docker socket into the container, enabling Docker-in-Docker. +const Core_docker_run_v1_Input_docker_socket core.InputId = "docker_socket" +// Volume mounts in the format host_path:container_path. +const Core_docker_run_v1_Input_volumes core.InputId = "volumes" +// Working directory inside the container. +const Core_docker_run_v1_Input_workdir core.InputId = "workdir" + +// Outputs (o) ==> + +const Core_docker_run_v1_Output_exec_err core.OutputId = "exec-err" +const Core_docker_run_v1_Output_exec_success core.OutputId = "exec-success" +const Core_docker_run_v1_Output_exit_code core.OutputId = "exit_code" From f2515e509e09421299267cabc473c79cada9ba91 Mon Sep 17 00:00:00 2001 From: Sebatian Rath Date: Sun, 1 Feb 2026 22:31:52 -0500 Subject: [PATCH 4/8] Add docker-run node yaml definition --- nodes/docker-run@v1.yml | 121 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 121 insertions(+) create mode 100644 nodes/docker-run@v1.yml diff --git a/nodes/docker-run@v1.yml b/nodes/docker-run@v1.yml new file mode 100644 index 0000000..daeed5f --- /dev/null +++ b/nodes/docker-run@v1.yml @@ -0,0 +1,121 @@ +yaml-version: 3.0 + +id: core/docker-run +name: Docker Run +version: 1 +category: system +icon: octContainer +style: + header: + background: "#2496ED" + body: + background: "#1a6fb3" +short_desc: Run a Docker container from an image or Dockerfile. +long_desc: | + Runs a Docker container directly without requiring a GitHub Action wrapper. + + The `image` input uses the `docker://` prefix convention (same as GitHub Actions): + + **Pull from registry** (use `docker://` prefix): + - `docker://hello-world` + - `docker://alpine:latest` + - `docker://ghcr.io/owner/image:tag` + - `docker://docker.io/library/nginx` + + **Build from Dockerfile** (no prefix): + - `Dockerfile` + - `./Dockerfile` + - `./build/Dockerfile.prod` + - `/absolute/path/to/Dockerfile` + +outputs: + exec-success: + name: Success + exec: true + index: 0 + exec-err: + name: Error + exec: true + index: 1 + exit_code: + name: Exit Code + type: number + index: 2 + +inputs: + exec: + exec: true + index: 0 + image: + name: Image + type: string + desc: | + Use `docker://` prefix for registry images, or path for Dockerfile. + Registry: `docker://alpine:latest`, `docker://ghcr.io/owner/image` + Dockerfile: `Dockerfile`, `./build/Dockerfile.prod` + index: 1 + hint: "docker://alpine:latest" + args: + name: Args + type: "[]string" + desc: Arguments to pass to the container entrypoint. + index: 2 + entrypoint: + name: Entrypoint + type: "[]string" + desc: | + Override the image's default entrypoint. + Example: `["/bin/sh", "-c"]` + index: 3 + workdir: + name: Working Directory + type: string + desc: | + Working directory inside the container. + For GitHub workflows: defaults to `/github/workspace` (where the repo is mounted). + Otherwise: uses the image's default WORKDIR. + index: 4 + hint: "Eg: /my-dir" + volumes: + name: Volumes + type: "[]string" + index: 5 + hint: "/host/path:/container/path" + desc: Volume mounts in the format `host_path:container_path` or `host_path:container_path:ro` for read-only. + network: + name: Network + type: string + desc: Docker network to connect the container to (e.g., host, bridge, or a custom network name). + index: 6 + hint: "Eg: host, bridge, none" + pull: + name: Pull Policy + type: option + desc: When to pull the image from the registry. + index: 7 + default: missing + options: + - name: Always + value: always + - name: Missing + value: missing + - name: Never + value: never + docker_socket: + name: Mount Docker Socket + type: bool + desc: | + Mount the host's Docker socket into the container, enabling Docker-in-Docker. + This matches GitHub Actions' default behavior for container actions. + Disable if the container doesn't need Docker access. + index: 8 + default: true + env: + name: Environment Vars + type: "[]string" + index: 9 + hint: "MY_VAR=VALUE" + desc: | + Additional environment variables for the container. + Workflow/job environment variables are automatically included. + Use `KEY=VALUE` format to add or override variables. From 752d23e790078205dafb32c992fc8690a917ad62 Mon Sep 17 00:00:00 2001 From: Sebatian Rath Date: Sun, 1 Feb 2026 22:48:29 -0500 Subject: [PATCH 5/8] Implement docker-run node for container execution --- nodes/docker-run@v1.go | 403 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 403 insertions(+) create mode 100644 nodes/docker-run@v1.go diff --git a/nodes/docker-run@v1.go b/nodes/docker-run@v1.go new file mode 100644 index 0000000..6cf6809 --- /dev/null +++ b/nodes/docker-run@v1.go @@ -0,0 +1,403 @@ +package nodes + +import ( + _ "embed" + "fmt" + "maps" + "os" + "path/filepath" + "runtime" + "strings" + + "github.com/actionforge/actrun-cli/core" + ni "github.com/actionforge/actrun-cli/node_interfaces" + "github.com/google/uuid" +) + +//go:embed docker-run@v1.yml +var dockerDefinition string + +type DockerNode struct { + core.NodeBaseComponent + core.Inputs + core.Outputs + core.Executions +} + +func (n *DockerNode) ExecuteImpl(c *core.ExecutionState, inputId core.InputId, prevError error) error { + image, err := core.InputValueById[string](c, n, ni.Core_docker_run_v1_Input_image) + if err != nil { + return err + } + + if image == "" { + return core.CreateErr(c, nil, "image is required") + } + + args, err := core.InputValueById[[]string](c, n, ni.Core_docker_run_v1_Input_args) + if err != nil { + return err + } + + entrypoint, err := core.InputValueById[[]string](c, n, ni.Core_docker_run_v1_Input_entrypoint) + if err != nil { + return err + } + + workdir, err := core.InputValueById[string](c, n, ni.Core_docker_run_v1_Input_workdir) + if err != nil { + return err + } + + envs, err := core.InputValueById[[]string](c, n, ni.Core_docker_run_v1_Input_env) + if err != nil { + return err + } + + volumes, err := core.InputValueById[[]string](c, n, ni.Core_docker_run_v1_Input_volumes) + if err != nil { + return err + } + + network, err := core.InputValueById[string](c, n, ni.Core_docker_run_v1_Input_network) + if err != nil { + return err + } + + pullPolicy, err := core.InputValueById[string](c, n, ni.Core_docker_run_v1_Input_pull) + if err != nil { + return err + } + + dockerSocket, err := core.InputValueById[bool](c, n, ni.Core_docker_run_v1_Input_docker_socket) + if err != nil { + return err + } + + // build env map from context and input env vars. + // In contrary to Docker, env vars in GitHub are automatically included, see code. + // See: github.com/actions/runner/blob/main/src/Runner.Worker/Handlers/ContainerActionHandler.cs + // I just take this behaviour for the entire system. + // TODO: (Seb) Add an option to override this + currentEnvMap := c.GetContextEnvironMapCopy() + for _, env := range envs { + if idx := strings.Index(env, "="); idx > 0 { + currentEnvMap[env[:idx]] = env[idx+1:] + } + // KEY without `=` is a Docker feature, but here a no-op since + // we pass the full env to Docker anyway + } + + // parse image reference. docker:// prefix means registry, otherwise Dockerfile + isRegistry, imageRef := parseImageReference(image) + + var containerImage string + cwd, _ := os.Getwd() + + dockerClient, err := core.NewDockerClient() + if err != nil { + return core.CreateErr(c, err, "failed to create Docker client") + } + defer dockerClient.Close() + + if isRegistry { + // pull from registry + containerImage = imageRef + + shouldPull := false + switch pullPolicy { + case "always": + shouldPull = true + case "missing": + exists, err := dockerClient.ImageExists(c.Ctx, containerImage) + if err != nil { + return core.CreateErr(c, err, "failed to check if image exists") + } + shouldPull = !exists + case "never": + shouldPull = false + } + + if shouldPull { + err = dockerClient.PullImage(c.Ctx, containerImage) + if err != nil { + return core.CreateErr(c, err, "failed to pull Docker image: %s", containerImage) + } + } + } else { + // build from Dockerfile + dockerfilePath := imageRef + if !filepath.IsAbs(dockerfilePath) { + dockerfilePath = filepath.Join(cwd, dockerfilePath) + } + + if _, err := os.Stat(dockerfilePath); os.IsNotExist(err) { + // check if this looks like an image reference (user forgot docker:// prefix) + if looksLikeImageReference(imageRef) { + return core.CreateErr(c, nil, "Dockerfile not found: %s. Did you mean 'docker://%s' to pull from a registry?", dockerfilePath, imageRef) + } + return core.CreateErr(c, nil, "Dockerfile not found: %s", dockerfilePath) + } + + buildTag := fmt.Sprintf("actrun-docker-%s", uuid.New().String()[:8]) + buildContext := filepath.Dir(dockerfilePath) + + err = dockerClient.BuildImage(c.Ctx, dockerfilePath, buildContext, buildTag) + if err != nil { + return core.CreateErr(c, err, "failed to build Docker image from %s", dockerfilePath) + } + + containerImage = buildTag + } + + // parse volume mounts + var mountVolumes []core.Volume + for _, vol := range volumes { + if vol == "" { + continue + } + v, err := parseVolume(vol) + if err != nil { + return core.CreateErr(c, err, "invalid volume format: %s", vol) + } + mountVolumes = append(mountVolumes, v) + } + + // mount docker socket on Linux/macOS if enabled and not already mounted + // reminder here, windows uses named pipes (//./pipe/docker_engine) not Unix sockets + // I haven't tried this on Windows yet + if dockerSocket && runtime.GOOS != "windows" { + socketAlreadyMounted := false + for _, v := range mountVolumes { + if v.TargetVolumePath == "/var/run/docker.sock" { + socketAlreadyMounted = true + break + } + } + if !socketAlreadyMounted { + mountVolumes = append(mountVolumes, core.Volume{ + SourceVolumePath: "/var/run/docker.sock", + TargetVolumePath: "/var/run/docker.sock", + ReadOnly: false, + }) + } + } + + // If in GitHub, auto-mount workspace and set default workdir + // See here https://docs.github.com/en/actions/creating-actions/creating-a-docker-container-action + containerWorkdir := workdir + if c.IsGitHubWorkflow { + // Mount current working directory to /github/workspace + mountVolumes = append(mountVolumes, core.Volume{ + SourceVolumePath: cwd, + TargetVolumePath: "/github/workspace", + ReadOnly: false, + }) + // Set default workdir to /github/workspace if not specified + if containerWorkdir == "" { + containerWorkdir = "/github/workspace" + } + } + + config := core.DockerRunConfig{ + Image: containerImage, + Name: fmt.Sprintf("actrun_docker_%s", uuid.New().String()[:8]), + Entrypoint: entrypoint, + Cmd: args, + Env: currentEnvMap, + WorkingDir: containerWorkdir, + Volumes: mountVolumes, + Network: network, + AutoRemove: true, + AttachStdio: true, + Labels: map[string]string{"actrun": "docker-node"}, + } + + exitCode, err := dockerClient.RunContainer(c.Ctx, config) + + setErr := n.SetOutputValue(c, ni.Core_docker_run_v1_Output_exit_code, int(exitCode), core.SetOutputValueOpts{}) + if setErr != nil { + return setErr + } + + if err != nil { + execErr := n.Execute(ni.Core_docker_run_v1_Output_exec_err, c, err) + if execErr != nil { + return execErr + } + return nil + } + + if exitCode != 0 { + execErr := n.Execute(ni.Core_docker_run_v1_Output_exec_err, c, core.CreateErr(c, nil, "docker run failed with exit code %d", exitCode)) + if execErr != nil { + return execErr + } + return nil + } + + // Handle GITHUB_ENV and GITHUB_OUTPUT for GitHub workflows + if c.IsGitHubWorkflow { + ghContextParser := GhContextParser{} + ghEnvs, err := ghContextParser.Parse(c, currentEnvMap) + if err != nil { + return err + } + + nextEnvMap := c.GetContextEnvironMapCopy() + maps.Copy(nextEnvMap, ghEnvs) + c.SetContextEnvironMap(nextEnvMap) + + // Parse GITHUB_OUTPUT file if it exists + githubOutput := currentEnvMap["GITHUB_OUTPUT"] + if githubOutput != "" { + b, err := os.ReadFile(githubOutput) + if err != nil { + return core.CreateErr(c, err, "unable to read github output file") + } + + outputs, err := parseOutputFile(string(b)) + if err != nil { + return err + } + for key, value := range outputs { + err = n.SetOutputValue(c, core.OutputId(key), strings.TrimRight(value, "\t\n"), core.SetOutputValueOpts{ + NotExistsIsNoError: true, + }) + if err != nil { + return err + } + } + + _ = os.Remove(githubOutput) + } + } + + err = n.Execute(ni.Core_docker_run_v1_Output_exec_success, c, nil) + if err != nil { + return err + } + + return nil +} + +// looksLikeImageReference checks if a string looks like a Docker image reference +// rather than a Dockerfile path. Used to provide helpful error messages. +func looksLikeImageReference(s string) bool { + // Image references typically have patterns like: + // - "nginx" (short name) + // - "nginx:latest" (with tag) + // - "library/nginx" (with namespace) + // - "ghcr.io/owner/image:tag" (full registry path) + // + // Dockerfile paths typically have patterns like: + // - "Dockerfile" + // - "./Dockerfile" + // - "./build/Dockerfile.prod" + + // If it starts with "./" or "/" (or ".\" on Windows) it's likely a path + if strings.HasPrefix(s, "./") || strings.HasPrefix(s, ".\\") || strings.HasPrefix(s, "/") { + return false + } + + // If it contains "Dockerfile" (case-insensitive), it's likely a path + if strings.Contains(strings.ToLower(s), "dockerfile") { + return false + } + + // If it has a tag separator ":" followed by something that looks like a tag + // (not a Windows drive letter), it's likely an image + if idx := strings.LastIndex(s, ":"); idx > 0 { + tag := s[idx+1:] + // Tags are typically alphanumeric with dots, dashes, underscores + if len(tag) > 0 && !strings.ContainsAny(tag, "/\\") { + return true + } + } + + // If it contains a registry path (e.g., "gcr.io/", "ghcr.io/", "docker.io/") + if strings.Contains(s, ".io/") || strings.Contains(s, ".com/") { + return true + } + + // If it's a simple name without path separators and no file extension + if !strings.ContainsAny(s, "/\\") && !strings.Contains(s, ".") { + return true + } + + return false +} + +// parseImageReference parses the image input and returns: +// - isRegistry: true if it's a registry reference (docker:// prefix) +// - imageRef: the actual image reference or Dockerfile path +// +// Convention (matches GitHub Actions): +// - docker://alpine:latest → Pull from registry +// - docker://ghcr.io/owner/img → Pull from registry +// - Dockerfile → Build from local file +// - ./path/to/Dockerfile → Build from local file +func parseImageReference(image string) (isRegistry bool, imageRef string) { + if after, ok := strings.CutPrefix(image, "docker://"); ok { + return true, after + } + return false, image +} + +// parseVolume parses a volume string in the format "host:container" or "host:container:ro" +func parseVolume(vol string) (core.Volume, error) { + parts := strings.Split(vol, ":") + + // Handle Windows paths like C:\path + // Examples: + // C:\host:/container -> ["C", "\host", "/container"] + // C:\host:/container:ro -> ["C", "\host", "/container", "ro"] + // C:\host:D:\container -> ["C", "\host", "D", "\container"] + // C:\host:D:\container:ro -> ["C", "\host", "D", "\container", "ro"] + if runtime.GOOS == "windows" && len(parts) >= 2 && len(parts[0]) == 1 { + hostPath := parts[0] + ":" + parts[1] + remaining := parts[2:] + + if len(remaining) >= 2 && len(remaining[0]) == 1 { + // container path also has a Windows drive letter + containerPath := remaining[0] + ":" + remaining[1] + if len(remaining) >= 3 { + parts = []string{hostPath, containerPath, remaining[2]} + } else { + parts = []string{hostPath, containerPath} + } + } else if len(remaining) >= 1 { + // container path is Unix-style (e.g., /container) + if len(remaining) >= 2 { + parts = []string{hostPath, remaining[0], remaining[1]} + } else { + parts = []string{hostPath, remaining[0]} + } + } + } + + if len(parts) < 2 { + return core.Volume{}, fmt.Errorf("volume must be in format 'host:container' or 'host:container:ro'") + } + + v := core.Volume{ + SourceVolumePath: parts[0], + TargetVolumePath: parts[1], + ReadOnly: false, + } + + if len(parts) >= 3 && parts[2] == "ro" { + v.ReadOnly = true + } + + return v, nil +} + +func init() { + err := core.RegisterNodeFactory(dockerDefinition, func(ctx any, parent core.NodeBaseInterface, parentId string, nodeDef map[string]any, validate bool, opts core.RunOpts) (core.NodeBaseInterface, []error) { + return &DockerNode{}, nil + }) + if err != nil { + panic(err) + } +} From 86547eff37943c8f2fcb612912fe4360250ecd9f Mon Sep 17 00:00:00 2001 From: Sebatian Rath Date: Sun, 1 Feb 2026 23:05:41 -0500 Subject: [PATCH 6/8] Add E2E test scripts for docker-run node --- nodes/docker-run@v1.go | 19 +- .../references/reference_docker-alpine.sh_l11 | 171 ++++++ .../reference_docker-hello-world.sh_l8 | 40 ++ .../reference_group-port-collision.sh_l13 | 2 +- .../reference_run-python-embedded.sh_l13 | 2 +- tests_e2e/scripts/docker-alpine.act | 540 ++++++++++++++++++ tests_e2e/scripts/docker-alpine.sh | 11 + tests_e2e/scripts/docker-hello-world.act | 24 + tests_e2e/scripts/docker-hello-world.sh | 8 + 9 files changed, 814 insertions(+), 3 deletions(-) create mode 100644 tests_e2e/references/reference_docker-alpine.sh_l11 create mode 100644 tests_e2e/references/reference_docker-hello-world.sh_l8 create mode 100644 tests_e2e/scripts/docker-alpine.act create mode 100644 tests_e2e/scripts/docker-alpine.sh create mode 100644 tests_e2e/scripts/docker-hello-world.act create mode 100644 tests_e2e/scripts/docker-hello-world.sh diff --git a/nodes/docker-run@v1.go b/nodes/docker-run@v1.go index 6cf6809..0a44987 100644 --- a/nodes/docker-run@v1.go +++ b/nodes/docker-run@v1.go @@ -80,6 +80,16 @@ func (n *DockerNode) ExecuteImpl(c *core.ExecutionState, inputId core.InputId, p // I just take this behaviour for the entire system. // TODO: (Seb) Add an option to override this currentEnvMap := c.GetContextEnvironMapCopy() + + // filter out env variables that would break Linux containers when running on Windows: + // 1. Empty keys or keys starting with '=' - Windows per-drive CWD tracking variables + // (eg =C:=, =D:=, =::=) are parsed by strings.Cut as empty-key entries + // 2. PATH - Windows PATH contains Windows paths that break Linux container commands + for key := range currentEnvMap { + if key == "" || strings.HasPrefix(key, "=") || key == "PATH" { + delete(currentEnvMap, key) + } + } for _, env := range envs { if idx := strings.Index(env, "="); idx > 0 { currentEnvMap[env[:idx]] = env[idx+1:] @@ -139,7 +149,14 @@ func (n *DockerNode) ExecuteImpl(c *core.ExecutionState, inputId core.InputId, p return core.CreateErr(c, nil, "Dockerfile not found: %s", dockerfilePath) } - buildTag := fmt.Sprintf("actrun-docker-%s", uuid.New().String()[:8]) + var containerIdSuffix string + if core.IsTestE2eRunning() { + containerIdSuffix = "e2e" + } else { + containerIdSuffix = uuid.New().String()[:8] + } + + buildTag := fmt.Sprintf("actrun-docker-%s", containerIdSuffix) buildContext := filepath.Dir(dockerfilePath) err = dockerClient.BuildImage(c.Ctx, dockerfilePath, buildContext, buildTag) diff --git a/tests_e2e/references/reference_docker-alpine.sh_l11 b/tests_e2e/references/reference_docker-alpine.sh_l11 new file mode 100644 index 0000000..94193d9 --- /dev/null +++ b/tests_e2e/references/reference_docker-alpine.sh_l11 @@ -0,0 +1,171 @@ +build hasn't expired yet +looking for value: 'env_file' + no value (is optional) found for: 'env_file' +looking for value: 'config_file' + no value (is optional) found for: 'config_file' +looking for value: 'concurrency' + no value (is optional) found for: 'concurrency' +looking for value: 'graph_file' + found value in: 'env (shell)' + evaluated to: 'docker-alpine.act' +looking for value: 'session_token' + no value (is optional) found for: 'session_token' +looking for value: 'create_debug_session' + found value in flags + evaluated to: 'false' +PushNodeVisit: start, execute: true +🟢 Execute 'Docker Run (docker-basic)' +PushNodeVisit: docker-basic, execute: true +Test 1: Basic echo with env vars - MY_VAR=hello_world +🟢 Execute 'Docker Run (docker-no-socket)' +PushNodeVisit: docker-no-socket, execute: true +Test 2: No docker socket, pull=never +🟢 Execute 'Docker Run (docker-workdir)' +PushNodeVisit: docker-workdir, execute: true +Test 3: Working dir is [REDACTED]/tmp +🟢 Execute 'Docker Run (docker-multi-env)' +PushNodeVisit: docker-multi-env, execute: true +Test 4: VAR1=first VAR2=second VAR3=third +🟢 Execute 'Docker Run (docker-busybox)' +PushNodeVisit: docker-busybox, execute: true +Test 5: Running in busybox! +🟢 Execute 'Docker Run (docker-multiline)' +PushNodeVisit: docker-multiline, execute: true +Test 6: Multi-line script +Line 1: Hello +Line 2: World +Line 3: Done +🟢 Execute 'Docker Run (docker-volume)' +PushNodeVisit: docker-volume, execute: true +Test 7: Volume mount test +Creating file in mounted volume... +test-content-1234 +🟢 Execute 'Docker Run (docker-network)' +PushNodeVisit: docker-network, execute: true +Test 8: Network host mode +Hostname: docker-desktop +🟢 Execute 'Docker Run (docker-pull-always)' +PushNodeVisit: docker-pull-always, execute: true +##[group]Pulling image 'alpine:latest' + Pulling from library/alpine + Digest: sha256:25109184c71bdad752c8312a8623239686a9a2071e8825f20acb8f2198c3f659 + Status: Image is up to date for alpine:latest +##[endgroup]Test 9: Pull always policy +🟢 Execute 'Docker Run (docker-special-chars)' +PushNodeVisit: docker-special-chars, execute: true +Test 10: Special chars test +Quotes: 'single' and "double" +Dollar: $HOME +Backtick: `date` +🟢 Execute 'Docker Run (docker-dockerfile-relative)' +PushNodeVisit: docker-dockerfile-relative, execute: true +##[group]Building image 'actrun-docker-[REDACTED]' from [REDACTED]/Dockerfile.test +Step 1/3 : FROM alpine:latest + ---> 25109184c71b +Step 2/3 : ENV MY_BUILD_VAR=default_build_value + ---> Using cache + ---> fde5e8900876 +Step 3/3 : WORKDIR [REDACTED]/app + ---> Using cache + ---> f6d5f9a2f2a5 +Successfully built f6d5f9a2f2a5 +Successfully tagged actrun-docker-[REDACTED]:latest +##[endgroup]Test 11: Built from local Dockerfile (relative path) +🟢 Execute 'Docker Run (docker-dockerfile-custom)' +PushNodeVisit: docker-dockerfile-custom, execute: true +##[group]Building image 'actrun-docker-[REDACTED]' from [REDACTED]/Dockerfile.test +Step 1/3 : FROM alpine:latest + ---> 25109184c71b +Step 2/3 : ENV MY_BUILD_VAR=default_build_value + ---> Using cache + ---> fde5e8900876 +Step 3/3 : WORKDIR [REDACTED]/app + ---> Using cache + ---> f6d5f9a2f2a5 +Successfully built f6d5f9a2f2a5 +Successfully tagged actrun-docker-[REDACTED]:latest +##[endgroup]Test 12: Dockerfile with env and workdir +MY_BUILD_VAR=from_dockerfile_build +Working dir: [REDACTED]/opt +🟢 Execute 'Docker Run (docker-volume-readonly)' +PushNodeVisit: docker-volume-readonly, execute: true +Test 13: Read-only volume mount +test-content-1234 +Correctly rejected write on read-only mount +🟢 Execute 'Docker Run (docker-multi-volumes)' +PushNodeVisit: docker-multi-volumes, execute: true +Test 14: Multiple volumes +Vol1 exists: [REDACTED]/vol1 +yes +Vol2 exists: [REDACTED]/vol2 +yes +Vol3 exists: [REDACTED]/vol3 +yes +🟢 Execute 'Docker Run (docker-pipes)' +PushNodeVisit: docker-pipes, execute: true +Test 15: Command with pipes +apple +banana +🟢 Execute 'Docker Run (docker-network-none)' +PushNodeVisit: docker-network-none, execute: true +Test 16: Network none (isolated) +1 +🟢 Execute 'Docker Run (docker-env-special)' +PushNodeVisit: docker-env-special, execute: true +Test 17: Env vars with spaces/special chars +SPACE_VAR=hello world with spaces +SPECIAL_VAR=a=b&c=d!@# +🟢 Execute 'Docker Run (docker-default-cmd)' +PushNodeVisit: docker-default-cmd, execute: true + +Hello from Docker! +This message shows that your installation appears to be working correctly. + +To generate this message, Docker took the following steps: + 1. The Docker client contacted the Docker daemon. + 2. The Docker daemon pulled the "hello-world" image from the Docker Hub. + (amd64) + 3. The Docker daemon created a new container from that image which runs the + executable that produces the output you are currently reading. + 4. The Docker daemon streamed that output to the Docker client, which sent it + to your terminal. + +To try something more ambitious, you can run an Ubuntu container with: + $ docker run -it ubuntu bash + +Share images, automate workflows, and more with a free Docker ID: + https:[REDACTED]/ + +For more examples and ideas, visit: + https:[REDACTED]/ + +🟢 Execute 'Docker Run (docker-exit-nonzero)' +PushNodeVisit: docker-exit-nonzero, execute: true +Test 19: Non-zero exit code +🟢 Execute 'Docker Run (docker-error-handler)' +PushNodeVisit: docker-error-handler, execute: true +Test 19b: Error handler caught the failure, continuing... +🟢 Execute 'Docker Run (docker-long-args)' +PushNodeVisit: docker-long-args, execute: true +Test 20: Long args list +Iteration 1 +Iteration 2 +Iteration 3 +Iteration 4 +Iteration 5 +🟢 Execute 'Docker Run (docker-dockerfile-absolute)' +PushNodeVisit: docker-dockerfile-absolute, execute: true +PushNodeVisit: core-filepath-join-v1-pink-hippopotamus-fig, execute: false +PushNodeVisit: core-filepath-location-v1-date-turquoise-teal, execute: false +##[group]Building image 'actrun-docker-[REDACTED]' from [REDACTED]/Dockerfile.test +Step 1/3 : FROM alpine:latest + ---> 25109184c71b +Step 2/3 : ENV MY_BUILD_VAR=default_build_value + ---> Using cache + ---> fde5e8900876 +Step 3/3 : WORKDIR [REDACTED]/app + ---> Using cache + ---> f6d5f9a2f2a5 +Successfully built f6d5f9a2f2a5 +Successfully tagged actrun-docker-[REDACTED]:latest +##[endgroup]Test 21: Absolute path Dockerfile diff --git a/tests_e2e/references/reference_docker-hello-world.sh_l8 b/tests_e2e/references/reference_docker-hello-world.sh_l8 new file mode 100644 index 0000000..bbdeafb --- /dev/null +++ b/tests_e2e/references/reference_docker-hello-world.sh_l8 @@ -0,0 +1,40 @@ +build hasn't expired yet +looking for value: 'env_file' + no value (is optional) found for: 'env_file' +looking for value: 'config_file' + no value (is optional) found for: 'config_file' +looking for value: 'concurrency' + no value (is optional) found for: 'concurrency' +looking for value: 'graph_file' + found value in: 'env (shell)' + evaluated to: 'docker-hello-world.act' +looking for value: 'session_token' + no value (is optional) found for: 'session_token' +looking for value: 'create_debug_session' + found value in flags + evaluated to: 'false' +PushNodeVisit: start, execute: true +🟢 Execute 'Docker Run (docker-hello-world)' +PushNodeVisit: docker-hello-world, execute: true + +Hello from Docker! +This message shows that your installation appears to be working correctly. + +To generate this message, Docker took the following steps: + 1. The Docker client contacted the Docker daemon. + 2. The Docker daemon pulled the "hello-world" image from the Docker Hub. + (amd64) + 3. The Docker daemon created a new container from that image which runs the + executable that produces the output you are currently reading. + 4. The Docker daemon streamed that output to the Docker client, which sent it + to your terminal. + +To try something more ambitious, you can run an Ubuntu container with: + $ docker run -it ubuntu bash + +Share images, automate workflows, and more with a free Docker ID: + https:[REDACTED]/ + +For more examples and ideas, visit: + https:[REDACTED]/ + diff --git a/tests_e2e/references/reference_group-port-collision.sh_l13 b/tests_e2e/references/reference_group-port-collision.sh_l13 index 4846360..e769563 100644 --- a/tests_e2e/references/reference_group-port-collision.sh_l13 +++ b/tests_e2e/references/reference_group-port-collision.sh_l13 @@ -20,7 +20,7 @@ error: ↳ group node has an input and output with the same name 'foo' stack trace: -github.com/actionforge/actrun-cli/nodes.init.40.func1 +github.com/actionforge/actrun-cli/nodes.init.41.func1 group@v1.go:154 github.com/actionforge/actrun-cli/core.NewNodeInstance base.go:619 diff --git a/tests_e2e/references/reference_run-python-embedded.sh_l13 b/tests_e2e/references/reference_run-python-embedded.sh_l13 index dca01ee..c2d4a20 100644 --- a/tests_e2e/references/reference_run-python-embedded.sh_l13 +++ b/tests_e2e/references/reference_run-python-embedded.sh_l13 @@ -24,7 +24,7 @@ hint: https:[REDACTED]/#not-available stack trace: -github.com/actionforge/actrun-cli/nodes.init.51.func1 +github.com/actionforge/actrun-cli/nodes.init.52.func1 nrun-python-embedded@v1.go:16 github.com/actionforge/actrun-cli/core.NewNodeInstance base.go:619 diff --git a/tests_e2e/scripts/docker-alpine.act b/tests_e2e/scripts/docker-alpine.act new file mode 100644 index 0000000..97c0b54 --- /dev/null +++ b/tests_e2e/scripts/docker-alpine.act @@ -0,0 +1,540 @@ +entry: start +nodes: + - id: start + type: core/start@v1 + position: + x: 10 + y: 3000 + - id: docker-basic + type: core/docker-run@v1 + position: + x: 290 + y: 2850 + inputs: + image: docker://alpine:latest + args: + - '-c' + - echo "Test 1: Basic echo with env vars - MY_VAR=$MY_VAR" + entrypoint: + - /bin/sh + env: + - MY_VAR=hello_world + pull: missing + docker_socket: true + - id: docker-no-socket + type: core/docker-run@v1 + position: + x: 830 + y: 2720 + inputs: + image: docker://alpine:latest + args: + - '-c' + - echo "Test 2: No docker socket, pull=never" + entrypoint: + - /bin/sh + pull: never + docker_socket: false + - id: docker-workdir + type: core/docker-run@v1 + position: + x: 1330 + y: 2600 + inputs: + image: docker://alpine:latest + args: + - '-c' + - echo "Test 3: Working dir is $(pwd)" + entrypoint: + - /bin/sh + workdir: /tmp + pull: missing + docker_socket: true + - id: docker-multi-env + type: core/docker-run@v1 + position: + x: 1820 + y: 2380 + inputs: + image: docker://alpine:latest + args: + - '-c' + - echo "Test 4: VAR1=$VAR1 VAR2=$VAR2 VAR3=$VAR3" + entrypoint: + - /bin/sh + env: + - VAR1=first + - VAR2=second + - VAR3=third + pull: missing + docker_socket: true + - id: docker-busybox + type: core/docker-run@v1 + position: + x: 2360 + y: 2260 + inputs: + image: docker://busybox:latest + args: + - '-c' + - echo "Test 5: Running in busybox!" + entrypoint: + - /bin/sh + pull: missing + docker_socket: true + - id: docker-multiline + type: core/docker-run@v1 + position: + x: 2860 + y: 2130 + inputs: + image: docker://alpine:latest + args: + - '-c' + - | + echo "Test 6: Multi-line script" + echo "Line 1: Hello" + echo "Line 2: World" + echo "Line 3: Done" + entrypoint: + - /bin/sh + pull: missing + docker_socket: true + - id: docker-volume + type: core/docker-run@v1 + position: + x: 3350 + y: 1980 + inputs: + image: docker://alpine:latest + args: + - '-c' + - | + echo "Test 7: Volume mount test" + echo "Creating file in mounted volume..." + echo "test-content-1234" > /mnt/host-tmp/docker-test-file.txt + cat /mnt/host-tmp/docker-test-file.txt + entrypoint: + - /bin/sh + volumes: + - /tmp:/mnt/host-tmp + pull: missing + docker_socket: true + - id: docker-network + type: core/docker-run@v1 + position: + x: 3850 + y: 1850 + inputs: + image: docker://alpine:latest + args: + - '-c' + - | + echo "Test 8: Network host mode" + echo "Hostname: $(hostname)" + entrypoint: + - /bin/sh + network: host + pull: missing + docker_socket: true + - id: docker-pull-always + type: core/docker-run@v1 + position: + x: 4340 + y: 1730 + inputs: + image: docker://alpine:latest + args: + - '-c' + - echo "Test 9: Pull always policy" + entrypoint: + - /bin/sh + pull: always + docker_socket: true + - id: docker-special-chars + type: core/docker-run@v1 + position: + x: 4840 + y: 1600 + inputs: + image: docker://alpine:latest + args: + - '-c' + - | + echo "Test 10: Special chars test" + echo "Quotes: 'single' and \"double\"" + echo "Dollar: \$HOME" + echo "Backtick: \`date\`" + entrypoint: + - /bin/sh + pull: missing + docker_socket: true + - id: docker-dockerfile-relative + type: core/docker-run@v1 + position: + x: 5330 + y: 1480 + inputs: + image: Dockerfile.test + args: + - '-c' + - echo "Test 11: Built from local Dockerfile (relative path)" + entrypoint: + - /bin/sh + docker_socket: true + - id: docker-dockerfile-custom + type: core/docker-run@v1 + position: + x: 5830 + y: 1320 + inputs: + image: ./Dockerfile.test + args: + - '-c' + - | + echo "Test 12: Dockerfile with env and workdir" + echo "MY_BUILD_VAR=$MY_BUILD_VAR" + echo "Working dir: $(pwd)" + entrypoint: + - /bin/sh + env: + - MY_BUILD_VAR=from_dockerfile_build + workdir: /opt + docker_socket: true + - id: docker-volume-readonly + type: core/docker-run@v1 + position: + x: 6370 + y: 1170 + inputs: + image: docker://alpine:latest + args: + - '-c' + - > + echo "Test 13: Read-only volume mount" + + cat /mnt/readonly/docker-test-file.txt 2>/dev/null || echo "File + exists check passed" + + # Try to write (should fail) + + if touch /mnt/readonly/test-write.txt 2>/dev/null; then + echo "ERROR: Write succeeded on read-only mount!" + else + echo "Correctly rejected write on read-only mount" + fi + entrypoint: + - /bin/sh + volumes: + - /tmp:/mnt/readonly:ro + pull: missing + docker_socket: true + - id: docker-multi-volumes + type: core/docker-run@v1 + position: + x: 6870 + y: 950 + inputs: + image: docker://alpine:latest + args: + - '-c' + - | + echo "Test 14: Multiple volumes" + echo "Vol1 exists: $(ls -d /vol1 2>/dev/null && echo yes || echo no)" + echo "Vol2 exists: $(ls -d /vol2 2>/dev/null && echo yes || echo no)" + echo "Vol3 exists: $(ls -d /vol3 2>/dev/null && echo yes || echo no)" + entrypoint: + - /bin/sh + volumes: + - /tmp:/vol1 + - /var:/vol2:ro + - /etc:/vol3:ro + pull: missing + docker_socket: true + - id: docker-pipes + type: core/docker-run@v1 + position: + x: 7360 + y: 820 + inputs: + image: docker://alpine:latest + args: + - '-c' + - | + echo "Test 15: Command with pipes" + echo -e "banana\napple\ncherry" | sort | head -2 + entrypoint: + - /bin/sh + pull: missing + docker_socket: true + - id: docker-network-none + type: core/docker-run@v1 + position: + x: 7860 + y: 700 + inputs: + image: docker://alpine:latest + args: + - '-c' + - > + echo "Test 16: Network none (isolated)" + + # Check if network interfaces are minimal + + ip link show 2>/dev/null | grep -c "^[0-9]" || echo "No ip command, + but that's ok" + entrypoint: + - /bin/sh + network: none + pull: missing + docker_socket: true + - id: docker-env-special + type: core/docker-run@v1 + position: + x: 8350 + y: 510 + inputs: + image: docker://alpine:latest + args: + - '-c' + - | + echo "Test 17: Env vars with spaces/special chars" + echo "SPACE_VAR=$SPACE_VAR" + echo "SPECIAL_VAR=$SPECIAL_VAR" + entrypoint: + - /bin/sh + env: + - SPACE_VAR=hello world with spaces + - SPECIAL_VAR=a=b&c=d!@# + pull: missing + docker_socket: true + - id: docker-default-cmd + type: core/docker-run@v1 + position: + x: 8890 + y: 480 + inputs: + image: docker://hello-world:latest + pull: missing + docker_socket: true + - id: docker-exit-nonzero + type: core/docker-run@v1 + position: + x: 9310 + y: 350 + inputs: + image: docker://alpine:latest + args: + - '-c' + - | + echo "Test 19: Non-zero exit code" + exit 42 + entrypoint: + - /bin/sh + pull: missing + docker_socket: true + - id: docker-error-handler + type: core/docker-run@v1 + position: + x: 9800 + y: 260 + inputs: + image: docker://alpine:latest + args: + - '-c' + - echo "Test 19b: Error handler caught the failure, continuing..." + entrypoint: + - /bin/sh + pull: missing + docker_socket: true + - id: docker-long-args + type: core/docker-run@v1 + position: + x: 10300 + y: 140 + inputs: + image: docker://alpine:latest + args: + - '-c' + - | + echo "Test 20: Long args list" + for i in 1 2 3 4 5; do + echo "Iteration $i" + done + entrypoint: + - /bin/sh + pull: missing + docker_socket: true + - id: docker-dockerfile-absolute + type: core/docker-run@v1 + position: + x: 10790 + y: 10 + inputs: + args: + - '-c' + - echo "Test 21: Absolute path Dockerfile" + entrypoint: + - /bin/sh + docker_socket: true + - id: core-filepath-location-v1-date-turquoise-teal + type: core/filepath-location@v1 + position: + x: 10160 + y: -50 + inputs: + location: working_dir + - id: core-filepath-join-v1-pink-hippopotamus-fig + type: core/filepath-join@v1 + position: + x: 10370 + y: 30 + inputs: + segments[0]: null + segments[1]: Dockerfile.test +connections: + - src: + node: core-filepath-location-v1-date-turquoise-teal + port: result + dst: + node: core-filepath-join-v1-pink-hippopotamus-fig + port: segments[0] + - src: + node: core-filepath-join-v1-pink-hippopotamus-fig + port: result + dst: + node: docker-dockerfile-absolute + port: image +executions: + - src: + node: start + port: exec + dst: + node: docker-basic + port: exec + - src: + node: docker-basic + port: exec-success + dst: + node: docker-no-socket + port: exec + - src: + node: docker-no-socket + port: exec-success + dst: + node: docker-workdir + port: exec + - src: + node: docker-workdir + port: exec-success + dst: + node: docker-multi-env + port: exec + - src: + node: docker-multi-env + port: exec-success + dst: + node: docker-busybox + port: exec + - src: + node: docker-busybox + port: exec-success + dst: + node: docker-multiline + port: exec + - src: + node: docker-multiline + port: exec-success + dst: + node: docker-volume + port: exec + - src: + node: docker-volume + port: exec-success + dst: + node: docker-network + port: exec + - src: + node: docker-network + port: exec-success + dst: + node: docker-pull-always + port: exec + - src: + node: docker-pull-always + port: exec-success + dst: + node: docker-special-chars + port: exec + - src: + node: docker-special-chars + port: exec-success + dst: + node: docker-dockerfile-relative + port: exec + - src: + node: docker-dockerfile-relative + port: exec-success + dst: + node: docker-dockerfile-custom + port: exec + - src: + node: docker-dockerfile-custom + port: exec-success + dst: + node: docker-volume-readonly + port: exec + - src: + node: docker-volume-readonly + port: exec-success + dst: + node: docker-multi-volumes + port: exec + - src: + node: docker-multi-volumes + port: exec-success + dst: + node: docker-pipes + port: exec + - src: + node: docker-pipes + port: exec-success + dst: + node: docker-network-none + port: exec + - src: + node: docker-network-none + port: exec-success + dst: + node: docker-env-special + port: exec + - src: + node: docker-env-special + port: exec-success + dst: + node: docker-default-cmd + port: exec + - src: + node: docker-default-cmd + port: exec-success + dst: + node: docker-exit-nonzero + port: exec + - src: + node: docker-exit-nonzero + port: exec-err + dst: + node: docker-error-handler + port: exec + - src: + node: docker-error-handler + port: exec-success + dst: + node: docker-long-args + port: exec + - src: + node: docker-long-args + port: exec-success + dst: + node: docker-dockerfile-absolute + port: exec diff --git a/tests_e2e/scripts/docker-alpine.sh b/tests_e2e/scripts/docker-alpine.sh new file mode 100644 index 0000000..b9f4820 --- /dev/null +++ b/tests_e2e/scripts/docker-alpine.sh @@ -0,0 +1,11 @@ +echo "Test Docker-Run Alpine Node" + +TEST_NAME=docker-alpine +GRAPH_FILE="${ACT_GRAPH_FILES_DIR}${PATH_SEPARATOR}${TEST_NAME}.act" +DOCKERFILE="${ACT_GRAPH_FILES_DIR}${PATH_SEPARATOR}Dockerfile.test" +cp $GRAPH_FILE $TEST_NAME.act +cp $DOCKERFILE Dockerfile.test + +export ACT_GRAPH_FILE=$TEST_NAME.act + +#! test actrun 2>&1 | sed 's/actrun-docker-[0-9a-z]*/actrun-docker-[REDACTED]/g' diff --git a/tests_e2e/scripts/docker-hello-world.act b/tests_e2e/scripts/docker-hello-world.act new file mode 100644 index 0000000..81dd542 --- /dev/null +++ b/tests_e2e/scripts/docker-hello-world.act @@ -0,0 +1,24 @@ +entry: start +nodes: + - id: start + type: core/start@v1 + position: + x: 0 + y: 0 + - id: docker-hello-world + type: core/docker-run@v1 + position: + x: 300 + y: 0 + inputs: + image: docker://hello-world:latest + pull: missing + docker_socket: true +connections: [] +executions: + - src: + node: start + port: exec + dst: + node: docker-hello-world + port: exec diff --git a/tests_e2e/scripts/docker-hello-world.sh b/tests_e2e/scripts/docker-hello-world.sh new file mode 100644 index 0000000..e5613f6 --- /dev/null +++ b/tests_e2e/scripts/docker-hello-world.sh @@ -0,0 +1,8 @@ +echo "Test Docker Hello-World Node" + +TEST_NAME=docker-hello-world +GRAPH_FILE="${ACT_GRAPH_FILES_DIR}${PATH_SEPARATOR}${TEST_NAME}.act" +cp $GRAPH_FILE $TEST_NAME.act +export ACT_GRAPH_FILE=$TEST_NAME.act + +#! test actrun From 6e63c1c92b9b3fab90ed3150bacb0437f818b80d Mon Sep 17 00:00:00 2001 From: Sebastian Rath Date: Mon, 2 Feb 2026 00:34:56 -0500 Subject: [PATCH 7/8] Fix env behaviour and dont let host envs leak into container unless allowlisted --- nodes/docker-run@v1.go | 110 ++++++++++++++++++++++++++++++++--------- 1 file changed, 87 insertions(+), 23 deletions(-) diff --git a/nodes/docker-run@v1.go b/nodes/docker-run@v1.go index 0a44987..b7774da 100644 --- a/nodes/docker-run@v1.go +++ b/nodes/docker-run@v1.go @@ -74,29 +74,18 @@ func (n *DockerNode) ExecuteImpl(c *core.ExecutionState, inputId core.InputId, p return err } - // build env map from context and input env vars. - // In contrary to Docker, env vars in GitHub are automatically included, see code. - // See: github.com/actions/runner/blob/main/src/Runner.Worker/Handlers/ContainerActionHandler.cs - // I just take this behaviour for the entire system. - // TODO: (Seb) Add an option to override this - currentEnvMap := c.GetContextEnvironMapCopy() - - // filter out env variables that would break Linux containers when running on Windows: - // 1. Empty keys or keys starting with '=' - Windows per-drive CWD tracking variables - // (eg =C:=, =D:=, =::=) are parsed by strings.Cut as empty-key entries - // 2. PATH - Windows PATH contains Windows paths that break Linux container commands - for key := range currentEnvMap { - if key == "" || strings.HasPrefix(key, "=") || key == "PATH" { - delete(currentEnvMap, key) - } - } - for _, env := range envs { - if idx := strings.Index(env, "="); idx > 0 { - currentEnvMap[env[:idx]] = env[idx+1:] - } - // KEY without `=` is a Docker feature, but here a no-op since - // we pass the full env to Docker anyway - } + // Build container environment following GitHub Actions approach: + // Don't inherit host OS environment wholesale. Instead, build explicitly from: + // 1. Allowlisted GITHUB_* variables (when in GitHub workflow mode) + // 2. Hardcoded CI=true, GITHUB_ACTIONS=true + // 3. User-defined env vars from node inputs + // 4. Proxy variables (HTTP_PROXY, HTTPS_PROXY, NO_PROXY) + // + // References: + // - GitHubContext.cs allowlist: https://github.com/actions/runner/blob/main/src/Runner.Worker/GitHubContext.cs#L106-L146 + // - DockerCommandManager.cs CI/GITHUB_ACTIONS: https://github.com/actions/runner/blob/main/src/Runner.Worker/Container/DockerCommandManager.cs#L329-L336 + // - ContainerActionHandler.cs HOME: https://github.com/actions/runner/blob/main/src/Runner.Worker/Handlers/ContainerActionHandler.cs#L176 + currentEnvMap := buildDockerEnvironment(c, envs) // parse image reference. docker:// prefix means registry, otherwise Dockerfile isRegistry, imageRef := parseImageReference(image) @@ -410,6 +399,81 @@ func parseVolume(vol string) (core.Volume, error) { return v, nil } +// githubContextAllowlist defines the GitHub context keys that are exposed as GITHUB_* env vars. +// This matches the allowlist in the GitHub Actions runner: +// https://github.com/actions/runner/blob/main/src/Runner.Worker/GitHubContext.cs#L106-L146 +var githubContextAllowlist = map[string]bool{ + "action": true, "action_path": true, "action_ref": true, "action_repository": true, + "actor": true, "actor_id": true, "api_url": true, "base_ref": true, + "env": true, "event_name": true, "event_path": true, "graphql_url": true, + "head_ref": true, "job": true, "output": true, "path": true, + "ref": true, "ref_name": true, "ref_protected": true, "ref_type": true, + "repository": true, "repository_id": true, "repository_owner": true, "repository_owner_id": true, + "retention_days": true, "run_attempt": true, "run_id": true, "run_number": true, + "server_url": true, "sha": true, "state": true, "step_summary": true, + "triggering_actor": true, "workflow": true, "workflow_ref": true, "workflow_sha": true, + "workspace": true, +} + +// buildDockerEnvironment builds the container environment based on the GitHub Actions impl +// so I just borrow tha, *partially* even for non-gh graphs. See also here: +// - GitHubContext.cs: https://github.com/actions/runner/blob/main/src/Runner.Worker/GitHubContext.cs#L106-L146 +// - DockerCommandManager.cs: https://github.com/actions/runner/blob/main/src/Runner.Worker/Container/DockerCommandManager.cs#L329-L336 +// - ContainerActionHandler.cs: https://github.com/actions/runner/blob/main/src/Runner.Worker/Handlers/ContainerActionHandler.cs#L176 +func buildDockerEnvironment(c *core.ExecutionState, userEnvs []string) map[string]string { + env := make(map[string]string) + contextEnv := c.GetContextEnvironMapCopy() + + // Add allowlisted GITHUB_* vars from context + // https://github.com/actions/runner/blob/main/src/Runner.Worker/GitHubContext.cs#L148-L160 + if c.IsGitHubWorkflow { + for key := range githubContextAllowlist { + envKey := "GITHUB_" + strings.ToUpper(key) + if val, ok := contextEnv[envKey]; ok { + env[envKey] = val + } + } + + // also add RUNNER_* vars if present + for key, val := range contextEnv { + if strings.HasPrefix(key, "RUNNER_") { + env[key] = val + } + } + } + + // add hardcoded variables (always set for Docker containers) + // https://github.com/actions/runner/blob/main/src/Runner.Worker/Container/DockerCommandManager.cs#L329-L336 + env["GITHUB_ACTIONS"] = "true" + if _, exists := env["CI"]; !exists { + env["CI"] = "true" + } + + // set HOME to container path (GitHub Actions sets this to /github/home) + // https://github.com/actions/runner/blob/main/src/Runner.Worker/Handlers/ContainerActionHandler.cs#L176 + if c.IsGitHubWorkflow { + env["HOME"] = "/github/home" + } + + // 4. add proxy variables if set in host environment + // https://github.com/actions/runner/blob/main/src/Runner.Worker/Container/ContainerInfo.cs#L105-L130 + proxyVars := []string{"HTTP_PROXY", "http_proxy", "HTTPS_PROXY", "https_proxy", "NO_PROXY", "no_proxy"} + for _, proxyVar := range proxyVars { + if val, ok := contextEnv[proxyVar]; ok && val != "" { + env[proxyVar] = val + } + } + + // 5. add user-defined env vars from node inputs. Highest prio, can override above + for _, e := range userEnvs { + if idx := strings.Index(e, "="); idx > 0 { + env[e[:idx]] = e[idx+1:] + } + } + + return env +} + func init() { err := core.RegisterNodeFactory(dockerDefinition, func(ctx any, parent core.NodeBaseInterface, parentId string, nodeDef map[string]any, validate bool, opts core.RunOpts) (core.NodeBaseInterface, []error) { return &DockerNode{}, nil From 79b5fd9c9d1c13eb5a7f1321014f682d71ecf38a Mon Sep 17 00:00:00 2001 From: Sebastian Rath Date: Mon, 2 Feb 2026 00:55:00 -0500 Subject: [PATCH 8/8] Remove some output from e2e tests as they are too noisy for reference files --- .gitignore | 1 - core/docker.go | 34 ++++++--- ...1 => reference_docker-alpine_linux.sh_l11} | 73 ++----------------- .../reference_docker-hello-world.sh_l8 | 40 ---------- .../reference_docker-hello-world_linux.sh_l8 | 19 +++++ tests_e2e/scripts/Dockerfile.e2e | 3 + tests_e2e/scripts/docker-alpine.act | 22 +++--- ...ocker-alpine.sh => docker-alpine_linux.sh} | 4 +- tests_e2e/scripts/docker-hello-world.act | 5 +- ...o-world.sh => docker-hello-world_linux.sh} | 0 tests_e2e/tests_e2e.py | 67 +++++++++++++++-- 11 files changed, 127 insertions(+), 141 deletions(-) rename tests_e2e/references/{reference_docker-alpine.sh_l11 => reference_docker-alpine_linux.sh_l11} (62%) delete mode 100644 tests_e2e/references/reference_docker-hello-world.sh_l8 create mode 100644 tests_e2e/references/reference_docker-hello-world_linux.sh_l8 create mode 100644 tests_e2e/scripts/Dockerfile.e2e rename tests_e2e/scripts/{docker-alpine.sh => docker-alpine_linux.sh} (73%) rename tests_e2e/scripts/{docker-hello-world.sh => docker-hello-world_linux.sh} (100%) diff --git a/.gitignore b/.gitignore index 5e1fcac..22afd59 100644 --- a/.gitignore +++ b/.gitignore @@ -29,7 +29,6 @@ __pycache__/ # Temp files .DS_Store -*.test tests_e2e/coverage # Output of the go coverage tool, specifically when used with LiteIDE diff --git a/core/docker.go b/core/docker.go index a975b8b..4584116 100644 --- a/core/docker.go +++ b/core/docker.go @@ -97,8 +97,12 @@ func (d *DockerClient) ImageExists(ctx context.Context, imageRef string) (bool, } func (d *DockerClient) PullImage(ctx context.Context, imageRef string) error { - utils.LogOut.Infof("%sPulling image '%s'\n", utils.LogGhStartGroup, imageRef) - defer utils.LogOut.Infof(utils.LogGhEndGroup) + verbose := !IsTestE2eRunning() + + if verbose { + utils.LogOut.Infof("%sPulling image '%s'\n", utils.LogGhStartGroup, imageRef) + defer utils.LogOut.Infof(utils.LogGhEndGroup) + } reader, err := d.cli.ImagePull(ctx, imageRef, image.PullOptions{}) if err != nil { @@ -116,11 +120,13 @@ func (d *DockerClient) PullImage(ctx context.Context, imageRef string) error { return err } - if status, ok := event["status"].(string); ok { - if progress, ok := event["progress"].(string); ok { - utils.LogOut.Infof(" %s %s\n", status, progress) - } else { - utils.LogOut.Infof(" %s\n", status) + if verbose { + if status, ok := event["status"].(string); ok { + if progress, ok := event["progress"].(string); ok { + utils.LogOut.Infof(" %s %s\n", status, progress) + } else { + utils.LogOut.Infof(" %s\n", status) + } } } } @@ -129,8 +135,12 @@ func (d *DockerClient) PullImage(ctx context.Context, imageRef string) error { } func (d *DockerClient) BuildImage(ctx context.Context, dockerfilePath, contextPath, tag string) error { - utils.LogOut.Infof("%sBuilding image '%s' from %s\n", utils.LogGhStartGroup, tag, dockerfilePath) - defer utils.LogOut.Infof(utils.LogGhEndGroup) + verbose := !IsTestE2eRunning() + + if verbose { + utils.LogOut.Infof("%sBuilding image '%s' from %s\n", utils.LogGhStartGroup, tag, dockerfilePath) + defer utils.LogOut.Infof(utils.LogGhEndGroup) + } // Create a tar archive of the build context buildContext, err := createBuildContext(contextPath) @@ -165,8 +175,10 @@ func (d *DockerClient) BuildImage(ctx context.Context, dockerfilePath, contextPa return err } - if stream, ok := event["stream"].(string); ok { - utils.LogOut.Infof("%s", stream) + if verbose { + if stream, ok := event["stream"].(string); ok { + utils.LogOut.Infof("%s", stream) + } } if errMsg, ok := event["error"].(string); ok { return fmt.Errorf("build error: %s", errMsg) diff --git a/tests_e2e/references/reference_docker-alpine.sh_l11 b/tests_e2e/references/reference_docker-alpine_linux.sh_l11 similarity index 62% rename from tests_e2e/references/reference_docker-alpine.sh_l11 rename to tests_e2e/references/reference_docker-alpine_linux.sh_l11 index 94193d9..296ed26 100644 --- a/tests_e2e/references/reference_docker-alpine.sh_l11 +++ b/tests_e2e/references/reference_docker-alpine_linux.sh_l11 @@ -42,15 +42,10 @@ Creating file in mounted volume... test-content-1234 🟢 Execute 'Docker Run (docker-network)' PushNodeVisit: docker-network, execute: true -Test 8: Network host mode -Hostname: docker-desktop +Linux 🟢 Execute 'Docker Run (docker-pull-always)' PushNodeVisit: docker-pull-always, execute: true -##[group]Pulling image 'alpine:latest' - Pulling from library/alpine - Digest: sha256:25109184c71bdad752c8312a8623239686a9a2071e8825f20acb8f2198c3f659 - Status: Image is up to date for alpine:latest -##[endgroup]Test 9: Pull always policy +Test 9: Pull always policy 🟢 Execute 'Docker Run (docker-special-chars)' PushNodeVisit: docker-special-chars, execute: true Test 10: Special chars test @@ -59,32 +54,10 @@ Dollar: $HOME Backtick: `date` 🟢 Execute 'Docker Run (docker-dockerfile-relative)' PushNodeVisit: docker-dockerfile-relative, execute: true -##[group]Building image 'actrun-docker-[REDACTED]' from [REDACTED]/Dockerfile.test -Step 1/3 : FROM alpine:latest - ---> 25109184c71b -Step 2/3 : ENV MY_BUILD_VAR=default_build_value - ---> Using cache - ---> fde5e8900876 -Step 3/3 : WORKDIR [REDACTED]/app - ---> Using cache - ---> f6d5f9a2f2a5 -Successfully built f6d5f9a2f2a5 -Successfully tagged actrun-docker-[REDACTED]:latest -##[endgroup]Test 11: Built from local Dockerfile (relative path) +Test 11: Built from local Dockerfile (relative path) 🟢 Execute 'Docker Run (docker-dockerfile-custom)' PushNodeVisit: docker-dockerfile-custom, execute: true -##[group]Building image 'actrun-docker-[REDACTED]' from [REDACTED]/Dockerfile.test -Step 1/3 : FROM alpine:latest - ---> 25109184c71b -Step 2/3 : ENV MY_BUILD_VAR=default_build_value - ---> Using cache - ---> fde5e8900876 -Step 3/3 : WORKDIR [REDACTED]/app - ---> Using cache - ---> f6d5f9a2f2a5 -Successfully built f6d5f9a2f2a5 -Successfully tagged actrun-docker-[REDACTED]:latest -##[endgroup]Test 12: Dockerfile with env and workdir +Test 12: Dockerfile with env and workdir MY_BUILD_VAR=from_dockerfile_build Working dir: [REDACTED]/opt 🟢 Execute 'Docker Run (docker-volume-readonly)' @@ -109,7 +82,7 @@ banana 🟢 Execute 'Docker Run (docker-network-none)' PushNodeVisit: docker-network-none, execute: true Test 16: Network none (isolated) -1 +Linux 🟢 Execute 'Docker Run (docker-env-special)' PushNodeVisit: docker-env-special, execute: true Test 17: Env vars with spaces/special chars @@ -117,28 +90,7 @@ SPACE_VAR=hello world with spaces SPECIAL_VAR=a=b&c=d!@# 🟢 Execute 'Docker Run (docker-default-cmd)' PushNodeVisit: docker-default-cmd, execute: true - -Hello from Docker! -This message shows that your installation appears to be working correctly. - -To generate this message, Docker took the following steps: - 1. The Docker client contacted the Docker daemon. - 2. The Docker daemon pulled the "hello-world" image from the Docker Hub. - (amd64) - 3. The Docker daemon created a new container from that image which runs the - executable that produces the output you are currently reading. - 4. The Docker daemon streamed that output to the Docker client, which sent it - to your terminal. - -To try something more ambitious, you can run an Ubuntu container with: - $ docker run -it ubuntu bash - -Share images, automate workflows, and more with a free Docker ID: - https:[REDACTED]/ - -For more examples and ideas, visit: - https:[REDACTED]/ - +Hello World 🟢 Execute 'Docker Run (docker-exit-nonzero)' PushNodeVisit: docker-exit-nonzero, execute: true Test 19: Non-zero exit code @@ -157,15 +109,4 @@ Iteration 5 PushNodeVisit: docker-dockerfile-absolute, execute: true PushNodeVisit: core-filepath-join-v1-pink-hippopotamus-fig, execute: false PushNodeVisit: core-filepath-location-v1-date-turquoise-teal, execute: false -##[group]Building image 'actrun-docker-[REDACTED]' from [REDACTED]/Dockerfile.test -Step 1/3 : FROM alpine:latest - ---> 25109184c71b -Step 2/3 : ENV MY_BUILD_VAR=default_build_value - ---> Using cache - ---> fde5e8900876 -Step 3/3 : WORKDIR [REDACTED]/app - ---> Using cache - ---> f6d5f9a2f2a5 -Successfully built f6d5f9a2f2a5 -Successfully tagged actrun-docker-[REDACTED]:latest -##[endgroup]Test 21: Absolute path Dockerfile +Test 21: Absolute path Dockerfile diff --git a/tests_e2e/references/reference_docker-hello-world.sh_l8 b/tests_e2e/references/reference_docker-hello-world.sh_l8 deleted file mode 100644 index bbdeafb..0000000 --- a/tests_e2e/references/reference_docker-hello-world.sh_l8 +++ /dev/null @@ -1,40 +0,0 @@ -build hasn't expired yet -looking for value: 'env_file' - no value (is optional) found for: 'env_file' -looking for value: 'config_file' - no value (is optional) found for: 'config_file' -looking for value: 'concurrency' - no value (is optional) found for: 'concurrency' -looking for value: 'graph_file' - found value in: 'env (shell)' - evaluated to: 'docker-hello-world.act' -looking for value: 'session_token' - no value (is optional) found for: 'session_token' -looking for value: 'create_debug_session' - found value in flags - evaluated to: 'false' -PushNodeVisit: start, execute: true -🟢 Execute 'Docker Run (docker-hello-world)' -PushNodeVisit: docker-hello-world, execute: true - -Hello from Docker! -This message shows that your installation appears to be working correctly. - -To generate this message, Docker took the following steps: - 1. The Docker client contacted the Docker daemon. - 2. The Docker daemon pulled the "hello-world" image from the Docker Hub. - (amd64) - 3. The Docker daemon created a new container from that image which runs the - executable that produces the output you are currently reading. - 4. The Docker daemon streamed that output to the Docker client, which sent it - to your terminal. - -To try something more ambitious, you can run an Ubuntu container with: - $ docker run -it ubuntu bash - -Share images, automate workflows, and more with a free Docker ID: - https:[REDACTED]/ - -For more examples and ideas, visit: - https:[REDACTED]/ - diff --git a/tests_e2e/references/reference_docker-hello-world_linux.sh_l8 b/tests_e2e/references/reference_docker-hello-world_linux.sh_l8 new file mode 100644 index 0000000..bb7d03f --- /dev/null +++ b/tests_e2e/references/reference_docker-hello-world_linux.sh_l8 @@ -0,0 +1,19 @@ +build hasn't expired yet +looking for value: 'env_file' + no value (is optional) found for: 'env_file' +looking for value: 'config_file' + no value (is optional) found for: 'config_file' +looking for value: 'concurrency' + no value (is optional) found for: 'concurrency' +looking for value: 'graph_file' + found value in: 'env (shell)' + evaluated to: 'docker-hello-world.act' +looking for value: 'session_token' + no value (is optional) found for: 'session_token' +looking for value: 'create_debug_session' + found value in flags + evaluated to: 'false' +PushNodeVisit: start, execute: true +🟢 Execute 'Docker Run (docker-hello-world)' +PushNodeVisit: docker-hello-world, execute: true +Hello World diff --git a/tests_e2e/scripts/Dockerfile.e2e b/tests_e2e/scripts/Dockerfile.e2e new file mode 100644 index 0000000..e21f2d1 --- /dev/null +++ b/tests_e2e/scripts/Dockerfile.e2e @@ -0,0 +1,3 @@ +FROM alpine:latest +RUN echo "Built from local Dockerfile!" +CMD ["echo", "Default CMD from Dockerfile"] diff --git a/tests_e2e/scripts/docker-alpine.act b/tests_e2e/scripts/docker-alpine.act index 97c0b54..15aaa95 100644 --- a/tests_e2e/scripts/docker-alpine.act +++ b/tests_e2e/scripts/docker-alpine.act @@ -130,8 +130,7 @@ nodes: args: - '-c' - | - echo "Test 8: Network host mode" - echo "Hostname: $(hostname)" + uname -s entrypoint: - /bin/sh network: host @@ -175,7 +174,7 @@ nodes: x: 5330 y: 1480 inputs: - image: Dockerfile.test + image: Dockerfile.e2e args: - '-c' - echo "Test 11: Built from local Dockerfile (relative path)" @@ -188,7 +187,7 @@ nodes: x: 5830 y: 1320 inputs: - image: ./Dockerfile.test + image: ./Dockerfile.e2e args: - '-c' - | @@ -276,13 +275,9 @@ nodes: image: docker://alpine:latest args: - '-c' - - > + - | echo "Test 16: Network none (isolated)" - - # Check if network interfaces are minimal - - ip link show 2>/dev/null | grep -c "^[0-9]" || echo "No ip command, - but that's ok" + uname -s entrypoint: - /bin/sh network: none @@ -314,7 +309,10 @@ nodes: x: 8890 y: 480 inputs: - image: docker://hello-world:latest + image: docker://alpine:latest + args: + - echo + - Hello World pull: missing docker_socket: true - id: docker-exit-nonzero @@ -391,7 +389,7 @@ nodes: y: 30 inputs: segments[0]: null - segments[1]: Dockerfile.test + segments[1]: Dockerfile.e2e connections: - src: node: core-filepath-location-v1-date-turquoise-teal diff --git a/tests_e2e/scripts/docker-alpine.sh b/tests_e2e/scripts/docker-alpine_linux.sh similarity index 73% rename from tests_e2e/scripts/docker-alpine.sh rename to tests_e2e/scripts/docker-alpine_linux.sh index b9f4820..325722d 100644 --- a/tests_e2e/scripts/docker-alpine.sh +++ b/tests_e2e/scripts/docker-alpine_linux.sh @@ -2,9 +2,9 @@ echo "Test Docker-Run Alpine Node" TEST_NAME=docker-alpine GRAPH_FILE="${ACT_GRAPH_FILES_DIR}${PATH_SEPARATOR}${TEST_NAME}.act" -DOCKERFILE="${ACT_GRAPH_FILES_DIR}${PATH_SEPARATOR}Dockerfile.test" +DOCKERFILE="${ACT_GRAPH_FILES_DIR}${PATH_SEPARATOR}Dockerfile.e2e" cp $GRAPH_FILE $TEST_NAME.act -cp $DOCKERFILE Dockerfile.test +cp $DOCKERFILE Dockerfile.e2e export ACT_GRAPH_FILE=$TEST_NAME.act diff --git a/tests_e2e/scripts/docker-hello-world.act b/tests_e2e/scripts/docker-hello-world.act index 81dd542..fda83bc 100644 --- a/tests_e2e/scripts/docker-hello-world.act +++ b/tests_e2e/scripts/docker-hello-world.act @@ -11,7 +11,10 @@ nodes: x: 300 y: 0 inputs: - image: docker://hello-world:latest + image: docker://alpine:latest + args: + - echo + - Hello World pull: missing docker_socket: true connections: [] diff --git a/tests_e2e/scripts/docker-hello-world.sh b/tests_e2e/scripts/docker-hello-world_linux.sh similarity index 100% rename from tests_e2e/scripts/docker-hello-world.sh rename to tests_e2e/scripts/docker-hello-world_linux.sh diff --git a/tests_e2e/tests_e2e.py b/tests_e2e/tests_e2e.py index 5b7e554..2e84616 100644 --- a/tests_e2e/tests_e2e.py +++ b/tests_e2e/tests_e2e.py @@ -63,6 +63,27 @@ def print_env_vars_redacted(env_vars: dict): IS_WINDOWS = sys.platform == "win32" +PLATFORM_MAP = { + "linux": "linux", + "darwin": "darwin", + "win32": "windows", +} +CURRENT_PLATFORM = PLATFORM_MAP.get(sys.platform, sys.platform) +ALL_PLATFORMS = ["linux", "darwin", "windows"] + +def get_script_platform(script_path: str) -> str | None: + name = Path(script_path).stem # removes .sh + for plat in ALL_PLATFORMS: + if name.endswith(f"_{plat}"): + return plat + return None + +def should_run_on_current_platform(script_path: str) -> bool: + script_platform = get_script_platform(script_path) + if script_platform is None: + return True # No suffix means run everywhere + return script_platform == CURRENT_PLATFORM + # --- Helper Classes --- class Style: @@ -116,7 +137,15 @@ def to_posix_path(path_str: str) -> str: return "/" + cleaned.lstrip("/") def collect_shell_scripts(directory: str) -> list[str]: - return [str(p) for p in Path(directory).rglob("*.sh")] + all_scripts = [str(p) for p in Path(directory).rglob("*.sh")] + filtered = [] + for script in all_scripts: + if should_run_on_current_platform(script): + filtered.append(script) + else: + script_platform = get_script_platform(script) + print(f"Skipping {os.path.basename(script)} (platform: {script_platform}, current: {CURRENT_PLATFORM})") + return filtered def create_temp_script() -> str: fd, path = tempfile.mkstemp(suffix=".sh") @@ -313,10 +342,23 @@ def main(): os.makedirs(cov_dir, exist_ok=True) - # delete all refs if running full suite + # delete refs if running full suite, but preserve refs from other platforms if target_test is None: - shutil.rmtree(ref_dir, ignore_errors=True) - os.makedirs(ref_dir, exist_ok=True) + if os.path.exists(ref_dir): + for ref_file in os.listdir(ref_dir): + # Check if this reference file belongs to another platform + # Reference files are named: reference_{script_name}_l{lineno} + # For platform-specific: reference_docker-alpine_linux.sh_l11 + is_other_platform = False + for plat in ALL_PLATFORMS: + if plat != CURRENT_PLATFORM and f"_{plat}.sh_" in ref_file: + is_other_platform = True + break + + if not is_other_platform: + os.remove(os.path.join(ref_dir, ref_file)) + else: + os.makedirs(ref_dir, exist_ok=True) compile_binaries(is_gh_actions) @@ -330,11 +372,20 @@ def main(): full_path = os.path.join(scripts_dir, target_test) process_and_run_test(base_cwd, full_path, ref_dir, cov_dir) - # check if there are any diffs between generated refs and committed/staged refs + # check if there are any diffs between generated refs and committed/staged refs. + # excludes reference files from other platforms (e.g., _linux files when running on darwin) try: - git_cmd = ['git', '-c', 'core.autocrlf=input', '-c', 'core.safecrlf=false', - '--no-pager', 'diff', ref_dir] - + git_cmd = ['git', '-c', 'core.autocrlf=input', '-c', 'core.safecrlf=false', + '--no-pager', 'diff', ref_dir, '--'] + + for plat in ALL_PLATFORMS: + if plat != CURRENT_PLATFORM: + # exclude reference files from scripts with platform suffix + # reference files are named like reference_{script_name}_l{lineno} + # For platform-specific scripts reference_docker-alpine_linux.sh_l11 + git_cmd.append(f':!*_{plat}.sh_*') + + print(f"Running git diff (excluding other platforms): {' '.join(git_cmd)}") res = subprocess.run(git_cmd, text=True, encoding='utf-8', capture_output=True, check=False) print(res.stdout)