mirror of
https://github.com/psviderski/uncloud.git
synced 2026-08-26 19:13:34 +00:00
feat(env): support env vars for services (both run command and compose)
This commit is contained in:
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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{
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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])
|
||||||
|
|||||||
Reference in New Issue
Block a user