Skip to content

Commit cebd4d2

Browse files
api: start, stop, list, details, logs
1 parent 60774b8 commit cebd4d2

File tree

24 files changed

+1441
-278
lines changed

24 files changed

+1441
-278
lines changed

.gitignore

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,4 +30,5 @@ build/
3030

3131
**/.cache
3232

33-
arduino-app-cli
33+
/arduino-app-cli
34+
/apps

cmd/arduino-app-cli/main.go

Lines changed: 143 additions & 81 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,12 @@ package main
22

33
import (
44
"context"
5+
"encoding/json"
56
"errors"
67
"fmt"
7-
"log"
88
"log/slog"
99
"net/http"
1010
"os"
11-
"path"
1211
"time"
1312

1413
dockerClient "github.com/docker/docker/client"
@@ -17,31 +16,10 @@ import (
1716

1817
"github.com/arduino/arduino-app-cli/internal/api"
1918
"github.com/arduino/arduino-app-cli/internal/orchestrator"
19+
"github.com/arduino/arduino-app-cli/pkg/httprecover"
2020
"github.com/arduino/arduino-app-cli/pkg/parser"
2121
)
2222

23-
const DockerRegistry = "ghcr.io/bcmi-labs/"
24-
const DockerPythonImage = "arduino/appslab-python-apps-base:0.0.2"
25-
26-
var pythonImage string
27-
28-
func init() {
29-
// Registry base: contains the registry and namespace, common to all Arduino docker images.
30-
registryBase := os.Getenv("DOCKER_REGISTRY_BASE")
31-
if registryBase == "" {
32-
registryBase = DockerRegistry
33-
}
34-
35-
// Python image: image name (repository) and optionally a tag.
36-
pythonImageAndTag := os.Getenv("DOCKER_PYTHON_BASE_IMAGE")
37-
if pythonImageAndTag == "" {
38-
pythonImageAndTag = DockerPythonImage
39-
}
40-
41-
pythonImage = path.Join(registryBase, pythonImageAndTag)
42-
fmt.Println("Using pythonImage:", pythonImage)
43-
}
44-
4523
func main() {
4624
docker, err := dockerClient.NewClientWithOpts(
4725
dockerClient.FromEnv,
@@ -52,117 +30,199 @@ func main() {
5230
}
5331
defer docker.Close()
5432

55-
var parsedApp parser.App
33+
var daemonPort string
34+
var completionNoDesc bool // Disable completion description for shells that support it
5635

5736
rootCmd := &cobra.Command{
58-
Use: "app <APP_PATH>",
37+
Use: "arduino-app-cli",
5938
Short: "A CLI to manage the Python app",
6039
PersistentPreRun: func(cmd *cobra.Command, args []string) {
61-
if cmd.Name() == "daemon" {
62-
return
63-
}
64-
if len(args) != 1 {
65-
_ = cmd.Help()
66-
os.Exit(1)
67-
}
68-
app, err := parser.Load(args[0])
69-
if err != nil {
70-
log.Panic(err)
40+
},
41+
}
42+
43+
completionCommand := &cobra.Command{
44+
Use: "completion [bash|zsh|fish|powershell] [--no-descriptions]",
45+
ValidArgs: []string{"bash", "zsh", "fish", "powershell"},
46+
Args: cobra.ExactArgs(1),
47+
Short: "Generates completion scripts",
48+
Long: "Generates completion scripts for various shells",
49+
Example: " " + os.Args[0] + " completion bash > completion.sh\n" +
50+
" " + "source completion.sh",
51+
RunE: func(cmd *cobra.Command, args []string) error {
52+
switch args[0] {
53+
case "bash":
54+
return cmd.Root().GenBashCompletionV2(cmd.OutOrStdout(), !completionNoDesc)
55+
case "zsh":
56+
if completionNoDesc {
57+
return cmd.Root().GenZshCompletionNoDesc(cmd.OutOrStdout())
58+
} else {
59+
return cmd.Root().GenZshCompletion(cmd.OutOrStdout())
60+
}
61+
case "fish":
62+
return cmd.Root().GenFishCompletion(cmd.OutOrStdout(), !completionNoDesc)
63+
case "powershell":
64+
return cmd.Root().GenPowerShellCompletion(cmd.OutOrStdout())
7165
}
72-
parsedApp = app
66+
return nil
67+
},
68+
}
69+
completionCommand.Flags().BoolVar(&completionNoDesc, "no-descriptions", false, "Disable completion description for shells that support it")
70+
71+
daemonCmd := &cobra.Command{
72+
Use: "daemon",
73+
Short: "Run an HTTP server to expose arduino-app-cli functionality thorough REST API",
74+
Run: func(cmd *cobra.Command, args []string) {
75+
httpHandler(cmd.Context(), docker, daemonPort)
7376
},
7477
}
78+
daemonCmd.Flags().StringVar(&daemonPort, "port", "8080", "The TCP port the daemon will listen to")
7579

7680
rootCmd.AddCommand(
7781
&cobra.Command{
78-
Use: "stop",
82+
Use: "stop app_path",
7983
Short: "Stop the Python app",
80-
Run: func(cmd *cobra.Command, args []string) {
81-
stopHandler(cmd.Context(), parsedApp)
84+
Args: cobra.MaximumNArgs(1),
85+
RunE: func(cmd *cobra.Command, args []string) error {
86+
if len(args) == 0 {
87+
return cmd.Help()
88+
}
89+
app, err := parser.Load(args[0])
90+
if err != nil {
91+
return err
92+
}
93+
return stopHandler(cmd.Context(), app)
8294
},
8395
},
8496
&cobra.Command{
85-
Use: "start",
97+
Use: "start app_path",
8698
Short: "Start the Python app",
87-
Run: func(cmd *cobra.Command, args []string) {
88-
if parsedApp.MainPythonFile != nil {
89-
provisionHandler(cmd.Context(), docker, parsedApp)
99+
Args: cobra.MaximumNArgs(1),
100+
RunE: func(cmd *cobra.Command, args []string) error {
101+
if len(args) == 0 {
102+
return cmd.Help()
90103
}
91-
92-
startHandler(cmd.Context(), parsedApp)
104+
app, err := parser.Load(args[0])
105+
if err != nil {
106+
return err
107+
}
108+
return startHandler(cmd.Context(), docker, app)
93109
},
94110
},
95111
&cobra.Command{
96-
Use: "logs",
112+
Use: "logs app_path",
97113
Short: "Show the logs of the Python app",
98-
Run: func(cmd *cobra.Command, args []string) {
99-
logsHandler(cmd.Context(), parsedApp)
114+
Args: cobra.MaximumNArgs(1),
115+
RunE: func(cmd *cobra.Command, args []string) error {
116+
if len(args) == 0 {
117+
return cmd.Help()
118+
}
119+
app, err := parser.Load(args[0])
120+
if err != nil {
121+
return err
122+
}
123+
return logsHandler(cmd.Context(), app)
100124
},
101125
},
102126
&cobra.Command{
103127
Use: "list",
104128
Short: "List all running Python apps",
105-
Run: func(cmd *cobra.Command, args []string) {
106-
listHandler(cmd.Context())
129+
RunE: func(cmd *cobra.Command, args []string) error {
130+
return listHandler(cmd.Context())
107131
},
108132
},
109133
&cobra.Command{
110-
Use: "provision",
134+
Use: "provision app_path",
111135
Short: "Makes sure the Python app deps are downloaded and running",
112-
Run: func(cmd *cobra.Command, args []string) {
113-
provisionHandler(cmd.Context(), docker, parsedApp)
114-
},
115-
},
116-
&cobra.Command{
117-
Use: "daemon",
118-
Short: "Run an HTTP server to expose orchestrator functionality thorough REST API",
119-
Run: func(cmd *cobra.Command, args []string) {
120-
httpHandler(cmd.Context(), docker)
136+
Args: cobra.MaximumNArgs(1),
137+
RunE: func(cmd *cobra.Command, args []string) error {
138+
if len(args) == 0 {
139+
return cmd.Help()
140+
}
141+
app, err := parser.Load(args[0])
142+
if err != nil {
143+
return err
144+
}
145+
return provisionHandler(cmd.Context(), docker, app)
121146
},
122147
},
148+
completionCommand,
149+
daemonCmd,
123150
)
124151

125152
ctx := context.Background()
126-
ctx, cancel := cleanup.InterruptableContext(ctx)
127-
defer cancel()
153+
ctx, _ = cleanup.InterruptableContext(ctx)
128154
if err := rootCmd.ExecuteContext(ctx); err != nil {
129-
log.Panic(err)
155+
slog.Error(err.Error())
130156
}
131157
}
132158

133-
func provisionHandler(ctx context.Context, docker *dockerClient.Client, app parser.App) {
134-
orchestrator.ProvisionApp(ctx, pythonImage, docker, app)
159+
func provisionHandler(ctx context.Context, docker *dockerClient.Client, app parser.App) error {
160+
if err := orchestrator.ProvisionApp(ctx, docker, app); err != nil {
161+
return err
162+
}
163+
return nil
135164
}
136165

137-
func startHandler(ctx context.Context, app parser.App) {
138-
orchestrator.StartApp(ctx, app)
166+
func startHandler(ctx context.Context, docker *dockerClient.Client, app parser.App) error {
167+
for message := range orchestrator.StartApp(ctx, docker, app) {
168+
switch message.GetType() {
169+
case orchestrator.ProgressType:
170+
slog.Info("progress", slog.Float64("progress", float64(message.GetProgress().Progress)))
171+
case orchestrator.InfoType:
172+
slog.Info("log", slog.String("message", message.GetData()))
173+
case orchestrator.ErrorType:
174+
return errors.New(message.GetError().Error())
175+
}
176+
}
177+
return nil
139178
}
140179

141-
func stopHandler(ctx context.Context, app parser.App) {
142-
orchestrator.StopApp(ctx, app)
180+
func stopHandler(ctx context.Context, app parser.App) error {
181+
for message := range orchestrator.StopApp(ctx, app) {
182+
switch message.GetType() {
183+
case orchestrator.ProgressType:
184+
slog.Info("progress", slog.Float64("progress", float64(message.GetProgress().Progress)))
185+
case orchestrator.InfoType:
186+
slog.Info("log", slog.String("message", message.GetData()))
187+
case orchestrator.ErrorType:
188+
return errors.New(message.GetError().Error())
189+
}
190+
}
191+
return nil
143192
}
144193

145-
func logsHandler(ctx context.Context, app parser.App) {
146-
orchestrator.AppLogs(ctx, app)
194+
func logsHandler(ctx context.Context, app parser.App) error {
195+
logsIter, err := orchestrator.AppLogs(ctx, app, orchestrator.AppLogsRequest{ShowAppLogs: true, Follow: true})
196+
if err != nil {
197+
return err
198+
}
199+
for msg := range logsIter {
200+
fmt.Printf("[%s] %s\n", msg.Name, msg.Content)
201+
}
202+
return nil
147203
}
148204

149-
func listHandler(ctx context.Context) {
205+
func listHandler(ctx context.Context) error {
150206
res, err := orchestrator.ListApps(ctx)
151207
if err != nil {
152-
slog.Error(err.Error())
153-
return
208+
return nil
154209
}
155-
fmt.Println(string(res.Stdout))
156-
if len(res.Stderr) > 0 {
157-
fmt.Println(string(res.Stderr))
210+
211+
resJSON, err := json.Marshal(res)
212+
if err != nil {
213+
return nil
158214
}
215+
fmt.Println(string(resJSON))
216+
return nil
159217
}
160218

161-
func httpHandler(ctx context.Context, dockerClient *dockerClient.Client) {
219+
func httpHandler(ctx context.Context, dockerClient *dockerClient.Client, daemonPort string) {
220+
slog.Info("Starting HTTP server", slog.String("address", ":"+daemonPort))
162221
apiSrv := api.NewHTTPRouter(dockerClient)
222+
163223
httpSrv := http.Server{
164-
Addr: ":8080",
165-
Handler: apiSrv,
224+
Addr: ":" + daemonPort,
225+
Handler: httprecover.RecoverPanic(apiSrv),
166226
ReadHeaderTimeout: 60 * time.Second,
167227
}
168228
go func() {
@@ -172,8 +232,10 @@ func httpHandler(ctx context.Context, dockerClient *dockerClient.Client) {
172232
}()
173233

174234
<-ctx.Done()
235+
slog.Info("Shutting down HTTP server", slog.String("address", ":"+daemonPort))
175236

176237
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
177238
_ = httpSrv.Shutdown(ctx)
178239
cancel()
240+
slog.Info("HTTP server shut down", slog.String("address", ":"+daemonPort))
179241
}

internal/api/api.go

Lines changed: 57 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,18 +2,72 @@ package api
22

33
import (
44
"net/http"
5+
"strings"
56

67
"github.com/arduino/arduino-app-cli/internal/api/handlers"
8+
"github.com/arduino/arduino-app-cli/internal/orchestrator"
79

810
dockerClient "github.com/docker/docker/client"
911
)
1012

1113
func NewHTTPRouter(dockerClient *dockerClient.Client) http.Handler {
1214
mux := http.NewServeMux()
1315

14-
mux.Handle("POST /v1/app/start", handlers.HandleAppStart(dockerClient))
15-
mux.Handle("POST /v1/app/stop", handlers.HandleAppStop(dockerClient))
16-
mux.Handle("GET /v1/app/list", handlers.HandleAppList(dockerClient))
16+
mux.Handle("GET /v1/apps", handlers.HandleAppList(dockerClient))
17+
mux.Handle("POST /v1/apps", handlers.HandleAppCreate(dockerClient))
18+
19+
appLogsHandler := handlers.HandleAppLogs(dockerClient)
20+
appEventsHandler := handlers.HandleAppEvents(dockerClient)
21+
appGetVariablesHandler := handlers.HandleAppGetVariables(dockerClient)
22+
appDetailsHandler := handlers.HandleAppDetails(dockerClient)
23+
mux.HandleFunc("GET /v1/apps/{path...}", func(w http.ResponseWriter, r *http.Request) {
24+
path := r.PathValue("path")
25+
switch {
26+
case strings.HasSuffix(path, "/logs"):
27+
id := strings.TrimSuffix(path, "/logs")
28+
appLogsHandler(w, r, orchestrator.ID(id))
29+
case strings.HasSuffix(path, "/events"):
30+
id := strings.TrimSuffix(path, "/events")
31+
appEventsHandler(w, r, orchestrator.ID(id))
32+
case strings.HasSuffix(path, "/variables"):
33+
id := strings.TrimSuffix(path, "/variables")
34+
appGetVariablesHandler(w, r, orchestrator.ID(id))
35+
default:
36+
appDetailsHandler(w, r, orchestrator.ID(path))
37+
}
38+
})
39+
40+
appSetVariablesHandler := handlers.HandleAppSetVariables(dockerClient)
41+
mux.HandleFunc("PATCH /v1/apps/{path...}", func(w http.ResponseWriter, r *http.Request) {
42+
path := r.PathValue("path")
43+
switch {
44+
case strings.HasSuffix(path, "/variables"):
45+
id := strings.TrimSuffix(path, "/variables")
46+
appSetVariablesHandler(w, r, orchestrator.ID(id))
47+
default:
48+
w.WriteHeader(http.StatusNotFound)
49+
}
50+
})
51+
52+
startHandler := handlers.HandleAppStart(dockerClient)
53+
stopHandler := handlers.HandleAppStop(dockerClient)
54+
cloneHandler := handlers.HandleAppClone(dockerClient)
55+
mux.HandleFunc("POST /v1/apps/{path...}", func(w http.ResponseWriter, r *http.Request) {
56+
path := r.PathValue("path")
57+
switch {
58+
case strings.HasSuffix(path, "/start"):
59+
id := strings.TrimSuffix(path, "/start")
60+
startHandler(w, r, orchestrator.ID(id))
61+
case strings.HasSuffix(path, "/stop"):
62+
id := strings.TrimSuffix(path, "/stop")
63+
stopHandler(w, r, orchestrator.ID(id))
64+
case strings.HasSuffix(path, "/clone"):
65+
id := strings.TrimSuffix(path, "/clone")
66+
cloneHandler(w, r, orchestrator.ID(id))
67+
default:
68+
w.WriteHeader(http.StatusNotFound)
69+
}
70+
})
1771

1872
return mux
1973
}

0 commit comments

Comments
 (0)