Skip to content

feat: add suppor for Windows OS to Device Agent Installer #400

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 6 commits into
base: feat-device-linux-installer
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
8 changes: 6 additions & 2 deletions installer/go/build.sh
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,17 @@ echo "Building installers for FlowFuse Device Agent v$VERSION"
echo "Building Linux (amd64) installer..."
GOOS=linux GOARCH=amd64 go build -ldflags "-X main.version=$VERSION" -o build/linux/flowfuse-device-installer-linux-amd64 main.go

# # Build Linux (arm64)
# Build Linux (arm64)
echo "Building Linux (arm64) installer..."
GOOS=linux GOARCH=arm64 go build -ldflags "-X main.version=$VERSION" -o build/linux/flowfuse-device-installer-linux-arm64 main.go

# # Build Linux (arm) - for Raspberry Pi
# Build Linux (arm) - for Raspberry Pi
echo "Building Linux (arm) installer..."
GOOS=linux GOARCH=arm go build -ldflags "-X main.version=$VERSION" -o build/linux/flowfuse-device-installer-linux-arm main.go

# Build Windows (amd64)
echo "Building Windows (amd64) installer..."
GOOS=windows GOARCH=amd64 go build -ldflags "-X main.version=$VERSION" -o build/windows/flowfuse-device-installer-windows-amd64.exe main.go

echo "All builds completed!"
echo "Installers available in the build/ directory"
37 changes: 34 additions & 3 deletions installer/go/cmd/install.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,28 @@ import (
"github.com/flowfuse/device-agent-installer/pkg/utils"
)

// Install performs the installation of Node.js, the device agent, and sets up the service
// Install performs the complete installation of the FlowFuse Device Agent.
//
// The function performs the following steps:
// 1. Checks if the process has sufficient permissions
// 2. Creates a working directory for the installation
// 3. Ensures Node.js is installed at the required version
// 4. Installs the Device Agent npm package
// 5. Configures the Device Agent with the provided URL and one-time code
// 6. Sets up the Device Agent to run as a system service
// 7. Saves the installation configuration
//
// Parameters:
// - nodeVersion: The version of Node.js to install or use
// - agentVersion: The version of the FlowFuse Device Agent to install
// - installerDir: The directory where the installer files are located
// - url: The URL of the FlowFuse instance to connect to
// - otc: The one-time code (OTC) used for device registration
//
// Returns:
// - error: An error object if any step of the installation fails, nil otherwise
//
// The function logs detailed information about each step of the process.
func Install(nodeVersion, agentVersion, installerDir string, url string, otc string) error {
logger.LogFunctionEntry("Install", map[string]interface{}{
"nodeVersion": nodeVersion,
Expand Down Expand Up @@ -65,7 +86,6 @@ func Install(nodeVersion, agentVersion, installerDir string, url string, otc str
}
logger.Debug("Device agent configuration successful")

// Create service configuration
logger.Info("Configuring FlowFuse Device Agent to run as system service...")
if err := service.Install("flowfuse-device-agent", workDir); err != nil {
logger.Error("Service setup failed: %v", err)
Expand All @@ -90,7 +110,18 @@ func Install(nodeVersion, agentVersion, installerDir string, url string, otc str
return nil
}

// Uninstall removes the system service, device agent package and working directory
// Uninstall removes the FlowFuse Device Agent from the system.
// It performs the following steps:
// 1. Verifies if the device agent is currently installed
// 2. Removes the device agent service
// 3. Uninstalls the device agent package
// 4. Removes the working directory
// 5. Removes the service account that was used to run the agent
//
// The function uses configuration settings if available, or falls back to
// default values when the configuration cannot be loaded.
//
// Returns an error if any step in the uninstallation process fails.
func Uninstall() error {
logger.LogFunctionEntry("Uninstall", nil)

Expand Down
42 changes: 30 additions & 12 deletions installer/go/pkg/nodejs/deviceagent.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,22 +15,19 @@ const packageName = "@flowfuse/device-agent"

// InstallDeviceAgent installs the FlowFuse Device Agent with the specified version
// to the given base directory. It requires Node.js to be already installed.
//
// Parameters:
// - version: The version of the Device Agent to install (use "latest" for the latest version)
// - baseDir: The base directory where Node.js is installed and where the Device Agent will be installed
//
// The function will:
// 1. Check if Node.js is installed
// 2. Install the Device Agent globally using npm with the appropriate version
// 3. The installation runs as the service user
//
// Parameters:
// - version: The version of the Device Agent to install (use "latest" for the latest version)
// - baseDir: The base directory where Node.js is installed and where the Device Agent will be installed
//
// Returns an error if:
// - Node.js is not found
// - The operating system is not supported (currently only Linux is supported)
// - The operating system is not supported
// - The installation process fails
//
// Note: For Linux, the installation uses sudo to run npm as the service user.
func InstallDeviceAgent(version string, baseDir string) error {
setNodeDirectories(baseDir)

Expand All @@ -39,7 +36,6 @@ func InstallDeviceAgent(version string, baseDir string) error {
}

serviceUser := utils.ServiceUsername

packageName := packageName
if version != "latest" {
packageName += "@" + version
Expand All @@ -57,6 +53,10 @@ func InstallDeviceAgent(version string, baseDir string) error {
installCmd = exec.Command("sudo", "--preserve-env=PATH", "-u", serviceUser, npmBinPath, "install", "-g", packageName)
env := os.Environ()
installCmd.Env = append(env, npmPrefix, newPath)
case "windows":
installCmd = exec.Command("cmd", "/C", npmBinPath, "install", "-g", packageName)
env := os.Environ()
installCmd.Env = append(env, npmPrefix, newPath)
default:
return fmt.Errorf("unsupported operating system: %s", runtime.GOOS)
}
Expand All @@ -72,7 +72,7 @@ func InstallDeviceAgent(version string, baseDir string) error {
}

// UninstallDeviceAgent removes the FlowFuse Device Agent package from the system.
// It uninstalls the package using the local npm, running the uninstall command with
// It uninstalls the package using the local npm, running the uninstall command with
// the appropriate permissions based on the operating system.
//
// Parameters:
Expand All @@ -98,6 +98,17 @@ func UninstallDeviceAgent(baseDir string) error {
uninstallCmd = exec.Command("sudo", "--preserve-env=PATH", "-u", serviceUser, npmBinPath, "uninstall", "-g", packageName)
env := os.Environ()
uninstallCmd.Env = append(env, npmPrefix, newPath)
case "windows":
workDir, err := utils.GetWorkingDirectory()
if err != nil {
return fmt.Errorf("failed to get working directory: %w", err)
}

deviceAgentPath := filepath.Join(workDir, "node", "node_modules", "@flowfuse", "device-agent")
uninstallCmd = exec.Command("cmd", "/C", "rmdir", "/S", "/Q", deviceAgentPath)
env := os.Environ()
uninstallCmd.Env = append(env, npmPrefix, newPath)

default:
return fmt.Errorf("unsupported operating system: %s", runtime.GOOS)
}
Expand All @@ -124,7 +135,8 @@ func UninstallDeviceAgent(baseDir string) error {
// - error: An error if configuration fails, or nil if successful
//
// The function skips configuration if device.yml already exists in the base directory.
// Currently, only Linux operating systems are supported.
// For Linux, the configuration uses sudo to run as the service user.
// For Windows, it uses cmd.exe .
func ConfigureDeviceAgent(url string, token string, baseDir string) error {

var deviceAgentPath string
Expand Down Expand Up @@ -153,12 +165,18 @@ func ConfigureDeviceAgent(url string, token string, baseDir string) error {
// Getting full path to flowfuse-device-agent binary
if runtime.GOOS == "linux" {
deviceAgentPath = filepath.Join(nodeBinDir, "flowfuse-device-agent")
} else {
deviceAgentPath = filepath.Join(nodeBaseDir, "flowfuse-device-agent.cmd")
}

// Create configure command
switch runtime.GOOS {
case "linux":
configureCmd = exec.Command("sudo", "--preserve-env=PATH", "-u", serviceUser, deviceAgentPath, "-o", token, "-u", url)
configureCmd = exec.Command("sudo", "--preserve-env=PATH", "-u", serviceUser, deviceAgentPath, "-o", token, "-u", url, "--otc-no-start", "--otc-no-import")
env := os.Environ()
configureCmd.Env = append(env, newPath)
case "windows":
configureCmd = exec.Command("cmd", "/C", deviceAgentPath, "-o", token, "-u", url, "--otc-no-start", "--otc-no-import")
env := os.Environ()
configureCmd.Env = append(env, newPath)
default:
Expand Down
165 changes: 17 additions & 148 deletions installer/go/pkg/nodejs/nodejs.go
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
package nodejs

import (
"archive/tar"
"compress/gzip"
"fmt"
"io"
"net/http"
Expand Down Expand Up @@ -147,8 +145,8 @@ func compareVersions(installed, requested string) bool {
func setNodeDirectories(basedir string) {
nodeBaseDir = filepath.Join(basedir, NodeDir)
if runtime.GOOS == "windows" {
nodeBinPath = filepath.Join(nodeBaseDir, "bin", "node.exe")
npmBinPath = filepath.Join(nodeBaseDir, "bin", "npm.cmd")
nodeBinPath = filepath.Join(nodeBaseDir, "node.exe")
npmBinPath = filepath.Join(nodeBaseDir, "npm.cmd")
} else {
nodeBinPath = filepath.Join(nodeBaseDir, "bin", "node")
npmBinPath = filepath.Join(nodeBaseDir, "bin", "npm")
Expand Down Expand Up @@ -222,6 +220,10 @@ func installNodeJs(version string) error {
if output, err := chownCmd.CombinedOutput(); err != nil {
return fmt.Errorf("failed to set directory ownership: %w\nOutput: %s", err, output)
}
} else {
if err := os.MkdirAll(nodeBaseDir, 0755); err != nil {
return fmt.Errorf("failed to create Node.js installation directory: %w", err)
}
}

downloadURL, err := getNodeDownloadURL(version)
Expand Down Expand Up @@ -269,6 +271,8 @@ func getNodeDownloadURL(version string) (string, error) {
switch runtime.GOOS {
case "linux":
return fmt.Sprintf("%s/node-v%s-linux-%s.tar.gz", baseURL, version, arch), nil
case "windows":
return fmt.Sprintf("%s/node-v%s-win-%s.zip", baseURL, version, arch), nil
default:
return "", fmt.Errorf("unsupported operating system: %s", runtime.GOOS)
}
Expand Down Expand Up @@ -322,7 +326,9 @@ func downloadAndExtractNode(url, version string) error {

// Extract based on file type
if strings.HasSuffix(url, ".tar.gz") {
err = extractTarGz(tempFile.Name(), nodeBaseDir, version)
err = utils.ExtractTarGz(tempFile.Name(), nodeBaseDir, version)
} else if strings.HasSuffix(url, ".zip") {
err = utils.ExtractZip(tempFile.Name(), nodeBaseDir, version)
} else {
err = fmt.Errorf("unsupported archive format")
}
Expand Down Expand Up @@ -354,152 +360,15 @@ func downloadAndExtractNode(url, version string) error {
if output, err := binDirCmd.CombinedOutput(); err != nil {
return fmt.Errorf("failed to set permissions for bin directory: %w\nOutput: %s", err, output)
}
}

logger.Info("Node.js installed successfully!")
return nil
}

// extractTarGz extracts a Node.js tar.gz archive to the specified destination directory.
//
// This function handles the extraction of a Node.js tar.gz archive and manages the necessary permissions.
// On Linux, it first extracts the archive to a temporary directory and then uses sudo to move the files
// to the destination directory with proper ownership and permissions.
//
// Parameters:
// - tarGzFile: Path to the Node.js tar.gz archive file.
// - destDir: Destination directory where the contents should be extracted.
// - version: Node.js version string used to identify the root directory in the archive.
//
// Returns:
// - error: If any step in the extraction process fails, an error is returned with details.
//
// Notes:
// - Currently only supports Linux platforms.
// - Requires sudo privileges to set proper ownership and permissions.
// - Handles directory creation, file extraction, symbolic links, and permission setting.
func extractTarGz(tarGzFile, destDir, version string) error {
file, err := os.Open(tarGzFile)
if err != nil {
return err
}
defer file.Close()

gzipReader, err := gzip.NewReader(file)
if err != nil {
return err
}
defer gzipReader.Close()

tarReader := tar.NewReader(gzipReader)

// Get the root directory name in the archive
var archSuffix string
var rootDir string
if runtime.GOOS == "linux" {
if runtime.GOARCH == "amd64" {
archSuffix = "x64"
} else if runtime.GOARCH == "386" {
archSuffix = "x86"
} else if runtime.GOARCH == "arm" {
archSuffix = "armv7l"
} else {
archSuffix = runtime.GOARCH
}
rootDir = fmt.Sprintf("node-v%s-linux-%s", version, archSuffix)
}

if runtime.GOOS == "linux" {
// Create a temporary directory
tempExtractDir, err := os.MkdirTemp("", "nodejs-extract-")
if err != nil {
return fmt.Errorf("failed to create temporary extraction directory: %w", err)
}
defer os.RemoveAll(tempExtractDir)

// First, extract to a temporary directory that doesn't require elevated privileges
for {
header, err := tarReader.Next()
if err == io.EOF {
break
}
if err != nil {
return err
}

// Skip if it's the root directory
if header.Name == rootDir || header.Name == rootDir+"/" {
continue
}

// Remove root directory from path
relPath := strings.TrimPrefix(header.Name, rootDir)
relPath = strings.TrimPrefix(relPath, "/")

if relPath == "" {
continue
}

tempPath := filepath.Join(tempExtractDir, relPath)

switch header.Typeflag {
case tar.TypeDir:
if err := os.MkdirAll(tempPath, 0755); err != nil {
return err
}
case tar.TypeReg:
if err := os.MkdirAll(filepath.Dir(tempPath), 0755); err != nil {
return err
}

outFile, err := os.Create(tempPath)
if err != nil {
return err
}

if _, err := io.Copy(outFile, tarReader); err != nil {
outFile.Close()
return err
}
outFile.Close()

if err := os.Chmod(tempPath, os.FileMode(header.Mode)); err != nil {
return err
}
case tar.TypeSymlink:
if err := os.Symlink(header.Linkname, tempPath); err != nil {
return err
}
}
}

// Copy the content from temp dir to the destination using sudo
logger.Debug("Moving extracted files to %s (requires sudo)...", destDir)

// Ensure the destination directory exists with proper permissions
mkdirCmd := exec.Command("sudo", "mkdir", "-p", destDir)
if output, err := mkdirCmd.CombinedOutput(); err != nil {
return fmt.Errorf("failed to create destination directory: %w\nOutput: %s", err, output)
}

// Copy the extracted files from temp dir to destination
cpCmd := exec.Command("sudo", "cp", "-a", tempExtractDir+"/.", destDir)
if output, err := cpCmd.CombinedOutput(); err != nil {
return fmt.Errorf("failed to copy extracted files: %w\nOutput: %s", err, output)
}

// Set ownership of all files to the service user
chownCmd := exec.Command("sudo", "chown", "-R", utils.ServiceUsername+":"+utils.ServiceUsername, destDir)
chmodCmd := exec.Command("sudo", "chmod", "755", destDir)
if output, err := chmodCmd.CombinedOutput(); err != nil {
return fmt.Errorf("failed to set directory permissions: %w\nOutput: %s", err, output)
} else {
if err := os.Chmod(nodeBinPath, 0755); err != nil {
return fmt.Errorf("failed to set permissions for node executable: %w", err)
}
if output, err := chownCmd.CombinedOutput(); err != nil {
return fmt.Errorf("failed to set directory ownership: %w\nOutput: %s", err, output)
if err := os.Chmod(npmBinPath, 0755); err != nil {
return fmt.Errorf("failed to set permissions for npm executable: %w", err)
}

return nil
}

logger.Info("Node.js installed successfully!")
return nil
}
Loading