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 (
"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
}
+1
View File
@@ -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,
+17 -1
View File
@@ -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
+1 -1
View File
@@ -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{
+12 -1
View File
@@ -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,
+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)
}
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)
+7 -1
View File
@@ -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",
},
+4
View File
@@ -1,5 +1,9 @@
services:
basic:
environment:
VAR: "value"
BOOL: "true"
EMPTY: ""
image: portainer/pause:3.9
x-ports:
- basic.example.com:80/https
+20 -3
View File
@@ -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])