From 25b173128ac9944d51b01f87d15a65ddc9e4e662 Mon Sep 17 00:00:00 2001 From: Pavel Sviderski Date: Thu, 3 Apr 2025 18:14:17 +1000 Subject: [PATCH] feat(env): support env vars for services (both run command and compose) --- cmd/uncloud/service/run.go | 33 ++++++++++++++++++++++++++++ internal/machine/docker/server.go | 1 + pkg/api/service.go | 18 ++++++++++++++- pkg/client/compose/deploy.go | 2 +- pkg/client/compose/service.go | 13 ++++++++++- test/e2e/assert.go | 5 +++++ test/e2e/compose_test.go | 8 ++++++- test/e2e/fixtures/basic-compose.yaml | 4 ++++ test/e2e/service_test.go | 23 ++++++++++++++++--- 9 files changed, 100 insertions(+), 7 deletions(-) diff --git a/cmd/uncloud/service/run.go b/cmd/uncloud/service/run.go index b22febf5..c72b1cf4 100644 --- a/cmd/uncloud/service/run.go +++ b/cmd/uncloud/service/run.go @@ -3,6 +3,7 @@ package service import ( "context" "fmt" + "os" "slices" "strings" @@ -17,6 +18,7 @@ type runOptions struct { command []string entrypoint string entrypointChanged bool + env []string image string machines []string mode string @@ -51,6 +53,9 @@ func NewRunCommand() *cobra.Command { cmd.Flags().StringVar(&opts.entrypoint, "entrypoint", "", "Overwrite the default ENTRYPOINT of the image. Pass an empty string \"\" to reset it.") + cmd.Flags().StringSliceVarP(&opts.env, "env", "e", nil, + "Set an environment variable for service containers. Can be specified multiple times.\n"+ + "Format: VAR=value or just VAR to use the value from the local environment.") cmd.Flags().StringVar(&opts.mode, "mode", api.ServiceModeReplicated, fmt.Sprintf("Replication mode of the service: either '%s' (a specified number of containers across "+ "the machines) or '%s' (one container on every machine).", @@ -88,6 +93,11 @@ func NewRunCommand() *cobra.Command { } func run(ctx context.Context, uncli *cli.CLI, opts runOptions) error { + env, err := parseEnv(opts.env) + if err != nil { + return err + } + switch opts.mode { case api.ServiceModeReplicated, api.ServiceModeGlobal: default: @@ -136,6 +146,7 @@ func run(ctx context.Context, uncli *cli.CLI, opts runOptions) error { spec := api.ServiceSpec{ Container: api.ContainerSpec{ Command: opts.command, + Env: env, Image: opts.image, PullPolicy: opts.pull, Volumes: opts.volumes, @@ -184,3 +195,25 @@ func run(ctx context.Context, uncli *cli.CLI, opts runOptions) error { return nil } + +// parseEnv parses the environment variables from the command line arguments. +// It supports two formats: "VAR=value" or just "VAR" to use the value from the local environment. +func parseEnv(env []string) (api.EnvVars, error) { + envVars := make(api.EnvVars) + for _, e := range env { + key, value, hasValue := strings.Cut(e, "=") + if key == "" { + return nil, fmt.Errorf("invalid environment variable: '%s'", e) + } + + if hasValue { + envVars[key] = value + } else { + if localEnvValue, ok := os.LookupEnv(key); ok { + envVars[key] = localEnvValue + } + } + } + + return envVars, nil +} diff --git a/internal/machine/docker/server.go b/internal/machine/docker/server.go index aea3f751..20b77a37 100644 --- a/internal/machine/docker/server.go +++ b/internal/machine/docker/server.go @@ -357,6 +357,7 @@ func (s *Server) CreateServiceContainer( config := &container.Config{ Cmd: spec.Container.Command, + Env: spec.Container.Env.ToSlice(), Entrypoint: spec.Container.Entrypoint, Hostname: containerName, Image: spec.Container.Image, diff --git a/pkg/api/service.go b/pkg/api/service.go index 5eb26497..ab61c7ee 100644 --- a/pkg/api/service.go +++ b/pkg/api/service.go @@ -107,7 +107,9 @@ type ContainerSpec struct { Command []string // Entrypoint overrides the default ENTRYPOINT of the image. Entrypoint []string - Image string + // Env defines the environment variables to set inside the container. + Env EnvVars + Image string // Run a custom init inside the container. If nil, use the daemon's configured settings. Init *bool // PullPolicy determines when to pull the image from the registry or use the image already available in the cluster. @@ -164,6 +166,20 @@ func (s *ContainerSpec) Clone() ContainerSpec { return spec } +type EnvVars map[string]string + +// ToSlice converts the environment variables to a slice of strings in the format "key=value". +func (e EnvVars) ToSlice() []string { + env := make([]string, 0, len(e)) + for k, v := range e { + if k == "" { + continue + } + env = append(env, fmt.Sprintf("%s=%s", k, v)) + } + return env +} + type Service struct { ID string Name string diff --git a/pkg/client/compose/deploy.go b/pkg/client/compose/deploy.go index b4c95276..e446c476 100644 --- a/pkg/client/compose/deploy.go +++ b/pkg/client/compose/deploy.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "github.com/compose-spec/compose-go/v2/graph" "github.com/compose-spec/compose-go/v2/types" "github.com/psviderski/uncloud/pkg/api" @@ -31,7 +32,6 @@ func NewDeployment(ctx context.Context, cli Client, project *types.Project) (*De resolver := &deploy.ServiceSpecResolver{ // If the domain is not found (not reserved), an empty domain is used for the resolver. ClusterDomain: domain, - // TODO: provide an image resolver. } return &Deployment{ diff --git a/pkg/client/compose/service.go b/pkg/client/compose/service.go index 077f06df..312a92c0 100644 --- a/pkg/client/compose/service.go +++ b/pkg/client/compose/service.go @@ -2,6 +2,7 @@ package compose import ( "fmt" + "github.com/compose-spec/compose-go/v2/types" "github.com/psviderski/uncloud/pkg/api" ) @@ -19,13 +20,23 @@ func ServiceSpecFromCompose(name string, service types.ServiceConfig) (api.Servi return api.ServiceSpec{}, fmt.Errorf("unsupported pull policy: '%s'", service.PullPolicy) } + env := make(map[string]string, len(service.Environment)) + for k, v := range service.Environment { + if v == nil { + // nil value means the variable misses a value in the compose file, and it hasn't been resolved + // to a variable from the local environment running this code. + continue + } + env[k] = *v + } + spec := api.ServiceSpec{ Container: api.ContainerSpec{ Command: service.Command, + Env: env, Image: service.Image, Init: service.Init, PullPolicy: pullPolicy, - // TODO: env // TODO: volumes }, Name: name, diff --git a/test/e2e/assert.go b/test/e2e/assert.go index a0127e07..6447751f 100644 --- a/test/e2e/assert.go +++ b/test/e2e/assert.go @@ -47,6 +47,11 @@ func assertContainerMatchesSpec(t *testing.T, ctr api.ServiceContainer, spec api assert.EqualValues(t, spec.Container.Entrypoint, ctr.Config.Entrypoint) } + expectedEnvs := spec.Container.Env.ToSlice() + for _, env := range expectedEnvs { + assert.Contains(t, ctr.Config.Env, env) + } + assert.Equal(t, spec.Container.Image, ctr.Config.Image) assert.Equal(t, spec.Container.Init, ctr.HostConfig.Init) assert.ElementsMatch(t, spec.Container.Volumes, ctr.HostConfig.Binds) diff --git a/test/e2e/compose_test.go b/test/e2e/compose_test.go index 6139b680..a402d53c 100644 --- a/test/e2e/compose_test.go +++ b/test/e2e/compose_test.go @@ -3,12 +3,13 @@ package e2e import ( "context" "errors" + "testing" + "github.com/psviderski/uncloud/internal/ucind" "github.com/psviderski/uncloud/pkg/api" "github.com/psviderski/uncloud/pkg/client/compose" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "testing" ) func TestComposeDeployment(t *testing.T) { @@ -52,6 +53,11 @@ func TestComposeDeployment(t *testing.T) { Name: name, Mode: api.ServiceModeReplicated, Container: api.ContainerSpec{ + Env: map[string]string{ + "VAR": "value", + "BOOL": "true", + "EMPTY": "", + }, // TODO: resolve image digest and substitute the image with the image@digest. Image: "portainer/pause:3.9", }, diff --git a/test/e2e/fixtures/basic-compose.yaml b/test/e2e/fixtures/basic-compose.yaml index 7dde8649..78f646e8 100644 --- a/test/e2e/fixtures/basic-compose.yaml +++ b/test/e2e/fixtures/basic-compose.yaml @@ -1,5 +1,9 @@ services: basic: + environment: + VAR: "value" + BOOL: "true" + EMPTY: "" image: portainer/pause:3.9 x-ports: - basic.example.com:80/https diff --git a/test/e2e/service_test.go b/test/e2e/service_test.go index 6b912c97..e80c04f2 100644 --- a/test/e2e/service_test.go +++ b/test/e2e/service_test.go @@ -659,6 +659,8 @@ func TestServiceLifecycle(t *testing.T) { // Verify default settings. assert.Empty(t, ctr.Config.Cmd) assert.EqualValues(t, []string{"/pause"}, ctr.Config.Entrypoint) // Populated by the image. + assert.Equal(t, []string{"PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"}, ctr.Config.Env) + assert.Nil(t, ctr.HostConfig.Init) assert.Empty(t, ctr.HostConfig.Binds) assert.Empty(t, ctr.HostConfig.PortBindings) @@ -691,9 +693,15 @@ func TestServiceLifecycle(t *testing.T) { Command: []string{"sleep", "infinity"}, // Extra slashes is not a typo, it changes the spec but Linux ignores them and uses the default /pause. Entrypoint: []string{"///pause"}, - Image: "portainer/pause:latest", - Init: &init, - Volumes: []string{"/host/path:/container/path:ro"}, + Env: map[string]string{ + "VAR": "value", + "EMTPY": "", + "BOOL": "true", + "": "ignored", + }, + Image: "portainer/pause:latest", + Init: &init, + Volumes: []string{"/host/path:/container/path:ro"}, }, Ports: []api.PortSpec{ { @@ -733,6 +741,15 @@ func TestServiceLifecycle(t *testing.T) { assert.EqualValues(t, spec.Container.Command, ctr.Config.Cmd) assert.EqualValues(t, spec.Container.Entrypoint, ctr.Config.Entrypoint) + + expectedEnv := []string{ + "BOOL=true", + "EMTPY=", + "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", + "VAR=value", + } + assert.ElementsMatch(t, expectedEnv, ctr.Config.Env) + assert.True(t, *ctr.HostConfig.Init) assert.Len(t, ctr.HostConfig.Binds, 1) assert.Contains(t, ctr.HostConfig.Binds, spec.Container.Volumes[0])