Skip to content
Merged
Show file tree
Hide file tree
Changes from 16 commits
Commits
Show all changes
43 commits
Select commit Hold shift + click to select a range
922ab70
implement export app
mirkoCrobu Dec 23, 2025
4d1d6c3
add test e2e export
mirkoCrobu Dec 24, 2025
c78331f
add import endpoint
mirkoCrobu Dec 29, 2025
d636791
add test e2e for import endpoint
mirkoCrobu Dec 29, 2025
aa7d42a
refactoring and finalize implementation
mirkoCrobu Dec 30, 2025
96ec574
fix export app
mirkoCrobu Jan 12, 2026
8d33b8a
fix export docs and e2e test
mirkoCrobu Jan 12, 2026
dc4bbf5
refactorign import
mirkoCrobu Jan 12, 2026
c67dc74
fix import
mirkoCrobu Jan 12, 2026
86d675a
implement copilot code review
mirkoCrobu Jan 12, 2026
faf78a0
fix e2e tests
mirkoCrobu Jan 12, 2026
8c5060b
implement atomic swap for import app
mirkoCrobu Jan 12, 2026
9b3911f
implement security checks
mirkoCrobu Jan 12, 2026
d297c1c
update go mod
mirkoCrobu Jan 12, 2026
8653a4a
rename function
mirkoCrobu Jan 13, 2026
db1a60e
increare export performance
mirkoCrobu Jan 13, 2026
fb8ebe6
refactoring handler
mirkoCrobu Jan 13, 2026
f087ca4
add unit tests
mirkoCrobu Jan 13, 2026
7a10bd2
update doc openapi and e2e test for import endpoint
mirkoCrobu Jan 13, 2026
1852e2c
fix format bae64 to binary in openapi generated file
mirkoCrobu Jan 13, 2026
488e861
improve import with new specification
mirkoCrobu Jan 14, 2026
e758c09
code review fixes
mirkoCrobu Jan 14, 2026
70ad2dc
add include_data param for export_app endpoint
mirkoCrobu Jan 15, 2026
10f8cc2
fix test export api
mirkoCrobu Jan 15, 2026
e03b5c1
code review fix
mirkoCrobu Jan 15, 2026
61af008
use path pkg func to create tmp dir
mirkoCrobu Jan 15, 2026
9037f3c
using rand text instead of uuid
mirkoCrobu Jan 15, 2026
c548bd3
add filter to avoid listing hidden tmp apps
mirkoCrobu Jan 15, 2026
485a828
update dependencies
mirkoCrobu Jan 15, 2026
6e4baf3
revew fixes
mirkoCrobu Jan 15, 2026
70a6827
add comment
mirkoCrobu Jan 15, 2026
9b9a0d1
move ZipAppToBuffer to app pkg
mirkoCrobu Jan 15, 2026
07d1b17
move ReadAppDescriptorFromZip to app pkg
mirkoCrobu Jan 15, 2026
f51d9ad
move ValidateAppZipContent to app pkg
mirkoCrobu Jan 15, 2026
a1e8ae9
ignore tmp apps into list app and tests
mirkoCrobu Jan 15, 2026
f3203c5
fix error message
mirkoCrobu Jan 16, 2026
fcdb785
fome exporto to app package
mirkoCrobu Jan 16, 2026
504f197
move import/export to orchestrator pkg
mirkoCrobu Jan 16, 2026
40b05bb
fix app structure validation
mirkoCrobu Jan 16, 2026
e96234b
set better error return value
mirkoCrobu Jan 16, 2026
ad7cf12
set better error return value
mirkoCrobu Jan 16, 2026
d30aec0
make lint happy
mirkoCrobu Jan 16, 2026
f88de34
fix error messages
mirkoCrobu Jan 16, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions cmd/gendoc/docs.go
Original file line number Diff line number Diff line change
Expand Up @@ -562,6 +562,55 @@ Contains a JSON object with the details of an error.
{StatusCode: http.StatusInternalServerError, Reference: "#/components/responses/InternalServerError"},
},
},
{
OperationId: "importApp",
Method: http.MethodPost,
Path: "/v1/apps/import",
Parameters: nil,
Request: (*struct {
FolderName string `form:"folder_name" description:"The name of the folder where the app will be stored." validate:"required"`
File []byte `form:"file" description:"The ZIP archive containing the application structure. Must contain app.yaml and python folder." validate:"required"`
})(nil),
CustomSuccessResponse: &CustomResponseDef{
ContentType: "application/json",
StatusCode: http.StatusCreated,
DataStructure: struct {
ID string `json:"id" description:"The Base64 encoded identifier of the imported application."`
}{},
Description: "Application imported successfully.",
},
Description: "Imports a new application from a ZIP file. The system extracts the archive, validates the app.yaml manifest, sanitizes the name, and returns the ID in Base64.",
Summary: "Imports an app from ZIP",
Tags: []Tag{ApplicationTag},
PossibleErrors: []ErrorResponse{
{StatusCode: http.StatusBadRequest, Reference: "#/components/responses/BadRequest"},
{StatusCode: http.StatusConflict, Reference: "#/components/responses/Conflict"},
{StatusCode: http.StatusInternalServerError, Reference: "#/components/responses/InternalServerError"},
},
},
{
OperationId: "exportApp",
Method: http.MethodGet,
Path: "/v1/apps/{id}/export",
Request: (*struct {
ID string `path:"id" description:"application identifier."`
})(nil),
Parameters: nil,
CustomSuccessResponse: &CustomResponseDef{
ContentType: "application/zip",
DataStructure: []byte{},
Description: "The ZIP archive containing the application structure.",
StatusCode: http.StatusOK,
},
Description: "Exports the application folder structure as a ZIP file.",
Summary: "Exports an app as ZIP",
Tags: []Tag{ApplicationTag},
PossibleErrors: []ErrorResponse{
{StatusCode: http.StatusBadRequest, Reference: "#/components/responses/BadRequest"},
{StatusCode: http.StatusNotFound, Reference: "#/components/responses/NotFound"},
{StatusCode: http.StatusInternalServerError, Reference: "#/components/responses/InternalServerError"},
},
},
{
OperationId: "getAppEvents",
Method: http.MethodGet,
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ require (
github.com/gofrs/flock v0.12.1
github.com/google/go-cmp v0.7.0
github.com/google/renameio/v2 v2.0.0
github.com/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.0
github.com/gosimple/slug v1.15.0
github.com/jedib0t/go-pretty/v6 v6.6.8
Expand Down Expand Up @@ -148,7 +149,6 @@ require (
github.com/google/gnostic-models v0.6.8 // indirect
github.com/google/gofuzz v1.2.0 // indirect
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/mux v1.8.1 // indirect
github.com/gosimple/unidecode v1.0.1 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.1 // indirect
Expand Down
3 changes: 2 additions & 1 deletion internal/api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -74,14 +74,15 @@ func NewHTTPRouter(
mux.Handle("GET /v1/apps", handlers.HandleAppList(dockerClient, idProvider, cfg))
mux.Handle("POST /v1/apps", handlers.HandleAppCreate(idProvider, cfg))
mux.Handle("GET /v1/apps/events", handlers.HandlerAppStatus(dockerClient, idProvider, cfg))

mux.Handle("GET /v1/apps/{appID}", handlers.HandleAppDetails(dockerClient, bricksIndex, idProvider, cfg))
mux.Handle("PATCH /v1/apps/{appID}", handlers.HandleAppDetailsEdits(dockerClient, bricksIndex, idProvider, cfg))
mux.Handle("GET /v1/apps/{appID}/logs", handlers.HandleAppLogs(dockerClient, idProvider, staticStore))
mux.Handle("POST /v1/apps/{appID}/start", handlers.HandleAppStart(dockerClient, provisioner, modelsIndex, bricksIndex, idProvider, cfg, staticStore))
mux.Handle("POST /v1/apps/{appID}/stop", handlers.HandleAppStop(dockerClient, idProvider))
mux.Handle("POST /v1/apps/{appID}/clone", handlers.HandleAppClone(dockerClient, idProvider, cfg))
mux.Handle("DELETE /v1/apps/{appID}", handlers.HandleAppDelete(dockerClient, idProvider))
mux.Handle("GET /v1/apps/{appID}/export", handlers.HandleAppExport(idProvider, cfg))
mux.Handle("POST /v1/apps/import", handlers.HandleAppImport(cfg, idProvider))
Comment thread
mirkoCrobu marked this conversation as resolved.
Outdated
mux.Handle("GET /v1/apps/{appID}/exposed-ports", handlers.HandleAppPorts(bricksIndex, idProvider))
mux.Handle("PUT /v1/apps/{appID}/sketch/libraries/{libRef}", handlers.HandleSketchAddLibrary(idProvider))
mux.Handle("DELETE /v1/apps/{appID}/sketch/libraries/{libRef}", handlers.HandleSketchRemoveLibrary(idProvider))
Expand Down
85 changes: 85 additions & 0 deletions internal/api/docs/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -522,6 +522,35 @@ paths:
summary: Get application events
tags:
- Application
/v1/apps/{id}/export:
get:
description: Exports the application folder structure as a ZIP file.
operationId: exportApp
parameters:
- description: application identifier.
in: path
name: id
required: true
schema:
description: application identifier.
type: string
responses:
"200":
content:
application/zip:
schema:
format: base64
Comment thread
mirkoCrobu marked this conversation as resolved.
Outdated
type: string
description: The ZIP archive containing the application structure.
"400":
$ref: '#/components/responses/BadRequest'
"404":
$ref: '#/components/responses/NotFound'
"500":
$ref: '#/components/responses/InternalServerError'
summary: Exports an app as ZIP
tags:
- Application
/v1/apps/{id}/logs:
get:
description: Obtain a ServerSentEvnt stream of logs. It is possible to apply
Expand Down Expand Up @@ -673,6 +702,62 @@ paths:
summary: Get application events
tags:
- Application
/v1/apps/import:
post:
description: Imports a new application from a ZIP file. The system extracts
the archive, validates the app.yaml manifest, sanitizes the name, and returns
the ID in Base64.
operationId: importApp
parameters:
- description: The name of the folder where the app will be stored.
in: query
name: folder_name
schema:
description: The name of the folder where the app will be stored.
type: string
- description: The ZIP archive containing the application structure. Must contain
app.yaml and python folder.
in: query
name: file
schema:
description: The ZIP archive containing the application structure. Must
contain app.yaml and python folder.
format: base64
type: string
requestBody:
content:
application/x-www-form-urlencoded:
schema:
properties:
file:
description: The ZIP archive containing the application structure.
Must contain app.yaml and python folder.
format: base64
type: string
folder_name:
description: The name of the folder where the app will be stored.
type: string
type: object
responses:
"201":
content:
application/json:
schema:
properties:
id:
description: The Base64 encoded identifier of the imported application.
type: string
type: object
description: Application imported successfully.
"400":
$ref: '#/components/responses/BadRequest'
"409":
$ref: '#/components/responses/Conflict'
"500":
$ref: '#/components/responses/InternalServerError'
summary: Imports an app from ZIP
tags:
- Application
/v1/bricks:
get:
description: Returns all the existing bricks. Bricks that are ready to use are
Expand Down
48 changes: 48 additions & 0 deletions internal/api/handlers/app_export.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
package handlers
Comment thread
mirkoCrobu marked this conversation as resolved.

import (
"errors"
"log/slog"
"net/http"
"os"

"github.com/arduino/arduino-app-cli/internal/api/models"
"github.com/arduino/arduino-app-cli/internal/orchestrator"
"github.com/arduino/arduino-app-cli/internal/orchestrator/app"
"github.com/arduino/arduino-app-cli/internal/orchestrator/config"
"github.com/arduino/arduino-app-cli/internal/render"
)

func HandleAppExport(
idProvider *app.IDProvider,
cfg config.Configuration,
) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id, err := idProvider.IDFromBase64(r.PathValue("appID"))
if err != nil {
render.EncodeResponse(w, http.StatusPreconditionFailed, models.ErrorResponse{Details: "invalid id"})
Comment thread
mirkoCrobu marked this conversation as resolved.
Outdated
return
}
app, err := app.Load(id.ToPath())
if err != nil {
slog.Error("Unable to parse the app.yaml", slog.String("error", err.Error()), slog.String("path", id.String()))
Comment thread
mirkoCrobu marked this conversation as resolved.
Outdated
if errors.Is(err, os.ErrNotExist) {
render.EncodeResponse(w, http.StatusNotFound, models.ErrorResponse{Details: "unable to find the app"})
Comment thread
mirkoCrobu marked this conversation as resolved.
Outdated
} else {
render.EncodeResponse(w, http.StatusInternalServerError, models.ErrorResponse{Details: "unable to parse the app"})
Comment thread
mirkoCrobu marked this conversation as resolved.
Outdated
}
return
}

zipBytes, fileName, err := orchestrator.ExportAppZip(r.Context(), app)
if err != nil {
slog.Error("failed to export app", slog.String("app_id", id.String()), slog.String("error", err.Error()))
Comment thread
mirkoCrobu marked this conversation as resolved.
Outdated
render.EncodeResponse(w, http.StatusInternalServerError, models.ErrorResponse{
Details: "Failed to generate archive.",
Comment thread
mirkoCrobu marked this conversation as resolved.
Outdated
})
return
}

render.EncodeZipResponse(w, http.StatusOK, zipBytes, fileName)
}
}
88 changes: 88 additions & 0 deletions internal/api/handlers/app_import.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
// This file is part of arduino-app-cli.
//
// Copyright 2025 ARDUINO SA (http://www.arduino.cc/)
// ... (License header standard) ...

package handlers

import (
"errors"
"io"
"log/slog"
"net/http"
"os"
"strings"

"github.com/arduino/arduino-app-cli/internal/api/models"
"github.com/arduino/arduino-app-cli/internal/orchestrator"
"github.com/arduino/arduino-app-cli/internal/orchestrator/app"
"github.com/arduino/arduino-app-cli/internal/orchestrator/config"
"github.com/arduino/arduino-app-cli/internal/render"
)

type ImportResponse struct {
Comment thread
mirkoCrobu marked this conversation as resolved.
Outdated
ID string `json:"id"`
}

func HandleAppImport(
cfg config.Configuration,
idProvider *app.IDProvider,
) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
folderName := strings.TrimSpace(r.FormValue("folder_name"))
Comment thread
mirkoCrobu marked this conversation as resolved.
Outdated
Comment thread
mirkoCrobu marked this conversation as resolved.
Outdated
Comment thread
mirkoCrobu marked this conversation as resolved.
Outdated
if folderName == "" {
render.EncodeResponse(w, http.StatusBadRequest, models.ErrorResponse{Details: "missing required 'folder_name' parameter"})
return
}

file, _, err := r.FormFile("file")
if err != nil {
slog.Error("missing file parameter", slog.String("error", err.Error()))
Comment thread
mirkoCrobu marked this conversation as resolved.
Outdated
render.EncodeResponse(w, http.StatusBadRequest, models.ErrorResponse{Details: "missing required file parameter"})
return
}
defer file.Close()

tempFile, err := os.CreateTemp("", "app-import-*.zip")
if err != nil {
slog.Error("unable to create temp file", slog.String("error", err.Error()))
Comment thread
mirkoCrobu marked this conversation as resolved.
Outdated
render.EncodeResponse(w, http.StatusInternalServerError, models.ErrorResponse{Details: "internal server error"})
return
}
tempPath := tempFile.Name()
defer os.Remove(tempPath)

if _, err := io.Copy(tempFile, file); err != nil {
tempFile.Close()
slog.Error("unable to save upload to temp file", slog.String("error", err.Error()))
Comment thread
mirkoCrobu marked this conversation as resolved.
Outdated
render.EncodeResponse(w, http.StatusInternalServerError, models.ErrorResponse{Details: "failed to save uploaded file"})
return
}
tempFile.Close()

appID, err := orchestrator.ImportAppFromZip(cfg, tempPath, folderName, idProvider)
if err != nil {
handleImportError(w, err)
return
}

slog.Info("app imported successfully", slog.String("app_id", appID))
Comment thread
mirkoCrobu marked this conversation as resolved.
Outdated
render.EncodeResponse(w, http.StatusCreated, ImportResponse{ID: appID})
}
}

func handleImportError(w http.ResponseWriter, err error) {
slog.Error("import failed", slog.String("error", err.Error()))

if errors.Is(err, orchestrator.ErrAppAlreadyExists) {
render.EncodeResponse(w, http.StatusConflict, models.ErrorResponse{Details: err.Error()})
return
}
if errors.Is(err, orchestrator.ErrBadRequest) ||
strings.Contains(err.Error(), "not a valid zip file") {
render.EncodeResponse(w, http.StatusBadRequest, models.ErrorResponse{Details: err.Error()})
return
}

render.EncodeResponse(w, http.StatusInternalServerError, models.ErrorResponse{Details: "failed to process the archive: " + err.Error()})
}
Comment thread
mirkoCrobu marked this conversation as resolved.
Outdated
Loading