feat: service container in host mode (#95)

This commit is contained in:
ChristopherHX
2025-04-26 16:28:53 +02:00
committed by GitHub
parent a3c8116dee
commit 8536279ece
3 changed files with 129 additions and 82 deletions

View File

@@ -26,6 +26,7 @@ import (
"github.com/actions-oss/act-cli/pkg/model"
"github.com/docker/go-connections/nat"
"github.com/opencontainers/selinux/go-selinux"
"github.com/sirupsen/logrus"
)
// RunContext contains info about current job
@@ -232,7 +233,13 @@ func (rc *RunContext) startHostEnvironment() common.Executor {
},
StdOut: logWriter,
}
rc.cleanUpJobContainer = rc.JobContainer.Remove()
networkName, createAndDeleteNetwork, err := rc.prepareServiceContainers(ctx, logger, container.LinuxContainerEnvironmentExtensions{}, logWriter)
if err != nil {
return err
}
rc.cleanUpJobContainer = common.Executor(func(ctx context.Context) error {
return rc.cleanupServiceContainer(ctx, logger, createAndDeleteNetwork, networkName)
}).Finally(rc.JobContainer.Remove())
for k, v := range rc.JobContainer.GetRunnerContext(ctx) {
if v, ok := v.(string); ok {
rc.Env[fmt.Sprintf("RUNNER_%s", strings.ToUpper(k))] = v
@@ -257,6 +264,12 @@ func (rc *RunContext) startHostEnvironment() common.Executor {
Mode: 0o666,
Body: "",
}),
rc.pullServicesImages(rc.Config.ForcePull),
func(ctx context.Context) error {
return rc.cleanupServiceContainer(ctx, logger, createAndDeleteNetwork, networkName)
},
container.NewDockerNetworkCreateExecutor(networkName).IfBool(createAndDeleteNetwork),
rc.startServiceContainers(networkName),
)(ctx)
}
}
@@ -294,70 +307,9 @@ func (rc *RunContext) startJobContainer() common.Executor {
ext := container.LinuxContainerEnvironmentExtensions{}
binds, mounts := rc.GetBindsAndMounts()
// specify the network to which the container will connect when `docker create` stage. (like execute command line: docker create --network <networkName> <image>)
// if using service containers, will create a new network for the containers.
// and it will be removed after at last.
networkName, createAndDeleteNetwork := rc.networkName()
// add service containers
for serviceID, spec := range rc.Run.Job().Services {
// interpolate env
interpolatedEnvs := make(map[string]string, len(spec.Env))
for k, v := range spec.Env {
interpolatedEnvs[k] = rc.ExprEval.Interpolate(ctx, v)
}
envs := make([]string, 0, len(interpolatedEnvs))
for k, v := range interpolatedEnvs {
envs = append(envs, fmt.Sprintf("%s=%s", k, v))
}
username, password, err = rc.handleServiceCredentials(ctx, spec.Credentials)
if err != nil {
return fmt.Errorf("failed to handle service %s credentials: %w", serviceID, err)
}
interpolatedVolumes := make([]string, 0, len(spec.Volumes))
for _, volume := range spec.Volumes {
interpolatedVolumes = append(interpolatedVolumes, rc.ExprEval.Interpolate(ctx, volume))
}
serviceBinds, serviceMounts := rc.GetServiceBindsAndMounts(interpolatedVolumes)
interpolatedPorts := make([]string, 0, len(spec.Ports))
for _, port := range spec.Ports {
interpolatedPorts = append(interpolatedPorts, rc.ExprEval.Interpolate(ctx, port))
}
exposedPorts, portBindings, err := nat.ParsePortSpecs(interpolatedPorts)
if err != nil {
return fmt.Errorf("failed to parse service %s ports: %w", serviceID, err)
}
imageName := rc.ExprEval.Interpolate(ctx, spec.Image)
if imageName == "" {
logger.Infof("The service '%s' will not be started because the container definition has an empty image.", serviceID)
continue
}
serviceContainerName := createContainerName(rc.jobContainerName(), serviceID)
c := container.NewContainer(&container.NewContainerInput{
Name: serviceContainerName,
WorkingDir: ext.ToContainerPath(rc.Config.Workdir),
Image: imageName,
Username: username,
Password: password,
Env: envs,
Mounts: serviceMounts,
Binds: serviceBinds,
Stdout: logWriter,
Stderr: logWriter,
Privileged: rc.Config.Privileged,
UsernsMode: rc.Config.UsernsMode,
Platform: rc.Config.ContainerArchitecture,
Options: rc.ExprEval.Interpolate(ctx, spec.Options),
NetworkMode: networkName,
NetworkAliases: []string{serviceID},
ExposedPorts: exposedPorts,
PortBindings: portBindings,
})
rc.ServiceContainers = append(rc.ServiceContainers, c)
networkName, createAndDeleteNetwork, err := rc.prepareServiceContainers(ctx, logger, ext, logWriter)
if err != nil {
return err
}
rc.cleanUpJobContainer = func(ctx context.Context) error {
@@ -370,23 +322,7 @@ func (rc *RunContext) startJobContainer() common.Executor {
Then(container.NewDockerVolumeRemoveExecutor(rc.jobContainerName(), false)).IfNot(reuseJobContainer).
Then(container.NewDockerVolumeRemoveExecutor(rc.jobContainerName()+"-env", false)).IfNot(reuseJobContainer).
Then(func(ctx context.Context) error {
if len(rc.ServiceContainers) > 0 {
logger.Infof("Cleaning up services for job %s", rc.JobName)
if err := rc.stopServiceContainers()(ctx); err != nil {
logger.Errorf("error while cleaning services: %v", err)
}
if createAndDeleteNetwork {
// clean network if it has been created by act
// if using service containers
// it means that the network to which containers are connecting is created by `act_runner`,
// so, we should remove the network at last.
logger.Infof("Cleaning up network for job %s, and network name is: %s", rc.JobName, networkName)
if err := container.NewDockerNetworkRemoveExecutor(networkName)(ctx); err != nil {
logger.Errorf("error while cleaning network: %v", err)
}
}
}
return nil
return rc.cleanupServiceContainer(ctx, logger, createAndDeleteNetwork, networkName)
})(ctx)
}
return nil
@@ -445,6 +381,91 @@ func (rc *RunContext) startJobContainer() common.Executor {
}
}
func (rc *RunContext) cleanupServiceContainer(ctx context.Context, logger logrus.FieldLogger, createAndDeleteNetwork bool, networkName string) error {
if len(rc.ServiceContainers) > 0 {
logger.Infof("Cleaning up services for job %s", rc.JobName)
if err := rc.stopServiceContainers()(ctx); err != nil {
logger.Errorf("error while cleaning services: %v", err)
}
if createAndDeleteNetwork {
logger.Infof("Cleaning up network for job %s, and network name is: %s", rc.JobName, networkName)
if err := container.NewDockerNetworkRemoveExecutor(networkName)(ctx); err != nil {
logger.Errorf("error while cleaning network: %v", err)
}
}
}
return nil
}
func (rc *RunContext) prepareServiceContainers(ctx context.Context, logger logrus.FieldLogger, ext container.LinuxContainerEnvironmentExtensions, logWriter io.Writer) (string, bool, error) {
// specify the network to which the container will connect when `docker create` stage. (like execute command line: docker create --network <networkName> <image>)
// if using service containers, will create a new network for the containers.
// and it will be removed after at last
networkName, createAndDeleteNetwork := rc.networkName()
// add service containers
for serviceID, spec := range rc.Run.Job().Services {
// interpolate env
interpolatedEnvs := make(map[string]string, len(spec.Env))
for k, v := range spec.Env {
interpolatedEnvs[k] = rc.ExprEval.Interpolate(ctx, v)
}
envs := make([]string, 0, len(interpolatedEnvs))
for k, v := range interpolatedEnvs {
envs = append(envs, fmt.Sprintf("%s=%s", k, v))
}
username, password, err := rc.handleServiceCredentials(ctx, spec.Credentials)
if err != nil {
return "", false, fmt.Errorf("failed to handle service %s credentials: %w", serviceID, err)
}
interpolatedVolumes := make([]string, 0, len(spec.Volumes))
for _, volume := range spec.Volumes {
interpolatedVolumes = append(interpolatedVolumes, rc.ExprEval.Interpolate(ctx, volume))
}
serviceBinds, serviceMounts := rc.GetServiceBindsAndMounts(interpolatedVolumes)
interpolatedPorts := make([]string, 0, len(spec.Ports))
for _, port := range spec.Ports {
interpolatedPorts = append(interpolatedPorts, rc.ExprEval.Interpolate(ctx, port))
}
exposedPorts, portBindings, err := nat.ParsePortSpecs(interpolatedPorts)
if err != nil {
return "", false, fmt.Errorf("failed to parse service %s ports: %w", serviceID, err)
}
imageName := rc.ExprEval.Interpolate(ctx, spec.Image)
if imageName == "" {
logger.Infof("The service '%s' will not be started because the container definition has an empty image.", serviceID)
continue
}
serviceContainerName := createContainerName(rc.jobContainerName(), serviceID)
c := container.NewContainer(&container.NewContainerInput{
Name: serviceContainerName,
WorkingDir: ext.ToContainerPath(rc.Config.Workdir),
Image: imageName,
Username: username,
Password: password,
Env: envs,
Mounts: serviceMounts,
Binds: serviceBinds,
Stdout: logWriter,
Stderr: logWriter,
Privileged: rc.Config.Privileged,
UsernsMode: rc.Config.UsernsMode,
Platform: rc.Config.ContainerArchitecture,
Options: rc.ExprEval.Interpolate(ctx, spec.Options),
NetworkMode: networkName,
NetworkAliases: []string{serviceID},
ExposedPorts: exposedPorts,
PortBindings: portBindings,
})
rc.ServiceContainers = append(rc.ServiceContainers, c)
}
return networkName, createAndDeleteNetwork, nil
}
func (rc *RunContext) execJobContainer(cmd []string, env map[string]string, user, workdir string) common.Executor {
return func(ctx context.Context) error {
return rc.JobContainer.Exec(cmd, env, user, workdir)(ctx)

View File

@@ -329,6 +329,8 @@ func TestRunEvent(t *testing.T) {
// docker action on host executor
{workdir, "docker-action-host-env", "push", "", platforms, secrets},
// docker service on host executor
{workdir, "nginx-service-container-host-mode", "push", "", platforms, secrets},
}
for _, table := range tables {

View File

@@ -0,0 +1,24 @@
name: Self-Hosted Runner with NGINX Service
on: [push]
jobs:
nginx_service_job:
runs-on: self-hosted
services:
nginx:
image: nginx:latest
ports:
- 8084:80
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Wait for NGINX to be ready
run: |
for i in {1..10}; do
curl -I http://localhost:8084 && break || sleep 3
done
- name: Verify NGINX is running
run: curl -I http://localhost:8084