diff --git a/.github/workflows/test-update.yml b/.github/workflows/test-update.yml new file mode 100644 index 00000000..ae90f7a9 --- /dev/null +++ b/.github/workflows/test-update.yml @@ -0,0 +1,31 @@ +name: Build & Update with Arduino CLI + +on: + push: + branches: + - main + - test_package_update + workflow_dispatch: + +permissions: + contents: read + + +jobs: + build-and-update: + runs-on: ubuntu-22.04 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + + - name: Run dep package update test + env: + GH_TOKEN: ${{ secrets.ARDUINOBOT_TOKEN }} + run: | + go test -v ./internal/testtools/test_deb_update -- --arch amd64 \ No newline at end of file diff --git a/Taskfile.yml b/Taskfile.yml index 06e54550..0b39b75a 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -12,6 +12,9 @@ vars: RUNNER_VERSION: "0.5.0" VERSION: # if version is not passed we hack the semver by encoding the commit as pre-release sh: echo "${VERSION:-0.0.0-$(git rev-parse --short HEAD)}" + NEW_PACKAGE: + sh: ls -1 ./build/arduino-app-cli_*.deb 2>/dev/null | head -n 1 + GITHUB_TOKEN_FILE: ./github_token.txt tasks: init: @@ -102,9 +105,10 @@ tasks: deps: - build-deb:clone-examples cmds: - - docker build --build-arg BINARY_NAME=arduino-app-cli --build-arg DEB_NAME=arduino-app-cli --build-arg VERSION={{ .VERSION }} --build-arg ARCH={{ .ARCH }} --build-arg RELEASE={{ .RELEASE }} --output=./build -f debian/Dockerfile . + - docker build --build-arg BINARY_NAME=arduino-app-cli --build-arg DEB_NAME=arduino-app-cli --build-arg VERSION={{ .VERSION }} --build-arg ARCH={{ .ARCH }} --build-arg RELEASE={{ .RELEASE }} --output={{ .OUTPUT }} -f debian/Dockerfile . vars: ARCH: '{{.ARCH | default "arm64"}}' + OUTPUT: '{{.OUTPUT | default "./build"}}' build-deb:clone-examples: desc: "Clones the examples repo directly into the debian structure" @@ -123,6 +127,44 @@ tasks: echo "Examples successfully cloned." silent: false + build-image: + desc: "Builds the mock-repo Docker image (requires GITHUB_TOKEN_FILE)" + deps: [build-deb] + vars: + PKG_PATH: "{{.NEW_PACKAGE}}" + cmds: + # --- MODIFIED --- + # Check for both the package and the token file + - | + if [ ! -f "{{.GITHUB_TOKEN_FILE}}" ]; then + echo "Error: GitHub token file not found at {{.GITHUB_TOKEN_FILE}}" + echo "Please create this file and add your GitHub PAT to it." + exit 1 + fi + - | + echo "Using package: {{.PKG_PATH}}" + echo "Using GitHub token from: {{.GITHUB_TOKEN_FILE}}" + + # Enable BuildKit and pass the secret + DOCKER_BUILDKIT=1 docker build \ + --secret id=github_token,src={{.GITHUB_TOKEN_FILE}} \ + --build-arg NEW_PACKAGE_PATH={{.PKG_PATH}} \ + -t newdeb \ + -f test.Dockerfile . + status: + - '[[ -f "{{.PKG_PATH}}" ]]' + - '[[ -f "{{.DOCKERFILE_NAME}}" ]]' + # Re-build if token file changes + - '[[ -f "{{.GITHUB_TOKEN_FILE}}" ]]' + + test-deb: + desc: Test the debian package locally + deps: + - build-deb + cmds: + - docker build --no-cache -t mock-apt-repo -f test.Dockerfile . + - docker run --rm -it --privileged -v /sys/fs/cgroup:/sys/fs/cgroup:ro --name apt-test-update mock-apt-repo + arduino-app-cli:build:local: desc: "Build the arduino-app-cli locally" cmds: diff --git a/internal/testtools/package_update.go b/internal/testtools/package_update.go new file mode 100644 index 00000000..f0fd39d7 --- /dev/null +++ b/internal/testtools/package_update.go @@ -0,0 +1,38 @@ +// This file is part of arduino-app-cli. +// +// Copyright 2025 ARDUINO SA (http://www.arduino.cc/) +// +// This software is released under the GNU General Public License version 3, +// which covers the main part of arduino-app-cli. +// The terms of this license can be found at: +// https://www.gnu.org/licenses/gpl-3.0.en.html +// +// You can be released from the requirements of the above licenses by purchasing +// a commercial license. Buying such a license is mandatory if you want to +// modify or otherwise use the software for commercial activities involving the +// Arduino software without disclosing the source code of your own applications. +// To purchase a commercial license, send an email to license@arduino.cc. +package testtools + +import ( + "os" + "os/exec" + "runtime" + "testing" +) + +func DockerBuild(t *testing.T) { + + if runtime.GOOS != "linux" && os.Getenv("CI") != "" { + t.Skip("Skipping tests in CI that requires docker on non-Linux systems") + } + t.Helper() + + cmd := exec.Command("docker", "build", "-t", "adbd", "-f", "test.Dockerfile", ".") + cmd.Dir = getBaseProjectPath(t) + err := cmd.Run() + if err != nil { + t.Fatalf("failed to build adb daemon: %v", err) + } + +} diff --git a/internal/testtools/test_deb_update/deb_test.go b/internal/testtools/test_deb_update/deb_test.go new file mode 100644 index 00000000..05465f69 --- /dev/null +++ b/internal/testtools/test_deb_update/deb_test.go @@ -0,0 +1,145 @@ +package testtools + +import ( + "context" + "flag" + "fmt" + "log" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +var arch = flag.String("arch", "amd64", "target architecture") + +func TestStableToUnstable(t *testing.T) { + fmt.Printf("***** ARCH %s ***** \n", *arch) + tagAppCli := FetchDebPackage(t, "arduino-app-cli", "latest", *arch) + FetchDebPackage(t, "arduino-router", "latest", *arch) + majorTag := majorTag(t, tagAppCli) + _ = minorTag(t, tagAppCli) + + fmt.Printf("Updating from stable version %s to unstable version %s \n", tagAppCli, majorTag) + fmt.Printf("Building local deb version %s \n", majorTag) + buildDebVersion(t, majorTag, *arch) + // fmt.Printf("Check folder structure and deb downloaded\n") + // ls(t) + // fmt.Println("**** BUILD docker image *****") + // buildDockerImage(t, "test.Dockerfile", "apt-test-update-image", *arch) + // fmt.Println("**** RUN docker image *****") + // runDockerContainer(t, "apt-test-update", "apt-test-update-image") + // preUpdateVersion := runDockerSystemVersion(t, "apt-test-update") + // runDockerSystemUpdate(t, "apt-test-update") + // postUpdateVersion := runDockerSystemVersion(t, "apt-test-update") + // runDockerCleanUp(t, "apt-test-update") + // require.Equal(t, preUpdateVersion, "Arduino App CLI "+tagAppCli+"\n") + // require.Equal(t, postUpdateVersion, "Arduino App CLI "+majorTag+"\n") +} + +// func TestClientUpdateStU(t *testing.T) { + +// fmt.Printf("***** ARCH %s ***** \n", *arch) +// tagAppCli := FetchDebPackage(t, "arduino-app-cli", "latest", *arch) +// FetchDebPackage(t, "arduino-router", "latest", *arch) +// majorTag := majorTag(t, tagAppCli) + +// fmt.Println("**** RUN docker image *****") +// runDockerContainer(t, "apt-test-update", "apt-test-update-image") +// preUpdateVersion := runDockerSystemVersion(t, "apt-test-update") + +// runDockerDaemon(t, "apt-test-update") +// WaitForPort(t, "127.0.0.1", 8800, 5*time.Second) + +// status := putUpdateRequest(t, "http://127.0.0.1:8800/v1/system/update/apply") +// fmt.Printf("Response status: %s\n", status) + +// itr := NewSSEClient(context.Background(), "GET", "http://localhost:8800/v1/system/update/events") + +// for event, err := range itr { +// if err != nil { +// log.Printf("Error receiving SSE event: %v", err) +// } +// fmt.Printf("Received event: ID=%s, Event=%s, Data=%s\n", event.ID, event.Event, string(event.Data)) +// if string(event.Data) == "Download complete" { +// fmt.Println("✅ Download complete — exiting successfully.") +// } +// } + +// postUpdateVersion := runDockerSystemVersion(t, "apt-test-update") + +// require.Equal(t, preUpdateVersion, "Arduino App CLI "+tagAppCli+"\n") +// require.Equal(t, postUpdateVersion, "Arduino App CLI "+majorTag+"\n") +// runDockerCleanUp(t, "apt-test-update") + +// } + +func TestUnstableToStable(t *testing.T) { + + t.Run("CLI Update Testing", func(t *testing.T) { + tagAppCli := FetchDebPackage(t, "arduino-app-cli", "latest", *arch) + FetchDebPackage(t, "arduino-router", "latest", *arch) + minorTag := minorTag(t, tagAppCli) + //Move the stable package to the build (unstable) folder + moveDeb(t, "build/stable", "build/", "arduino-app-cli", tagAppCli, *arch) + fmt.Printf("Updating from unstable version %s to stable version %s \n", minorTag, tagAppCli) + fmt.Printf("Building local deb version %s \n", minorTag) + //Build unstable with a minor tag w.r.t stable + buildDebVersion(t, minorTag, *arch) + //Move the unstable package to the stable folder + moveDeb(t, "build/", "build/stable", "arduino-app-cli", minorTag, *arch) + fmt.Printf("Check folder structure and deb downloaded\n") + ls(t) + fmt.Println("**** BUILD docker image *****") + buildDockerImage(t, "test.Dockerfile", "test-apt-update-unstable-image", *arch) + fmt.Println("**** RUN docker image *****") + runDockerContainer(t, "apt-test-update-unstable", "test-apt-update-unstable-image") + preUpdateVersion := runDockerSystemVersion(t, "apt-test-update-unstable") + runDockerSystemUpdate(t, "apt-test-update-unstable") + postUpdateVersion := runDockerSystemVersion(t, "apt-test-update-unstable") + runDockerCleanUp(t, "apt-test-update-unstable") + require.Equal(t, preUpdateVersion, "Arduino App CLI "+minorTag+"\n") + require.Equal(t, postUpdateVersion, "Arduino App CLI "+tagAppCli+"\n") + }) + + t.Run("Client Daemon Request Testing", func(t *testing.T) { + tagAppCli := FetchDebPackage(t, "arduino-app-cli", "latest", *arch) + FetchDebPackage(t, "arduino-router", "latest", *arch) + minorTag := minorTag(t, tagAppCli) + //Move the stable package to the build (unstable) folder + moveDeb(t, "build/stable", "build/", "arduino-app-cli", tagAppCli, *arch) + fmt.Printf("Updating from unstable version %s to stable version %s \n", minorTag, tagAppCli) + fmt.Printf("Building local deb version %s \n", minorTag) + //Build unstable with a minor tag w.r.t stable + buildDebVersion(t, minorTag, *arch) + //Move the unstable package to the stable folder + moveDeb(t, "build/", "build/stable", "arduino-app-cli", minorTag, *arch) + fmt.Printf("Check folder structure and deb downloaded\n") + fmt.Println("**** RUN docker image *****") + runDockerContainer(t, "apt-test-update-unstable", "test-apt-update-unstable-image") + preUpdateVersion := runDockerSystemVersion(t, "apt-test-update-unstable") + runDockerDaemon(t, "apt-test-update-unstable ") + WaitForPort(t, "127.0.0.1", 8800, 5*time.Second) + status := putUpdateRequest(t, "http://127.0.0.1:8800/v1/system/update/apply") + fmt.Printf("Response status: %s\n", status) + + itr := NewSSEClient(context.Background(), "GET", "http://localhost:8800/v1/system/update/events") + + for event, err := range itr { + if err != nil { + log.Printf("Error receiving SSE event: %v", err) + } + fmt.Printf("Received event: ID=%s, Event=%s, Data=%s\n", event.ID, event.Event, string(event.Data)) + if string(event.Data) == "Download complete" { + fmt.Println("✅ Download complete — exiting successfully.") + } + } + + postUpdateVersion := runDockerSystemVersion(t, "apt-test-update") + runDockerCleanUp(t, "apt-test-update-unstable") + require.Equal(t, preUpdateVersion, "Arduino App CLI "+tagAppCli+"\n") + require.Equal(t, postUpdateVersion, "Arduino App CLI "+minorTag+"\n") + + }) + +} diff --git a/internal/testtools/test_deb_update/helpers.go b/internal/testtools/test_deb_update/helpers.go new file mode 100644 index 00000000..2400f3a7 --- /dev/null +++ b/internal/testtools/test_deb_update/helpers.go @@ -0,0 +1,386 @@ +package testtools + +import ( + "bufio" + "bytes" + "context" + "fmt" + "io" + "iter" + "log" + "net" + "net/http" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "testing" + "time" +) + +func FetchDebPackage(t *testing.T, repo, version, arch string) string { + t.Helper() + + cmd := exec.Command( + "gh", "release", "list", + "--repo", "github.com/arduino/"+repo, + "--exclude-pre-releases", + "--limit", "1", + ) + + output, err := cmd.CombinedOutput() + if err != nil { + log.Fatalf("command failed: %v\nOutput: %s", err, output) + } + + fmt.Println(string(output)) + + fields := strings.Fields(string(output)) + if len(fields) == 0 { + log.Fatal("could not parse tag from gh release list output") + } + tag := fields[0] + tagPath := strings.TrimPrefix(tag, "v") + + debFile := fmt.Sprintf("build/stable/%s_%s-1_%s.deb", repo, tagPath, arch) + fmt.Println(debFile) + if _, err := os.Stat(debFile); err == nil { + fmt.Printf("✅ %s already exists, skipping download.\n", debFile) + return tag + } + fmt.Println("Detected tag:", tag) + cmd2 := exec.Command( + "gh", "release", "download", + tag, + "--repo", "github.com/arduino/"+repo, + "--pattern", "*", + "--dir", "./build/stable", + ) + + out, err := cmd2.CombinedOutput() + if err != nil { + log.Fatalf("download failed: %v\nOutput: %s", err, out) + } + + return tag + +} + +func buildDebVersion(t *testing.T, tagVersion, arch string) { + t.Helper() + cwd, err := os.Getwd() + if err != nil { + panic(err) + } + outputDir := filepath.Join(cwd, "build") + + cmd := exec.Command( + "go", "tool", "task", "build-deb", + fmt.Sprintf("VERSION=%s", tagVersion), + fmt.Sprintf("ARCH=%s", arch), + fmt.Sprintf("OUTPUT=%s", outputDir), + ) + + if err := cmd.Run(); err != nil { + log.Fatalf("failed to run build command: %v", err) + } +} + +func majorTag(t *testing.T, tag string) string { + t.Helper() + + parts := strings.Split(tag, ".") + last := parts[len(parts)-1] + + lastNum, _ := strconv.Atoi(strings.TrimPrefix(last, "v")) + lastNum++ + + parts[len(parts)-1] = strconv.Itoa(lastNum) + newTag := strings.Join(parts, ".") + + return newTag +} + +func minorTag(t *testing.T, tag string) string { + t.Helper() + + parts := strings.Split(tag, ".") + last := parts[len(parts)-1] + + lastNum, _ := strconv.Atoi(strings.TrimPrefix(last, "v")) + if lastNum > 0 { + lastNum-- + } + + parts[len(parts)-1] = strconv.Itoa(lastNum) + newTag := strings.Join(parts, ".") + + if !strings.HasPrefix(newTag, "v") { + newTag = "v" + newTag + } + return newTag +} + +func buildDockerImage(t *testing.T, dockerfile, name, arch string) { + t.Helper() + + cmd := exec.Command("docker", "build", "--build-arg", "ARCH="+arch, "-t", name, "-f", dockerfile, ".") + // Capture both stdout and stderr + var out bytes.Buffer + var stderr bytes.Buffer + cmd.Stdout = &out + cmd.Stderr = &stderr + + err := cmd.Run() + + if err != nil { + fmt.Printf("❌ Docker build failed: %v\n", err) + fmt.Printf("---- STDERR ----\n%s\n", stderr.String()) + fmt.Printf("---- STDOUT ----\n%s\n", out.String()) + return + } + + fmt.Println("✅ Docker build succeeded!") + fmt.Println(out.String()) + +} + +func runDockerContainer(t *testing.T, containerName string, containerImageName string) { + t.Helper() + + cmd := exec.Command( + "docker", "run", "--rm", "-d", + "-p", "8800:8800", + "--privileged", + "--cgroupns=host", + "--network", "host", + "-v", "/sys/fs/cgroup:/sys/fs/cgroup:rw", + "-v", "/var/run/docker.sock:/var/run/docker.sock", + "-e", "DOCKER_HOST=unix:///var/run/docker.sock", + "--name", containerName, + containerImageName, + ) + + if err := cmd.Run(); err != nil { + t.Fatalf("failed to run container: %v", err) + } + +} + +func runDockerSystemVersion(t *testing.T, containerName string) string { + t.Helper() + + cmd := exec.Command( + "docker", "exec", + "--user", "arduino", + containerName, + "arduino-app-cli", "version", + ) + + output, err := cmd.CombinedOutput() + if err != nil { + log.Fatalf("command failed: %v\nOutput: %s", err, output) + } + + return string(output) + +} + +func runDockerSystemUpdate(t *testing.T, containerName string) { + t.Helper() + var buf bytes.Buffer + + cmd := exec.Command( + "docker", "exec", + containerName, + "sh", "-lc", + `su - arduino -c "yes | arduino-app-cli system update"`, + ) + + cmd.Stdout = io.MultiWriter(os.Stdout, &buf) + + if err := cmd.Run(); err != nil { + fmt.Fprintf(os.Stderr, "Error running system update: %v\n", err) + os.Exit(1) + } + +} + +func runDockerDaemon(t *testing.T, containerName string) { + t.Helper() + + cmd := exec.Command( + "docker", "exec", + "-d", + "--user", "arduino", + containerName, + "systemctl", "start", "arduino-app-cli", + ) + output, err := cmd.CombinedOutput() + if err != nil { + log.Fatalf("command failed: %v\n Output: %s", err, output) + } + + fmt.Printf("Daemon started: %s\n", output) + +} + +func runDockerCleanUp(t *testing.T, containerName string) { + t.Helper() + + cleanupCmd := exec.Command("docker", "rm", "-f", containerName) + + fmt.Println("🧹 Removing Docker container " + containerName) + if err := cleanupCmd.Run(); err != nil { + fmt.Printf("⚠️ Warning: could not remove container (might not exist): %v\n", err) + } + +} + +func moveDeb(t *testing.T, startDir, targetDir, repo string, tagVersion string, arch string) { + t.Helper() + tagPath := strings.TrimPrefix(tagVersion, "v") + + debFile := fmt.Sprintf("%s/%s_%s-1_%s.deb", startDir, repo, tagPath, arch) + + moveCmd := exec.Command("cp", debFile, targetDir) + + fmt.Printf("📦 Moving %s → %s\n", debFile, targetDir) + if err := moveCmd.Run(); err != nil { + panic(fmt.Errorf("failed to move deb file: %w", err)) + } + + rm(t, debFile) +} + +func ls(t *testing.T) { + t.Helper() + cwd, err := os.Getwd() + if err != nil { + fmt.Println("Error getting working directory:", err) + return + } + + fmt.Println("Current directory:", cwd) + fmt.Println("Listing all files and folders recursively:") + + // Walk through all files and subdirectories + err = filepath.Walk(cwd, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + fmt.Println(path) + return nil + }) + +} + +func rm(t *testing.T, pathFile string) { + t.Helper() + removeCmd := exec.Command("rm", pathFile) + + err := removeCmd.Run() + if err != nil { + log.Fatalf("Failed to remove file: %v", err) + } + + fmt.Printf("📦 Removed %s\n", pathFile) + +} + +func putUpdateRequest(t *testing.T, url string) string { + + t.Helper() + + req, err := http.NewRequest(http.MethodPut, url, nil) + if err != nil { + log.Fatalf("Error creating request: %v", err) + } + + req.Header.Set("Content-Type", "application/json") + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + log.Fatalf("Error sending request: %v", err) + } + defer resp.Body.Close() + + fmt.Printf("Response status: %s\n", resp.Status) + + // Check status code + return resp.Status +} +func NewSSEClient(ctx context.Context, method, url string) iter.Seq2[Event, error] { + return func(yield func(Event, error) bool) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + _ = yield(Event{}, err) + return + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + _ = yield(Event{}, err) + return + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + _ = yield(Event{}, fmt.Errorf("got response status code %d", resp.StatusCode)) + return + } + + reader := bufio.NewReader(resp.Body) + + evt := Event{} + for { + line, err := reader.ReadString('\n') + if err != nil { + _ = yield(Event{}, err) + return + } + switch { + case strings.HasPrefix(line, "data:"): + evt.Data = []byte(strings.TrimSpace(strings.TrimPrefix(line, "data:"))) + case strings.HasPrefix(line, "event:"): + evt.Event = strings.TrimSpace(strings.TrimPrefix(line, "event:")) + case strings.HasPrefix(line, "id:"): + evt.ID = strings.TrimSpace(strings.TrimPrefix(line, "id:")) + case strings.HasPrefix(line, "\n"): + if !yield(evt, nil) { + return + } + evt = Event{} + default: + _ = yield(Event{}, fmt.Errorf("unknown line: '%s'", line)) + return + } + } + } +} + +type Event struct { + ID string + Event string + Data []byte // json +} + +// WaitForPort waits until a TCP port is open or fails after timeout. +func WaitForPort(t *testing.T, host string, port int, timeout time.Duration) { + t.Helper() + addr := fmt.Sprintf("%s:%d", host, port) + + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + conn, err := net.DialTimeout("tcp", addr, 500*time.Millisecond) + if err == nil { + _ = conn.Close() + t.Logf("Server is up on %s", addr) + return + } + time.Sleep(200 * time.Millisecond) + } + t.Fatalf("Server at %s did not start within %v", addr, timeout) +} diff --git a/internal/testtools/test_deb_update/test.Dockerfile b/internal/testtools/test_deb_update/test.Dockerfile new file mode 100644 index 00000000..578e71c0 --- /dev/null +++ b/internal/testtools/test_deb_update/test.Dockerfile @@ -0,0 +1,33 @@ +FROM debian:trixie + +RUN apt update && \ + apt install -y systemd systemd-sysv dbus \ + sudo docker.io ca-certificates curl gnupg \ + dpkg-dev apt-utils adduser gzip && \ + rm -rf /var/lib/apt/lists/* + +ARG ARCH=amd64 + +COPY build/stable/arduino-app-cli*_${ARCH}.deb /tmp/stable.deb +COPY build/arduino-app-cli*_${ARCH}.deb /tmp/unstable.deb +COPY build/stable/arduino-router*_${ARCH}.deb /tmp/router.deb + +RUN apt update && apt install -y /tmp/stable.deb /tmp/router.deb \ + && rm /tmp/stable.deb /tmp/router.deb \ + && mkdir -p /var/www/html/myrepo/dists/trixie/main/binary-${ARCH} \ + && mv /tmp/unstable.deb /var/www/html/myrepo/dists/trixie/main/binary-${ARCH}/ + +WORKDIR /var/www/html/myrepo +RUN dpkg-scanpackages dists/trixie/main/binary-${ARCH} /dev/null | gzip -9c > dists/trixie/main/binary-${ARCH}/Packages.gz +WORKDIR / + +RUN usermod -s /bin/bash arduino || true +RUN mkdir -p /home/arduino && chown -R arduino:arduino /home/arduino +RUN usermod -aG docker arduino + +RUN echo "deb [trusted=yes arch=${ARCH}] file:/var/www/html/myrepo trixie main" \ + > /etc/apt/sources.list.d/my-mock-repo.list + +EXPOSE 8800 +# CMD: systemd must be PID 1 +CMD ["/sbin/init"]