Skip to content

Commit bed52fc

Browse files
alessio-peruginicmaglieXayton
authored
orchestrator: base app launch functionalities
* first implementation Co-authored-by: Cristian Maglie <c.maglie@arduino.cc> * use upstream images * apease linter * use .cache folder under application * fixup! first implementation * add ports and devices * fix run.sh script This would prevent to correctly initialize uv, and uv/python caches * update example * go POSIX go * simplify mounting since we have decided to use the .cache to put everything, use a /tmp dir is redundant as the final target is still the .cache folder * Docker client should search the socket with the default approach (same as the CLI) * appease dprint * orchestrator: app-compose set projectname We take the name of the app folder as project name. This is needed otherwise docker compose will prefix with `cache-` the services, due to a fallback that takes the name of the parent folder if not explicit name is set * Restore taking the docker client config from env variables. * fix debian build in case host is on x86_64 arch * dpritning Everything * alwyas run provision when calling app start * update README --------- Co-authored-by: Cristian Maglie <c.maglie@arduino.cc> Co-authored-by: Xayton <30591904+Xayton@users.noreply.github.com>
1 parent 1d537fc commit bed52fc

File tree

18 files changed

+541
-346
lines changed

18 files changed

+541
-346
lines changed

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,3 +30,5 @@ go.work.sum
3030
.venv/
3131

3232
build/
33+
34+
**/.cache

cmd/orchestrator/main.go

Lines changed: 302 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,311 @@
11
package main
22

33
import (
4+
"context"
45
"fmt"
5-
"time"
6+
"log"
7+
"os"
8+
"os/user"
9+
"strings"
10+
11+
"github.com/arduino/go-paths-helper"
12+
"github.com/docker/docker/api/types/container"
13+
dockerClient "github.com/docker/docker/client"
14+
"github.com/spf13/cobra"
15+
"go.bug.st/cleanup"
16+
"go.bug.st/f"
17+
"gopkg.in/yaml.v3"
18+
19+
"github.com/arduino/arduino-app-cli/pkg/parser"
620
)
721

22+
const pythonImage = "arduino-python-base:latest"
23+
824
func main() {
9-
for {
10-
fmt.Println("running...")
11-
time.Sleep(1 * time.Second)
25+
docker, err := dockerClient.NewClientWithOpts(
26+
dockerClient.FromEnv,
27+
dockerClient.WithAPIVersionNegotiation(),
28+
)
29+
if err != nil {
30+
panic(err)
31+
}
32+
defer docker.Close()
33+
34+
var parsedApp parser.App
35+
36+
rootCmd := &cobra.Command{
37+
Use: "app <APP_PATH>",
38+
Short: "A CLI to manage the Python app",
39+
PersistentPreRun: func(cmd *cobra.Command, args []string) {
40+
if len(args) != 1 {
41+
_ = cmd.Help()
42+
os.Exit(1)
43+
}
44+
app, err := parser.Load(args[0])
45+
if err != nil {
46+
log.Panic(err)
47+
}
48+
parsedApp = app
49+
},
50+
}
51+
52+
rootCmd.AddCommand(
53+
&cobra.Command{
54+
Use: "stop",
55+
Short: "Stop the Python app",
56+
Run: func(cmd *cobra.Command, args []string) {
57+
stopHandler(cmd.Context(), parsedApp)
58+
},
59+
},
60+
&cobra.Command{
61+
Use: "start",
62+
Short: "Start the Python app",
63+
Run: func(cmd *cobra.Command, args []string) {
64+
provisionHandler(cmd.Context(), docker, parsedApp)
65+
startHandler(cmd.Context(), parsedApp)
66+
},
67+
},
68+
&cobra.Command{
69+
Use: "logs",
70+
Short: "Show the logs of the Python app",
71+
Run: func(cmd *cobra.Command, args []string) {
72+
logsHandler(cmd.Context(), parsedApp)
73+
},
74+
},
75+
&cobra.Command{
76+
Use: "list",
77+
Short: "List all running Python apps",
78+
Run: func(cmd *cobra.Command, args []string) {
79+
listHandler(cmd.Context(), parsedApp)
80+
},
81+
},
82+
&cobra.Command{
83+
Use: "provision",
84+
Short: "Provision the Python app",
85+
Run: func(cmd *cobra.Command, args []string) {
86+
provisionHandler(cmd.Context(), docker, parsedApp)
87+
},
88+
},
89+
)
90+
91+
ctx := context.Background()
92+
ctx, cancel := cleanup.InterruptableContext(ctx)
93+
defer cancel()
94+
if err := rootCmd.ExecuteContext(ctx); err != nil {
95+
log.Panic(err)
96+
}
97+
}
98+
99+
func getProvisioningStateDir(app parser.App) *paths.Path {
100+
cacheDir := app.FullPath.Join(".cache")
101+
if err := cacheDir.MkdirAll(); err != nil {
102+
panic(err)
103+
}
104+
return cacheDir
105+
}
106+
107+
func provisionHandler(ctx context.Context, docker *dockerClient.Client, app parser.App) {
108+
pwd, _ := os.Getwd()
109+
resp, err := docker.ContainerCreate(ctx, &container.Config{
110+
Image: pythonImage,
111+
User: getCurrentUser(),
112+
Entrypoint: []string{"/run.sh", "provision"},
113+
}, &container.HostConfig{
114+
Binds: []string{
115+
app.FullPath.String() + ":/app",
116+
pwd + "/scripts/provision.py:/provision.py",
117+
pwd + "/scripts/run.sh:/run.sh",
118+
},
119+
AutoRemove: true,
120+
}, nil, nil, app.Name)
121+
if err != nil {
122+
log.Panic(err)
123+
}
124+
125+
waitCh, errCh := docker.ContainerWait(ctx, resp.ID, container.WaitConditionNextExit)
126+
if err := docker.ContainerStart(ctx, resp.ID, container.StartOptions{}); err != nil {
127+
log.Panic(err)
128+
}
129+
fmt.Println("Container started with ID:", resp.ID)
130+
131+
select {
132+
case result := <-waitCh:
133+
if result.Error != nil {
134+
log.Panic("Container wait error:", result.Error.Message)
135+
}
136+
fmt.Println("Container exited with status code:", result.StatusCode)
137+
case err := <-errCh:
138+
log.Panic("Error waiting for container:", err)
139+
}
140+
141+
generateMainComposeFile(ctx, app)
142+
}
143+
144+
func generateMainComposeFile(ctx context.Context, app parser.App) {
145+
provisioningStateDir := getProvisioningStateDir(app)
146+
147+
var composeFiles paths.PathList
148+
for _, dep := range app.Descriptor.ModuleDependencies {
149+
composeFilePath := provisioningStateDir.Join("compose", dep, "module_compose.yaml")
150+
if composeFilePath.Exist() {
151+
composeFiles.Add(composeFilePath)
152+
}
153+
}
154+
155+
// Create a single docker-mainCompose that includes all the required services
156+
mainComposeFile := provisioningStateDir.Join("app-compose.yaml")
157+
158+
type service struct {
159+
Image string `yaml:"image"`
160+
DependsOn []string `yaml:"depends_on,omitempty"`
161+
Volumes []string `yaml:"volumes"`
162+
Devices []string `yaml:"devices"`
163+
Ports []string `yaml:"ports"`
164+
User string `yaml:"user"`
165+
Entrypoint string `yaml:"entrypoint"`
166+
}
167+
type mainService struct {
168+
Main service `yaml:"main"`
169+
}
170+
var mainAppCompose struct {
171+
Name string `yaml:"name"`
172+
Include []string `yaml:"include,omitempty"`
173+
Services *mainService `yaml:"services,omitempty"`
174+
}
175+
writeMainCompose := func() {
176+
data, _ := yaml.Marshal(mainAppCompose)
177+
if err := mainComposeFile.WriteFile(data); err != nil {
178+
log.Panic(err)
179+
}
180+
}
181+
182+
// Merge compose
183+
mainAppCompose.Include = composeFiles.AsStrings()
184+
mainAppCompose.Name = app.Name
185+
writeMainCompose()
186+
187+
// docker compose -f app-compose.yml config --services
188+
process, err := paths.NewProcess(nil, "docker", "compose", "-f", mainComposeFile.String(), "config", "--services")
189+
if err != nil {
190+
log.Panic(err)
191+
}
192+
stdout, stderr, err := process.RunAndCaptureOutput(ctx)
193+
if err != nil {
194+
log.Panic(err)
195+
}
196+
if len(stderr) > 0 {
197+
fmt.Println("stderr:", string(stderr))
198+
}
199+
pwd, _ := os.Getwd()
200+
services := strings.Split(strings.TrimSpace(string(stdout)), "\n")
201+
services = f.Filter(services, f.NotEquals("main"))
202+
203+
ports := make([]string, len(app.Descriptor.Ports))
204+
for i, p := range app.Descriptor.Ports {
205+
ports[i] = fmt.Sprintf("%d:%d", p, p)
206+
}
207+
208+
mainAppCompose.Services = &mainService{
209+
Main: service{
210+
Image: pythonImage, // TODO: when we will handle versioning change this
211+
Volumes: []string{
212+
app.FullPath.String() + ":/app",
213+
pwd + "/scripts/run.sh:/run.sh",
214+
},
215+
Ports: ports,
216+
Devices: getDevices(),
217+
Entrypoint: "/run.sh",
218+
DependsOn: services,
219+
User: getCurrentUser(),
220+
},
221+
}
222+
writeMainCompose()
223+
}
224+
225+
func startHandler(ctx context.Context, app parser.App) {
226+
provisioningStateDir := getProvisioningStateDir(app)
227+
mainCompose := provisioningStateDir.Join("app-compose.yaml")
228+
process, err := paths.NewProcess(nil, "docker", "compose", "-f", mainCompose.String(), "up", "-d", "--remove-orphans")
229+
if err != nil {
230+
log.Panic(err)
231+
}
232+
process.RedirectStdoutTo(os.Stdout)
233+
process.RedirectStderrTo(os.Stderr)
234+
err = process.RunWithinContext(ctx)
235+
if err != nil {
236+
log.Panic(err)
237+
}
238+
239+
fmt.Println("Docker Compose project started in detached mode.")
240+
}
241+
242+
func stopHandler(ctx context.Context, app parser.App) {
243+
provisioningStateDir := getProvisioningStateDir(app)
244+
mainCompose := provisioningStateDir.Join("app-compose.yaml")
245+
process, err := paths.NewProcess(nil, "docker", "compose", "-f", mainCompose.String(), "stop")
246+
if err != nil {
247+
log.Panic(err)
248+
}
249+
process.RedirectStdoutTo(os.Stdout)
250+
process.RedirectStderrTo(os.Stderr)
251+
err = process.RunWithinContext(ctx)
252+
if err != nil {
253+
log.Panic(err)
254+
}
255+
256+
fmt.Println("Container stopped and removed")
257+
}
258+
259+
// TODO: for now we show only logs for the main python container.
260+
// In the future we should also add logs for other services too.
261+
func logsHandler(ctx context.Context, app parser.App) {
262+
provisioningStateDir := getProvisioningStateDir(app)
263+
mainCompose := provisioningStateDir.Join("app-compose.yaml")
264+
process, err := paths.NewProcess(nil, "docker", "compose", "-f", mainCompose.String(), "logs", "main", "-f")
265+
if err != nil {
266+
log.Panic(err)
267+
}
268+
process.RedirectStdoutTo(os.Stdout)
269+
process.RedirectStderrTo(os.Stderr)
270+
err = process.RunWithinContext(ctx)
271+
if err != nil {
272+
log.Println(err)
273+
}
274+
}
275+
276+
// TODO: show arduino app in execution
277+
func listHandler(ctx context.Context, app parser.App) {
278+
provisioningStateDir := getProvisioningStateDir(app)
279+
mainCompose := provisioningStateDir.Join("app-compose.yaml")
280+
process, err := paths.NewProcess(nil, "docker", "compose", "-f", mainCompose.String(), "ls")
281+
if err != nil {
282+
log.Panic(err)
283+
}
284+
// stream output
285+
process.RedirectStdoutTo(os.Stdout)
286+
process.RedirectStderrTo(os.Stderr)
287+
288+
err = process.RunWithinContext(ctx)
289+
if err != nil {
290+
log.Panic(err)
291+
}
292+
}
293+
294+
func getDevices() []string {
295+
deviceList, err := paths.New("/dev").ReadDir()
296+
if err != nil {
297+
panic(err)
298+
}
299+
deviceList.FilterPrefix("video")
300+
return deviceList.AsStrings()
301+
}
302+
303+
func getCurrentUser() string {
304+
// Map user to avoid permission issues.
305+
// MacOS and Windows uses a VM so we don't need to map the user.
306+
user, err := user.Current()
307+
if err != nil {
308+
panic(err)
12309
}
310+
return user.Uid + ":" + user.Gid
13311
}

0 commit comments

Comments
 (0)