mirror of
https://github.com/psviderski/uncloud.git
synced 2026-08-26 11:03: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 (
|
||||
"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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
services:
|
||||
basic:
|
||||
environment:
|
||||
VAR: "value"
|
||||
BOOL: "true"
|
||||
EMPTY: ""
|
||||
image: portainer/pause:3.9
|
||||
x-ports:
|
||||
- basic.example.com:80/https
|
||||
|
||||
@@ -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])
|
||||
|
||||
Reference in New Issue
Block a user