Skip to content

Commit 8b1df7c

Browse files
[WIRE-1057] Ensure to execute referenced container as arduino user (#465)
Co-authored-by: Alessio Perugini <alessio@perugini.xyz>
1 parent f587bf7 commit 8b1df7c

File tree

4 files changed

+313
-33
lines changed

4 files changed

+313
-33
lines changed

internal/orchestrator/app/app.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,3 +117,7 @@ func (a *ArduinoApp) ProvisioningStateDir() *paths.Path {
117117
func (a *ArduinoApp) AppComposeFilePath() *paths.Path {
118118
return a.ProvisioningStateDir().Join("app-compose.yaml")
119119
}
120+
121+
func (a *ArduinoApp) AppComposeOverrideFilePath() *paths.Path {
122+
return a.ProvisioningStateDir().Join("app-compose-overrides.yaml")
123+
}

internal/orchestrator/orchestrator.go

Lines changed: 21 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -140,24 +140,13 @@ func StartApp(
140140
}
141141
}
142142
if app.MainPythonFile != nil {
143-
if !yield(StreamMessage{data: "Provisioning app..."}) {
144-
cancel()
145-
return
146-
}
147-
if err := ProvisionApp(ctx, provisioner, bricksIndex, &app); err != nil {
148-
yield(StreamMessage{error: err})
149-
return
150-
}
151-
if !yield(StreamMessage{data: "Starting app..."}) {
152-
cancel()
153-
return
154-
}
155-
156143
// Override the compose Variables with the app's variables and model configuration.
157144
envs := []string{}
145+
mapped_env := map[string]string{}
158146
addMapToEnv := func(m map[string]string) {
159147
for k, v := range m {
160148
envs = append(envs, fmt.Sprintf("%s=%s", k, v))
149+
mapped_env[k] = v
161150
}
162151
}
163152
for _, brick := range app.Descriptor.Bricks {
@@ -166,8 +155,26 @@ func StartApp(
166155
addMapToEnv(m.ModelConfiguration)
167156
}
168157
}
158+
// Add the APP_HOME directory to the environment variables
159+
addMapToEnv(map[string]string{"APP_HOME": app.FullPath.String()})
160+
slog.Debug("Configuring app environment", slog.String("APP_HOME", app.FullPath.String()), slog.Any("envs", envs))
161+
162+
if !yield(StreamMessage{data: "Provisioning app..."}) {
163+
cancel()
164+
return
165+
}
166+
if err := ProvisionApp(ctx, provisioner, bricksIndex, mapped_env, &app); err != nil {
167+
yield(StreamMessage{error: err})
168+
return
169+
}
170+
if !yield(StreamMessage{data: "Starting app..."}) {
171+
cancel()
172+
return
173+
}
174+
175+
// Launch the docker compose command to start the app
176+
overrideComposeFile := app.AppComposeOverrideFilePath()
169177

170-
overrideComposeFile := app.ProvisioningStateDir().Join("app-compose-overrides.yaml")
171178
commands := []string{}
172179
commands = append(commands, "docker", "compose", "-f", app.AppComposeFilePath().String())
173180
if ok, _ := overrideComposeFile.ExistCheck(); ok {

internal/orchestrator/provision.go

Lines changed: 153 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"maps"
99
"os"
1010
"path/filepath"
11+
"regexp"
1112
"slices"
1213
"strings"
1314
"time"
@@ -48,13 +49,14 @@ func ProvisionApp(
4849
ctx context.Context,
4950
provisioner *Provision,
5051
bricksIndex *bricksindex.BricksIndex,
52+
mapped_env map[string]string,
5153
app *app.ArduinoApp,
5254
) error {
5355
start := time.Now()
5456
defer func() {
5557
slog.Info("Provisioning took", "duration", time.Since(start).String())
5658
}()
57-
return provisioner.App(ctx, bricksIndex, app)
59+
return provisioner.App(ctx, bricksIndex, app, mapped_env)
5860
}
5961

6062
type Provision struct {
@@ -88,6 +90,7 @@ func (p *Provision) App(
8890
ctx context.Context,
8991
bricksIndex *bricksindex.BricksIndex,
9092
arduinoApp *app.ArduinoApp,
93+
mapped_env map[string]string,
9194
) error {
9295
if arduinoApp == nil {
9396
return fmt.Errorf("provisioning failed: arduinoApp is nil")
@@ -108,7 +111,7 @@ func (p *Provision) App(
108111
}
109112
}
110113

111-
return generateMainComposeFile(arduinoApp, bricksIndex, p.pythonImage)
114+
return generateMainComposeFile(arduinoApp, bricksIndex, p.pythonImage, mapped_env)
112115
}
113116

114117
func (p *Provision) IsUsingDynamicProvision() bool {
@@ -197,6 +200,7 @@ func generateMainComposeFile(
197200
app *app.ArduinoApp,
198201
bricksIndex *bricksindex.BricksIndex,
199202
pythonImage string,
203+
mapped_env map[string]string,
200204
) error {
201205
slog.Debug("Generating main compose file for the App")
202206

@@ -291,6 +295,7 @@ func generateMainComposeFile(
291295
Target: "/app",
292296
},
293297
}
298+
294299
slog.Debug("Adding UNIX socket", slog.Any("sock", orchestratorConfig.RouterSocketPath().String()), slog.Bool("exists", orchestratorConfig.RouterSocketPath().Exist()))
295300
if orchestratorConfig.RouterSocketPath().Exist() {
296301
volumes = append(volumes, volume{
@@ -301,6 +306,7 @@ func generateMainComposeFile(
301306
}
302307

303308
devices := getDevices()
309+
groups := []string{"dialout", "video", "audio"}
304310

305311
mainAppCompose.Services = &mainService{
306312
Main: service{
@@ -311,7 +317,7 @@ func generateMainComposeFile(
311317
Entrypoint: "/run.sh",
312318
DependsOn: services,
313319
User: getCurrentUser(),
314-
GroupAdd: []string{"dialout", "video", "audio"},
320+
GroupAdd: groups,
315321
ExtraHosts: []string{"msgpack-rpc-router:host-gateway"},
316322
Labels: map[string]string{
317323
DockerAppLabel: "true",
@@ -324,18 +330,28 @@ func generateMainComposeFile(
324330
if e := writeMainCompose(); e != nil {
325331
return e
326332
}
333+
327334
// If there are services that require devices, we need to generate an override compose file
328-
if overrideComposeFile.Exist() {
329-
if err := overrideComposeFile.Remove(); err != nil {
330-
return fmt.Errorf("failed to remove existing override compose file: %w", err)
331-
}
335+
// Write additional file to override devices section in included compose files
336+
if e := generateServicesOverrideFile(services, servicesThatRequireDevices, devices, getCurrentUser(), groups, overrideComposeFile); e != nil {
337+
return e
332338
}
333-
if len(servicesThatRequireDevices) > 0 {
334-
// Write additiona file to override devices section in included compose files
335-
if e := generateServicesOverrideFile(servicesThatRequireDevices, devices, overrideComposeFile); e != nil {
336-
return e
339+
340+
// Pre-provision containers required paths, if they do not exist.
341+
// This is required to preserve the host directory access rights for arduino user.
342+
// Otherwise, paths created by the container will have root:root ownership
343+
for _, additionalComposeFile := range composeFiles {
344+
composeFilePath := additionalComposeFile.String()
345+
slog.Debug("Pre-provisioning volumes from compose file", slog.String("compose_file", composeFilePath))
346+
347+
volumes, err := extractVolumesFromComposeFile(composeFilePath)
348+
if err != nil {
349+
slog.Warn("Failed to extract volumes from compose file", slog.String("compose_file", composeFilePath), slog.Any("error", err))
350+
continue
337351
}
352+
provisionComposeVolumes(composeFilePath, volumes, app, mapped_env)
338353
}
354+
339355
// Done!
340356
return nil
341357
}
@@ -364,20 +380,37 @@ func extracServicesFromComposeFile(composeFile *paths.Path) ([]string, error) {
364380
}
365381
}
366382

367-
func generateServicesOverrideFile(servicesThatRequireDevices []string, devices []string, overrideComposeFile *paths.Path) error {
383+
func generateServicesOverrideFile(services []string, servicesThatRequireDevices []string, devices []string, user string, groups []string, overrideComposeFile *paths.Path) error {
384+
if overrideComposeFile.Exist() {
385+
if err := overrideComposeFile.Remove(); err != nil {
386+
return fmt.Errorf("failed to remove existing override compose file: %w", err)
387+
}
388+
}
389+
390+
if len(services) == 0 {
391+
slog.Debug("No services to override, skipping override compose file generation")
392+
return nil
393+
}
394+
368395
type serviceOverride struct {
369-
Devices []string `yaml:"devices"`
396+
Devices *[]string `yaml:"devices,omitempty"`
397+
User string `yaml:"user"`
398+
GroupAdd []string `yaml:"group_add"`
370399
}
371400
var overrideCompose struct {
372401
Services map[string]serviceOverride `yaml:"services,omitempty"`
373402
}
374-
overrideCompose.Services = make(map[string]serviceOverride, len(servicesThatRequireDevices))
375-
for _, svc := range servicesThatRequireDevices {
376-
overrideCompose.Services[svc] = serviceOverride{
377-
Devices: devices,
403+
overrideCompose.Services = make(map[string]serviceOverride, len(services))
404+
for _, svc := range services {
405+
override := serviceOverride{
406+
User: user,
407+
GroupAdd: groups,
378408
}
409+
if slices.Contains(servicesThatRequireDevices, svc) {
410+
override.Devices = &devices
411+
}
412+
overrideCompose.Services[svc] = override
379413
}
380-
slog.Debug("Generating override compose file for devices", slog.Any("overrideCompose", overrideCompose), slog.Any("devices", devices))
381414
writeOverrideCompose := func() error {
382415
data, err := yaml.Marshal(overrideCompose)
383416
if err != nil {
@@ -393,3 +426,105 @@ func generateServicesOverrideFile(servicesThatRequireDevices []string, devices [
393426
}
394427
return nil
395428
}
429+
430+
var (
431+
// Regular expression to split on the first colon that is not followed by a hyphen
432+
volumeColonSplitRE = regexp.MustCompile(`:[^-]`)
433+
volumeAppHomeReplaceRE = regexp.MustCompile(`\$\{APP_HOME(:-\.)?\}`)
434+
volumePathReplaceRE = regexp.MustCompile(`\$\{([A-Z_-]+)(:-)?([\/a-zA-Z0-9._-]+)?\}`)
435+
)
436+
437+
// provisionComposeVolumes ensure we create the parent folder with the correct owner.
438+
// By default docker if it doesn't find the folder, it will create it as root.
439+
// We do not want that, to make sure to have it as `arduino:arduino` we have
440+
// to manually parse the volumes, and make sure to create the target dirs ourself.
441+
func provisionComposeVolumes(additionalComposeFile string, volumes []string, app *app.ArduinoApp, mapped_env map[string]string) {
442+
if len(volumes) == 0 {
443+
slog.Debug("No volumes to provision from compose file", slog.String("compose_file", additionalComposeFile))
444+
return
445+
}
446+
447+
slog.Debug("Extracted volumes from compose file", slog.String("compose_file", additionalComposeFile), slog.Any("volumes", volumes))
448+
for _, volume := range volumes {
449+
volume = replaceDockerMacros(volume, app, mapped_env, additionalComposeFile)
450+
hostDirectory := paths.New(volume)
451+
if strings.Contains(volume, ":") {
452+
volumes := volumeColonSplitRE.Split(volume, -1)
453+
hostDirectory = paths.New(volumes[0])
454+
}
455+
if !hostDirectory.Exist() {
456+
if err := hostDirectory.MkdirAll(); err != nil {
457+
slog.Warn("Failed to create host directory for compose file", slog.String("compose_file", additionalComposeFile), slog.String("host_directory", hostDirectory.String()), slog.Any("error", err))
458+
} else {
459+
slog.Debug("Pre-provisioning host directory for compose file", slog.String("compose_file", additionalComposeFile), slog.String("host_directory", hostDirectory.String()))
460+
}
461+
}
462+
}
463+
}
464+
465+
func replaceDockerMacros(volume string, app *app.ArduinoApp, mapped_env map[string]string, additionalComposeFile string) string {
466+
// Replace ${APP_HOME} with the actual app path
467+
volume = volumeAppHomeReplaceRE.ReplaceAllString(volume, app.FullPath.String())
468+
// Replace host volume directory with the actual path
469+
if volumePathReplaceRE.MatchString(volume) {
470+
groups := volumePathReplaceRE.FindStringSubmatch(volume)
471+
// idx 0 is the full match, idx 1 is the variable name, idx 2 is the optional `:-` and idx 3 is the default value
472+
switch len(groups) {
473+
case 2:
474+
// Check if the environment variable is set
475+
if value, ok := mapped_env[groups[1]]; ok {
476+
volume = volumePathReplaceRE.ReplaceAllString(volume, value)
477+
} else {
478+
slog.Warn("Environment variable not found for volume replacement", slog.String("variable", groups[1]), slog.String("compose_file", additionalComposeFile))
479+
}
480+
case 4:
481+
// If the variable is not set, use the default value
482+
if value, ok := mapped_env[groups[1]]; ok {
483+
volume = volumePathReplaceRE.ReplaceAllString(volume, value)
484+
} else {
485+
volume = volumePathReplaceRE.ReplaceAllString(volume, groups[3])
486+
}
487+
default:
488+
slog.Warn("Unexpected format for volume replacement", slog.String("volume", volume), slog.String("compose_file", additionalComposeFile))
489+
}
490+
}
491+
return volume
492+
}
493+
494+
func extractVolumesFromComposeFile(additionalComposeFile string) ([]string, error) {
495+
content, err := os.ReadFile(additionalComposeFile)
496+
if err != nil {
497+
slog.Error("Failed to read compose file", slog.String("compose_file", additionalComposeFile), slog.Any("error", err))
498+
return nil, err
499+
}
500+
// Try with string syntax first
501+
type composeServices[T any] struct {
502+
Services map[string]struct {
503+
Volumes []T `yaml:"volumes"`
504+
} `yaml:"services"`
505+
}
506+
var index composeServices[string]
507+
if err := yaml.Unmarshal(content, &index); err != nil {
508+
var index composeServices[volume]
509+
if err := yaml.Unmarshal(content, &index); err != nil {
510+
return nil, fmt.Errorf("failed to unmarshal compose file %s: %w", additionalComposeFile, err)
511+
}
512+
volumes := make([]string, 0, len(index.Services))
513+
for _, svc := range index.Services {
514+
for _, v := range svc.Volumes {
515+
if v.Type == "bind" {
516+
volumes = append(volumes, v.Source)
517+
} else {
518+
volumes = append(volumes, v.Target)
519+
}
520+
}
521+
}
522+
return volumes, nil
523+
}
524+
525+
volumes := make([]string, 0, len(index.Services))
526+
for _, svc := range index.Services {
527+
volumes = append(volumes, svc.Volumes...)
528+
}
529+
return volumes, nil
530+
}

0 commit comments

Comments
 (0)