feat(env): support env vars for services (both run command and compose)

This commit is contained in:
Pavel Sviderski
2025-04-03 18:14:17 +10:00
parent 4e40a93991
commit 25b173128a
9 changed files with 100 additions and 7 deletions
+33
View File
@@ -3,6 +3,7 @@ package service
import ( import (
"context" "context"
"fmt" "fmt"
"os"
"slices" "slices"
"strings" "strings"
@@ -17,6 +18,7 @@ type runOptions struct {
command []string command []string
entrypoint string entrypoint string
entrypointChanged bool entrypointChanged bool
env []string
image string image string
machines []string machines []string
mode string mode string
@@ -51,6 +53,9 @@ func NewRunCommand() *cobra.Command {
cmd.Flags().StringVar(&opts.entrypoint, "entrypoint", "", cmd.Flags().StringVar(&opts.entrypoint, "entrypoint", "",
"Overwrite the default ENTRYPOINT of the image. Pass an empty string \"\" to reset it.") "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, cmd.Flags().StringVar(&opts.mode, "mode", api.ServiceModeReplicated,
fmt.Sprintf("Replication mode of the service: either '%s' (a specified number of containers across "+ fmt.Sprintf("Replication mode of the service: either '%s' (a specified number of containers across "+
"the machines) or '%s' (one container on every machine).", "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 { func run(ctx context.Context, uncli *cli.CLI, opts runOptions) error {
env, err := parseEnv(opts.env)
if err != nil {
return err
}
switch opts.mode { switch opts.mode {
case api.ServiceModeReplicated, api.ServiceModeGlobal: case api.ServiceModeReplicated, api.ServiceModeGlobal:
default: default:
@@ -136,6 +146,7 @@ func run(ctx context.Context, uncli *cli.CLI, opts runOptions) error {
spec := api.ServiceSpec{ spec := api.ServiceSpec{
Container: api.ContainerSpec{ Container: api.ContainerSpec{
Command: opts.command, Command: opts.command,
Env: env,
Image: opts.image, Image: opts.image,
PullPolicy: opts.pull, PullPolicy: opts.pull,
Volumes: opts.volumes, Volumes: opts.volumes,
@@ -184,3 +195,25 @@ func run(ctx context.Context, uncli *cli.CLI, opts runOptions) error {
return nil 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
}
+1
View File
@@ -357,6 +357,7 @@ func (s *Server) CreateServiceContainer(
config := &container.Config{ config := &container.Config{
Cmd: spec.Container.Command, Cmd: spec.Container.Command,
Env: spec.Container.Env.ToSlice(),
Entrypoint: spec.Container.Entrypoint, Entrypoint: spec.Container.Entrypoint,
Hostname: containerName, Hostname: containerName,
Image: spec.Container.Image, Image: spec.Container.Image,
+17 -1
View File
@@ -107,7 +107,9 @@ type ContainerSpec struct {
Command []string Command []string
// Entrypoint overrides the default ENTRYPOINT of the image. // Entrypoint overrides the default ENTRYPOINT of the image.
Entrypoint []string 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. // Run a custom init inside the container. If nil, use the daemon's configured settings.
Init *bool Init *bool
// PullPolicy determines when to pull the image from the registry or use the image already available in the cluster. // 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 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 { type Service struct {
ID string ID string
Name string Name string
+1 -1
View File
@@ -4,6 +4,7 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"github.com/compose-spec/compose-go/v2/graph" "github.com/compose-spec/compose-go/v2/graph"
"github.com/compose-spec/compose-go/v2/types" "github.com/compose-spec/compose-go/v2/types"
"github.com/psviderski/uncloud/pkg/api" "github.com/psviderski/uncloud/pkg/api"
@@ -31,7 +32,6 @@ func NewDeployment(ctx context.Context, cli Client, project *types.Project) (*De
resolver := &deploy.ServiceSpecResolver{ resolver := &deploy.ServiceSpecResolver{
// If the domain is not found (not reserved), an empty domain is used for the resolver. // If the domain is not found (not reserved), an empty domain is used for the resolver.
ClusterDomain: domain, ClusterDomain: domain,
// TODO: provide an image resolver.
} }
return &Deployment{ return &Deployment{
+12 -1
View File
@@ -2,6 +2,7 @@ package compose
import ( import (
"fmt" "fmt"
"github.com/compose-spec/compose-go/v2/types" "github.com/compose-spec/compose-go/v2/types"
"github.com/psviderski/uncloud/pkg/api" "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) 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{ spec := api.ServiceSpec{
Container: api.ContainerSpec{ Container: api.ContainerSpec{
Command: service.Command, Command: service.Command,
Env: env,
Image: service.Image, Image: service.Image,
Init: service.Init, Init: service.Init,
PullPolicy: pullPolicy, PullPolicy: pullPolicy,
// TODO: env
// TODO: volumes // TODO: volumes
}, },
Name: name, Name: name,
+5
View File
@@ -47,6 +47,11 @@ func assertContainerMatchesSpec(t *testing.T, ctr api.ServiceContainer, spec api
assert.EqualValues(t, spec.Container.Entrypoint, ctr.Config.Entrypoint) 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.Image, ctr.Config.Image)
assert.Equal(t, spec.Container.Init, ctr.HostConfig.Init) assert.Equal(t, spec.Container.Init, ctr.HostConfig.Init)
assert.ElementsMatch(t, spec.Container.Volumes, ctr.HostConfig.Binds) assert.ElementsMatch(t, spec.Container.Volumes, ctr.HostConfig.Binds)
+7 -1
View File
@@ -3,12 +3,13 @@ package e2e
import ( import (
"context" "context"
"errors" "errors"
"testing"
"github.com/psviderski/uncloud/internal/ucind" "github.com/psviderski/uncloud/internal/ucind"
"github.com/psviderski/uncloud/pkg/api" "github.com/psviderski/uncloud/pkg/api"
"github.com/psviderski/uncloud/pkg/client/compose" "github.com/psviderski/uncloud/pkg/client/compose"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"testing"
) )
func TestComposeDeployment(t *testing.T) { func TestComposeDeployment(t *testing.T) {
@@ -52,6 +53,11 @@ func TestComposeDeployment(t *testing.T) {
Name: name, Name: name,
Mode: api.ServiceModeReplicated, Mode: api.ServiceModeReplicated,
Container: api.ContainerSpec{ Container: api.ContainerSpec{
Env: map[string]string{
"VAR": "value",
"BOOL": "true",
"EMPTY": "",
},
// TODO: resolve image digest and substitute the image with the image@digest. // TODO: resolve image digest and substitute the image with the image@digest.
Image: "portainer/pause:3.9", Image: "portainer/pause:3.9",
}, },
+4
View File
@@ -1,5 +1,9 @@
services: services:
basic: basic:
environment:
VAR: "value"
BOOL: "true"
EMPTY: ""
image: portainer/pause:3.9 image: portainer/pause:3.9
x-ports: x-ports:
- basic.example.com:80/https - basic.example.com:80/https
+20 -3
View File
@@ -659,6 +659,8 @@ func TestServiceLifecycle(t *testing.T) {
// Verify default settings. // Verify default settings.
assert.Empty(t, ctr.Config.Cmd) assert.Empty(t, ctr.Config.Cmd)
assert.EqualValues(t, []string{"/pause"}, ctr.Config.Entrypoint) // Populated by the image. 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.Nil(t, ctr.HostConfig.Init)
assert.Empty(t, ctr.HostConfig.Binds) assert.Empty(t, ctr.HostConfig.Binds)
assert.Empty(t, ctr.HostConfig.PortBindings) assert.Empty(t, ctr.HostConfig.PortBindings)
@@ -691,9 +693,15 @@ func TestServiceLifecycle(t *testing.T) {
Command: []string{"sleep", "infinity"}, Command: []string{"sleep", "infinity"},
// Extra slashes is not a typo, it changes the spec but Linux ignores them and uses the default /pause. // Extra slashes is not a typo, it changes the spec but Linux ignores them and uses the default /pause.
Entrypoint: []string{"///pause"}, Entrypoint: []string{"///pause"},
Image: "portainer/pause:latest", Env: map[string]string{
Init: &init, "VAR": "value",
Volumes: []string{"/host/path:/container/path:ro"}, "EMTPY": "",
"BOOL": "true",
"": "ignored",
},
Image: "portainer/pause:latest",
Init: &init,
Volumes: []string{"/host/path:/container/path:ro"},
}, },
Ports: []api.PortSpec{ 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.Command, ctr.Config.Cmd)
assert.EqualValues(t, spec.Container.Entrypoint, ctr.Config.Entrypoint) 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.True(t, *ctr.HostConfig.Init)
assert.Len(t, ctr.HostConfig.Binds, 1) assert.Len(t, ctr.HostConfig.Binds, 1)
assert.Contains(t, ctr.HostConfig.Binds, spec.Container.Volumes[0]) assert.Contains(t, ctr.HostConfig.Binds, spec.Container.Volumes[0])