Skip to content

Commit b00e61f

Browse files
committed
implement export app
1 parent 91cd5a6 commit b00e61f

File tree

7 files changed

+263
-25
lines changed

7 files changed

+263
-25
lines changed

cmd/gendoc/docs.go

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -562,6 +562,31 @@ Contains a JSON object with the details of an error.
562562
{StatusCode: http.StatusInternalServerError, Reference: "#/components/responses/InternalServerError"},
563563
},
564564
},
565+
{
566+
OperationId: "exportApp",
567+
Method: http.MethodGet,
568+
Path: "/v1/apps/{id}/export",
569+
Request: (*struct {
570+
ID string `path:"id" description:"application identifier."`
571+
})(nil),
572+
Parameters: (*struct {
573+
IncludeData bool `query:"include_data" description:"If true, the exported archive will include the 'data' directory. Default is false."`
574+
})(nil),
575+
CustomSuccessResponse: &CustomResponseDef{
576+
ContentType: "application/zip",
577+
DataStructure: []byte{},
578+
Description: "The ZIP archive containing the application structure.",
579+
StatusCode: http.StatusOK,
580+
},
581+
Description: "Exports the application folder structure as a ZIP file.",
582+
Summary: "Exports an app as ZIP",
583+
Tags: []Tag{ApplicationTag},
584+
PossibleErrors: []ErrorResponse{
585+
{StatusCode: http.StatusBadRequest, Reference: "#/components/responses/BadRequest"},
586+
{StatusCode: http.StatusNotFound, Reference: "#/components/responses/NotFound"},
587+
{StatusCode: http.StatusInternalServerError, Reference: "#/components/responses/InternalServerError"},
588+
},
589+
},
565590
{
566591
OperationId: "getAppEvents",
567592
Method: http.MethodGet,

internal/api/api.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,14 +74,14 @@ func NewHTTPRouter(
7474
mux.Handle("GET /v1/apps", handlers.HandleAppList(dockerClient, idProvider, cfg))
7575
mux.Handle("POST /v1/apps", handlers.HandleAppCreate(idProvider, cfg))
7676
mux.Handle("GET /v1/apps/events", handlers.HandlerAppStatus(dockerClient, idProvider, cfg))
77-
7877
mux.Handle("GET /v1/apps/{appID}", handlers.HandleAppDetails(dockerClient, bricksIndex, idProvider, cfg))
7978
mux.Handle("PATCH /v1/apps/{appID}", handlers.HandleAppDetailsEdits(dockerClient, bricksIndex, idProvider, cfg))
8079
mux.Handle("GET /v1/apps/{appID}/logs", handlers.HandleAppLogs(dockerClient, idProvider, staticStore))
8180
mux.Handle("POST /v1/apps/{appID}/start", handlers.HandleAppStart(dockerClient, provisioner, modelsIndex, bricksIndex, idProvider, cfg, staticStore))
8281
mux.Handle("POST /v1/apps/{appID}/stop", handlers.HandleAppStop(dockerClient, idProvider))
8382
mux.Handle("POST /v1/apps/{appID}/clone", handlers.HandleAppClone(dockerClient, idProvider, cfg))
8483
mux.Handle("DELETE /v1/apps/{appID}", handlers.HandleAppDelete(dockerClient, idProvider))
84+
mux.Handle("GET /v1/apps/{appID}/export", handlers.HandleAppExport(idProvider, cfg))
8585
mux.Handle("GET /v1/apps/{appID}/exposed-ports", handlers.HandleAppPorts(bricksIndex, idProvider))
8686
mux.Handle("PUT /v1/apps/{appID}/sketch/libraries/{libRef}", handlers.HandleSketchAddLibrary(idProvider))
8787
mux.Handle("DELETE /v1/apps/{appID}/sketch/libraries/{libRef}", handlers.HandleSketchRemoveLibrary(idProvider))

internal/api/docs/openapi.yaml

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -522,6 +522,43 @@ paths:
522522
summary: Get application events
523523
tags:
524524
- Application
525+
/v1/apps/{id}/export:
526+
get:
527+
description: Exports the application folder structure as a ZIP file.
528+
operationId: exportApp
529+
parameters:
530+
- description: application identifier.
531+
in: path
532+
name: id
533+
required: true
534+
schema:
535+
description: application identifier.
536+
type: string
537+
- description: If true, the exported archive will include the 'data' directory.
538+
Default is false.
539+
in: query
540+
name: include_data
541+
schema:
542+
description: If true, the exported archive will include the 'data' directory.
543+
Default is false.
544+
type: boolean
545+
responses:
546+
"200":
547+
content:
548+
application/zip:
549+
schema:
550+
format: base64
551+
type: string
552+
description: The ZIP archive containing the application structure.
553+
"400":
554+
$ref: '#/components/responses/BadRequest'
555+
"404":
556+
$ref: '#/components/responses/NotFound'
557+
"500":
558+
$ref: '#/components/responses/InternalServerError'
559+
summary: Exports an app as ZIP
560+
tags:
561+
- Application
525562
/v1/apps/{id}/logs:
526563
get:
527564
description: Obtain a ServerSentEvnt stream of logs. It is possible to apply
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
package handlers
2+
3+
import (
4+
"log/slog"
5+
"net/http"
6+
"strconv"
7+
8+
"github.com/arduino/arduino-app-cli/internal/api/models"
9+
"github.com/arduino/arduino-app-cli/internal/orchestrator"
10+
"github.com/arduino/arduino-app-cli/internal/orchestrator/app"
11+
"github.com/arduino/arduino-app-cli/internal/orchestrator/config"
12+
"github.com/arduino/arduino-app-cli/internal/render"
13+
)
14+
15+
func HandleAppExport(
16+
idProvider *app.IDProvider,
17+
cfg config.Configuration,
18+
) http.HandlerFunc {
19+
return func(w http.ResponseWriter, r *http.Request) {
20+
id, err := idProvider.IDFromBase64(r.PathValue("appID"))
21+
if err != nil {
22+
render.EncodeResponse(w, http.StatusPreconditionFailed, models.ErrorResponse{Details: "invalid id"})
23+
return
24+
}
25+
app, err := app.Load(id.ToPath())
26+
if err != nil {
27+
slog.Error("Unable to parse the app.yaml", slog.String("error", err.Error()), slog.String("path", id.String()))
28+
render.EncodeResponse(w, http.StatusInternalServerError, models.ErrorResponse{Details: "unable to find the app"})
29+
return
30+
}
31+
32+
includeData := false
33+
if val := r.URL.Query().Get("include_data"); val != "" {
34+
var err error
35+
includeData, err = strconv.ParseBool(val)
36+
if err != nil {
37+
render.EncodeResponse(w, http.StatusBadRequest, models.ErrorResponse{
38+
Details: "The parameter 'include_data' must be a boolean.",
39+
})
40+
return
41+
}
42+
}
43+
44+
zipBytes, fileName, err := orchestrator.ExportApp(r.Context(), app, includeData)
45+
if err != nil {
46+
slog.Error("failed to export app", slog.String("app_id", id.String()), slog.String("error", err.Error()))
47+
render.EncodeResponse(w, http.StatusInternalServerError, models.ErrorResponse{
48+
Details: "Failed to generate archive.",
49+
})
50+
return
51+
}
52+
53+
render.EncodeZipResponse(w, http.StatusOK, zipBytes, fileName)
54+
}
55+
}

internal/e2e/client/client.gen.go

Lines changed: 37 additions & 24 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

internal/orchestrator/orchestrator.go

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
package orchestrator
1717

1818
import (
19+
"archive/zip"
1920
"bytes"
2021
"context"
2122
"fmt"
@@ -54,6 +55,7 @@ import (
5455
var (
5556
ErrAppAlreadyExists = fmt.Errorf("app already exists")
5657
ErrAppDoesntExists = fmt.Errorf("app doesn't exist")
58+
ErrAppNotFound = fmt.Errorf("app not found")
5759
)
5860

5961
const (
@@ -905,6 +907,96 @@ func DeleteApp(ctx context.Context, dockerClient command.Cli, app app.ArduinoApp
905907
return app.FullPath.RemoveAll()
906908
}
907909

910+
func ExportApp(
911+
ctx context.Context,
912+
app app.ArduinoApp,
913+
includeData bool,
914+
) ([]byte, string, error) {
915+
916+
appName := sanitizeFilename(app.Name)
917+
if appName == "" {
918+
appName = "app-export"
919+
}
920+
filename := fmt.Sprintf("%s.zip", appName)
921+
zipBytes, err := zipAppToBuffer(app.FullPath.String(), includeData)
922+
if err != nil {
923+
return nil, "", fmt.Errorf("failed to create zip archive: %w", err)
924+
}
925+
return zipBytes, filename, nil
926+
}
927+
928+
func zipAppToBuffer(sourcePath string, includeData bool) ([]byte, error) {
929+
buf := new(bytes.Buffer)
930+
zipWriter := zip.NewWriter(buf)
931+
932+
err := filepath.Walk(sourcePath, func(path string, info os.FileInfo, err error) error {
933+
if err != nil {
934+
return err
935+
}
936+
relPath, err := filepath.Rel(sourcePath, path)
937+
if err != nil {
938+
return err
939+
}
940+
if relPath == "." {
941+
return nil
942+
}
943+
if strings.HasPrefix(relPath, ".cache") {
944+
if info.IsDir() {
945+
return filepath.SkipDir
946+
}
947+
return nil
948+
}
949+
if !includeData && strings.HasPrefix(relPath, "data") {
950+
if info.IsDir() {
951+
return filepath.SkipDir
952+
}
953+
return nil
954+
}
955+
header, err := zip.FileInfoHeader(info)
956+
if err != nil {
957+
return err
958+
}
959+
960+
header.Name = filepath.ToSlash(relPath)
961+
if info.IsDir() {
962+
header.Name += "/"
963+
} else {
964+
header.Method = zip.Deflate
965+
}
966+
writer, err := zipWriter.CreateHeader(header)
967+
if err != nil {
968+
return err
969+
}
970+
971+
if info.IsDir() {
972+
return nil
973+
}
974+
file, err := os.Open(path)
975+
if err != nil {
976+
return err
977+
}
978+
defer file.Close()
979+
_, err = io.Copy(writer, file)
980+
return err
981+
})
982+
983+
if err != nil {
984+
zipWriter.Close()
985+
return nil, err
986+
}
987+
988+
if err := zipWriter.Close(); err != nil {
989+
return nil, err
990+
}
991+
992+
return buf.Bytes(), nil
993+
}
994+
995+
func sanitizeFilename(name string) string {
996+
safe := strings.ReplaceAll(name, " ", "-")
997+
return strings.ToLower(safe)
998+
}
999+
9081000
const defaultAppFileName = "default.app"
9091001

9101002
func SetDefaultApp(app *app.ArduinoApp, cfg config.Configuration) error {

0 commit comments

Comments
 (0)