Fleet 文件脚本机制:跨平台软件安装、移除与卸载默认脚本的完整实现解析 📅 发布时间:2026/9/20 13:00:35 👁 浏览次数: Fleet 文件脚本机制跨平台软件安装、移除与卸载默认脚本的完整实现解析【免费下载链接】fleetOpen device management项目地址: https://gitcode.com/GitHub_Trending/fl/fleet在 Fleet 的声明式设备管理DDM体系中当管理员向 macOS、Linux 或 Windows 设备下发软件包时如果软件没有配置自定义脚本Fleet 会自动使用一组内置的文件脚本file scripts来完成安装、移除和卸载。本文基于仓库中 pkg/file/scripts 目录说明文档 及其配套的全部 17 个脚本源码逐平台解析这些默认脚本的命名规则、支持的运行时变量、各安装包格式.msi / .exe / .pkg / .deb / .rpm的具体执行逻辑以及 Fleet 服务端如何将这些脚本嵌入二进制并按安装包类型选择下发帮助开发者完整理解 Fleet 软件部署链路的默认行为与退出码语义。一、file scripts 目录的定位与设计初衷pkg/file/scripts/README.md 开篇即说明了该目录的职责存放针对不同安装包类型installer的安装/移除软件的脚本。文档还给出了脚本以独立文件形式存储的两个原因其中一些脚本会被读取并在 Fleet Web UI 中展示即管理员在软件界面中看到的安装脚本/卸载脚本内容实际上是这组默认脚本独立成文件便于获得良好的语法高亮也便于直接运行和审阅。当前目录下的完整脚本清单如下类型脚本文件适用安装包格式安装install_msi.ps1Windows .msi安装install_exe.ps1Windows .exe说明文档与代码选择逻辑中保留的位置见第五节安装install_pkg.shmacOS .pkg安装install_pkg_fleetd.shFleet 自身fleetd/orbit的 .pkg 更新安装install_deb.shLinux .deb安装install_rpm.shLinux .rpm移除remove_msi.ps1Windows .msi移除remove_exe.ps1Windows .exe移除remove_pkg.shmacOS .pkg移除remove_deb.shLinux .deb移除remove_rpm.shLinux .rpm卸载uninstall_msi.ps1Windows .msi卸载uninstall_msi_with_upgrade_code.ps1Windows .msi按升级代码关联产品卸载uninstall_pkg.shmacOS .pkg卸载uninstall_deb.shLinux .deb卸载uninstall_rpm.shLinux .rpm三类脚本的语义区别README 明确区分了三类脚本这一区分在 Fleet 的软件管理生命周期中很关键install_*.*各平台的默认安装脚本。uninstall_*.*各平台的默认卸载脚本。remove_*.*移除脚本用于两种场景——软件包是在卸载功能发布之前添加的尚未设置卸载脚本或者卸载脚本为空时的兜底。换句话说卸载uninstall依赖 Fleet 在上传软件包时提取出的软件标识信息如产品代码、包 ID而移除remove是更朴素、更防御性的兜底策略尽量只针对安装包文件本身做删除动作。二、脚本支持的运行时变量$INSTALLER_PATH、$PACKAGE_ID 与 $UPGRADE_CODEREADME 指出目录中的脚本接受形如$VAR_NAME的变量这些变量由fleetd在脚本运行时替换/填充并明确列出了受支持变量$INSTALLER_PATH安装包文件的路径。在全部脚本中都可以看到${env:INSTALLER_PATH}PowerShell或$INSTALLER_PATHshell形式的引用。但结合仓库源码可以确认实际填充的变量不止一个。pkg/file/management.go 中定义了两个专门用于匹配变量占位符的正则var PackageIDRegex regexp.MustCompile((((\$PACKAGE_ID)|(\$PACKAGE_ID))(?Psuffix\W|$))|((\${PACKAGE_ID})|(\${PACKAGE_ID}))) var UpgradeCodeRegex regexp.MustCompile((((\$UPGRADE_CODE)|(\$UPGRADE_CODE))(?Psuffix\W|$))|((\${UPGRADE_CODE})|(\${UPGRADE_CODE})))由此可确认脚本体系还支持$PACKAGE_IDFleet 在上传软件包时提取的软件标识deb/rpm 的包名、msi 的产品代码、pkg 的包 ID 等。例如 uninstall_deb.sh 首行即为package_name$PACKAGE_IDuninstall_msi.ps1 使用$product_code $PACKAGE_ID。$UPGRADE_CODEMSI 的升级代码供 uninstall_msi_with_upgrade_code.ps1 通过$UPGRADE_CODE找到所有关联产品并逐一卸载。值得注意的是源码中还有一道安全防线ValidatePackageIdentifiers 会在填充前校验包 ID 与升级代码是否包含 shell 元字符正则shellMetacharRegex覆盖单双引号、反引号、$、反斜杠、|;!以及换行等包含非法字符时直接报错拒绝。这说明 Fleet 对把外部可控的包标识插入可执行脚本这一模式做了显式的安全防护防止脚本注入。三、默认安装脚本按平台逐一解析3.1 Linux .debaptinstall_deb.sh 的全部内容只有两行#!/bin/sh apt-get install --assume-yes -f $INSTALLER_PATH要点使用apt-get install -f-f即 fix-broken顺带修复依赖安装本地 deb 文件--assume-yes保证非交互执行。3.2 Linux .rpmdnfinstall_rpm.sh#!/bin/sh dnf install --assumeyes $INSTALLER_PATH从脚本内容看Fleet 的 rpm 安装路径依赖dnf而非更通用的rpm命令这与其面向较新发行版的定位一致。3.3 macOS .pkginstall_pkg.sh#!/bin/sh installer -pkg $INSTALLER_PATH -target /调用 macOS 自带的installer命令将包安装到根目标卷。3.4 macOS 特例fleetdorbit自更新脚本install_pkg_fleetd.sh 是目录中最复杂的安装脚本专门用于 fleetd/orbit就地in-band自我升级。其头部注释解释了问题背景orbit 给自己安装新版本时macOS installer 会因正在运行的 orbit 进程占用了目标文件而报 An unexpected error occurred while moving files to the final destination。脚本的应对策略是if [ -d /opt/orbit/bin ]; then rm -rf /opt/orbit/bin/orbit/macos 2/dev/null rm -rf /opt/orbit/bin/osqueryd 2/dev/null rm -rf /opt/orbit/bin/desktop 2/dev/null # Marker file tells the postinstall script this is an in-band upgrade. # (The macOS installer command does not propagate env vars to postinstall scripts.) touch /opt/orbit/.inband_upgrade fi installer -pkg $INSTALLER_PATH -target / exit_code$? # Clean up the marker file in case the installer failed before the postinstall # script had a chance to run and remove it. rm -f /opt/orbit/.inband_upgrade 2/dev/null exit $exit_code这里有三个值得注意的工程细节先删除旧二进制再安装是安全的——macOS 上运行中的进程持有旧 inode 的文件描述符删除文件不影响进程继续运行足以撑过安装与结果上报窗口用标记文件而非环境变量传递这是就地升级信号因为installer命令不会把环境变量传播给 pkg 的 postinstall 脚本安装失败时清理标记文件避免残留的/opt/orbit/.inband_upgrade误导后续逻辑。服务端通过 IsFleetdPkg 判断包 ID 是否以com.fleetdm.orbit开头来决定是否走这条特殊脚本路径。3.5 Windows .msimsiexecinstall_msi.ps1 展示了 Fleet 对 MSI 退出码语义的完整处理$logFile ${env:TEMP}/fleet-install-software.log # MSI exit codes that indicate success. 3010 ERROR_SUCCESS_REBOOT_REQUIRED, # 1641 ERROR_SUCCESS_REBOOT_INITIATED. Treat these as success rather than failure. $successCodes (0, 3010, 1641) try { $installProcess Start-Process msiexec.exe -ArgumentList /quiet /norestart /lv ${logFile} /i ${env:INSTALLER_PATH} -PassThru -Verb RunAs -Wait Get-Content $logFile -Tail 500 if ($successCodes -contains $installProcess.ExitCode) { Exit 0 } Exit $installProcess.ExitCode } catch { Write-Host Error: $_ Exit 1 }关键设计点/quiet /norestart静默安装且不打断重启流程/lv将 MSI 日志写到%TEMP%\fleet-install-software.log脚本随后用Get-Content -Tail 500把日志尾部输出便于在 Fleet 的脚本结果中看到故障上下文-Verb RunAs请求以管理员权限运行 msiexec把 3010ERROR_SUCCESS_REBOOT_REQUIRED和 1641ERROR_SUCCESS_REBOOT_INITIATED视为成功——这两类退出码本质是安装已成功但需要/已发起重启若按非零即失败处理会产生大量误报。这一退出码规范在后续的卸载与移除脚本中被保持一致。3.6 Windows .exeinstall_exe.ps1 通过Start-Process直接运行安装包文件$exeFilePath ${env:INSTALLER_PATH} try { # Add argument to install silently # Argument to make install silent depends on installer, # each installer might use different argument (usually its /S or /s) $processOptions { FilePath $exeFilePath ArgumentList /S PassThru $true Wait $true } # Start process and track exit code $process Start-Process processOptions $exitCode $process.ExitCode # Prints the exit code Write-Host Install exit code: $exitCode Exit $exitCode } catch { Write-Host Error: $_ Exit 1 }脚本注释中坦承了一个现实约束静默安装参数因安装器而异通常是/S或/s默认采用/S。这意味着对于自定义 exe 安装器管理员往往需要根据具体安装器修改该参数这也是为什么自定义安装脚本存在价值的地方。四、移除脚本remove_*不依赖上传期元数据的兜底策略remove 类脚本的共同特点是尽量只依据安装包文件本身做操作因为针对它们的历史软件包可能没有可用$PACKAGE_ID之类的提取元数据。4.1 remove_deb.sh —— 从 deb 文件本身读包名#!/bin/sh apt-get remove -y $(dpkg -f $INSTALLER_PATH Package)dpkg -f 文件 Package直接从 deb 文件中读取包名字段再交给apt-get remove -y完全绕开了上传期提取。4.2 remove_pkg.sh —— 从 pkg 归档中解析 PackageInfo#!/bin/sh # grab the identifier from the first PackageInfo we find. Those are placed in different locations depending on the installer pkg_id$(tar xOvf $INSTALLER_PATH --include*PackageInfo* 2/dev/null | sed -n s/.*identifier\([^\]*\).*/\1/p) # remove all the files and empty directories that were installed pkgutil --files $pkg_id | tr \n \0 | xargs -n 1 -0 rm -d # remove the receipt pkgutil --forget $pkg_id逻辑是用tar从 pkg 归档中抽出PackageInfo并解析identifier...得到包 ID然后pkgutil --files列出该包安装的所有文件并逐一rm -d删除文件及空目录最后pkgutil --forget删除安装收据。注意与第五节的 uninstall_pkg.sh 的差别这里删除包安装的所有文件而 uninstall 版本只删除.app目录策略明显更重。4.3 remove_exe.ps1 —— 刻意保守的删除范围$exeFilePath ${env:INSTALLER_PATH} # extract the name of the executable to use as the sub-directory name $exeName [System.IO.Path]::GetFileName($exeFilePath) $subDir [System.IO.Path]::GetFileNameWithoutExtension($exeFilePath) # determine the correct Program Files directory based on OS architecture $destinationPath Join-Path -Path $env:ProgramFiles -ChildPath $subDir $destinationExePath Join-Path -Path $destinationPath -ChildPath $exeName # remove only the exe file, while at runtime other files could have been # created in this folder, this is a naive approach to prevent forcing us to # remove important folders by crafting a malicious file name. Remove-Item -Path $destinationExePath源码注释直接点明了设计动机只删除 exe 文件本体而不是整个安装目录——这是一种朴素但防御性的做法防止通过构造恶意文件名诱导脚本删除重要目录。它假设安装器被安装到Program Files\安装器文件名无扩展名\exe名这一常见布局。4.4 remove_msi.ps1 —— 按文件卸载 MSI$logFile ${env:TEMP}/fleet-remove-software.log $removeProcess Start-Process msiexec.exe -ArgumentList /quiet /norestart /lv \${logFile}\ /x \${env:INSTALLER_PATH}\ -PassThru -Verb RunAs -Wait Get-Content $logFile -Tail 500 exit $removeProcess.ExitCode与 install_msi.ps1 相比这里用/x 安装包路径直接按文件卸载同样写日志并输出尾部 500 行退出码原样透传。4.5 remove_rpm.sh#!/bin/sh package_name$PACKAGE_ID # Fleet uninstalls app using product name thats extracted on upload dnf remove --assumeyes $package_name从脚本可见 remove_rpm 与 uninstall_rpm 内容相同都依赖上传期提取的包名而 remove_deb 则改用dpkg -f现场解析——不同平台对兜底程度的把握并不一致。五、卸载脚本uninstall_*基于上传期提取的软件标识5.1 deb / rpm按包名移除uninstall_deb.sh 与 uninstall_rpm.sh 都只有一行核心逻辑依赖 Fleet 上传时提取的包名# uninstall_deb.sh package_name$PACKAGE_ID apt-get remove --purge --assume-yes $package_name# uninstall_rpm.sh package_name$PACKAGE_ID dnf remove --assumeyes $package_name注意 deb 版本使用了--purge连配置一起清除比 remove 版本的普通remove -y更彻底。5.2 macOS .pkg只清理 .app 并删除收据uninstall_pkg.sh 体现了对 GUI 应用卸载的典型处理——不清空包安装的全部文件只删除属于该包的.app目录再删除安装收据#!/bin/sh # Fleet extracts and saves package IDs. pkg_ids$PACKAGE_ID # For each package id, get all .app folders associated with the package and remove them. for pkg_id in ${pkg_ids[]} do # Get volume and location of the package. volume$(pkgutil --pkg-info $pkg_id | grep -i volume | awk {if (NF1) print $NF}) location$(pkgutil --pkg-info $pkg_id | grep -i location | awk {if (NF1) print $NF}) # Check if this package id corresponds to a valid/installed package if [[ ! -z $volume ]]; then # Remove individual directories that end with .app belonging to the package. # Only process directories that end with .app to prevent Fleet from removing top level directories. pkgutil --only-dirs --files $pkg_id | grep \.app$ | sed -e s^$volume$location/ | tr \n \0 | xargs -n 1 -0 rm -rf # Remove receipts pkgutil --forget $pkg_id else echo WARNING: volume is empty for package ID $pkg_id fi done脚本注释解释了只处理.app结尾目录的原因防止删除顶层目录。流程为pkgutil --pkg-info取安装卷与位置 →pkgutil --only-dirs --files列目录 → 过滤.app→ 拼接完整路径以 NUL 分隔传给xargs -0 rm -rf安全处理含空格路径→pkgutil --forget清收据。5.3 Windows .exe注册表驱动的卸载uninstall_exe.ps1 是整个目录中逻辑最完整的脚本展示了按名称找卸载器的标准 Windows 做法取$PACKAGE_ID作为软件名Fleet 从 exe 安装包中提取的软件名构造通配模式*$softwareName*同时扫描 64 位与 32 on 64 注册表卸载键HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*HKLM:\SOFTWARE\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*对匹配的注册表项优先使用QuietUninstallString否则回退到UninstallString卸载命令可能是C:\Program Files\Software\uninstall.exe --uninstall --silent这种可执行文件 参数结构脚本按引号切分Split()恰为 3 段时取出路径与参数出现多个带引号片段则抛错要求管理员修正脚本以Start-Process运行并等待打印并返回退出码找不到卸载器时打印Uninstaller for name not found.并以退出码 1 失败——脚本注释还提示如果希望程序本来就没装不算失败可把该行改为$exitCode 0。5.4 Windows .msi产品代码卸载 5 分钟超时uninstall_msi.ps1 与安装脚本呼应$product_code $PACKAGE_ID $timeoutSeconds 300 # 5 minute timeout # Fleet uninstalls app using product code thats extracted on upload $process Start-Process msiexec -ArgumentList (/quiet, /x, $product_code, /norestart) -PassThru # Wait for process with timeout $completed $process.WaitForExit($timeoutSeconds * 1000) if (-not $completed) { Stop-Process -Id $process.Id -Force -ErrorAction SilentlyContinue Exit 1603 # ERROR_UNINSTALL_FAILURE } # MSI exit codes that indicate success. 3010 ERROR_SUCCESS_REBOOT_REQUIRED, # 1641 ERROR_SUCCESS_REBOOT_INITIATED. Treat these as success rather than failure. $successCodes (0, 3010, 1641) if ($successCodes -contains $process.ExitCode) { Write-Output Exit 0 Exit 0 } else { Write-Output Exit $($process.ExitCode) Exit $process.ExitCode }两个值得注意的机制300 秒看门狗卸载进程超时则Stop-Process强杀并以1603ERROR_UNINSTALL_FAILURE退出避免 MSI 挂起把整个软件策略阻塞同样的 0/3010/1641 成功码判定。5.5 特例按升级代码Upgrade Code卸载所有关联产品uninstall_msi_with_upgrade_code.ps1 用于处理同一 MSI 升级链上的多个版本# Fleet uninstalls app by finding all related product codes for the specified upgrade code $inst New-Object -ComObject WindowsInstaller.Installer $timeoutSeconds 300 # 5 minute timeout per product $successCodes (0, 3010, 1641) foreach ($product_code in $inst.RelatedProducts($UPGRADE_CODE)) { $process Start-Process msiexec -ArgumentList (/quiet, /x, $product_code, /norestart) -PassThru $completed $process.WaitForExit($timeoutSeconds * 1000) if (-not $completed) { Stop-Process -Id $process.Id -Force -ErrorAction SilentlyContinue Exit 1603 # ERROR_UNINSTALL_FAILURE } if ($successCodes -notcontains $process.ExitCode) { Write-Output Uninstall for $($product_code) exited $($process.ExitCode) Exit $process.ExitCode } } Exit 0通过WindowsInstaller.InstallerCOM 对象的RelatedProducts($UPGRADE_CODE)枚举升级代码下的全部产品代码逐个执行带超时保护的静默卸载任何一个失败立即中止并透传该退出码全部成功才Exit 0。六、服务端如何嵌入与选择这些脚本这些脚本并不是运行在 Fleet 服务器上的文件资源而是编译进服务二进制的嵌入资产。pkg/file/management.go 通过//go:embed将各脚本嵌入为字符串变量如installMsiScript、removeExeScript、uninstallMsiScript等并提供三个按扩展名分发的选择函数// GetInstallScript 返回可用于安装给定扩展名安装包的脚本 func GetInstallScript(extension string) string { switch extension { case msi: return installMsiScript case deb: return installDebScript case rpm: return installRPMScript case pkg: return installPkgScript default: return } }GetInstallScriptmsi / deb / rpm / pkg 四选一其他扩展名返回空串意味着该格式没有默认安装脚本需自定义脚本GetRemoveScript覆盖 msi / deb / rpm / pkg /exe五种格式——这正是 README 中 remove 是未设置卸载脚本时的兜底 这一语义的服务端落点GetUninstallScriptmsi / deb / rpm / pkg不含 exe另有导出的 InstallPkgFleetdScript 与 UninstallMsiWithUpgradeCodeScript 供特定场景fleetd 自更新、按升级代码卸载直接使用IsFleetdPkg则依据包 ID 前缀com.fleetdm.orbit识别 fleetd 自身安装包。行为由 pkg/file/management_test.go 的 golden 测试锁定测试按扩展名断言每个脚本被正确选中并校验脚本内容与testdata/scripts/下的 golden 文件一致注释说明可用-update参数在修改脚本后刷新 golden。这套机制保证了扩展名 → 脚本的映射关系在重构时不会悄悄漂移。七、退出码约定与运维排障要点综合全部脚本源码Fleet 文件脚本体系形成了一致的退出码约定排障时可据此判读软件策略的执行结果退出码含义出现位置0成功全部脚本3010 / 1641成功但需重启 / 已发起重启MSI一律按成功处理install_msi.ps1、uninstall_msi.ps1、uninstall_msi_with_upgrade_code.ps11脚本自身异常try/catch 兜底或未找到卸载器install/uninstall 的 PowerShell 脚本1603ERROR_UNINSTALL_FAILURE卸载超时每产品 5 分钟后被强杀uninstall_msi.ps1、uninstall_msi_with_upgrade_code.ps1msiexec 原始退出码安装/卸载失败时原样透传install_msi.ps1、remove_msi.ps1排障实践上的几个抓手Windows MSI 问题先看日志安装/移除脚本都会把 msiexec 的/lv日志写到%TEMP%并把最后 500 行输出到脚本结果无需再登机器取文件exe 安装不静默默认参数只有/S遇到静默参数不同的安装器如 NSIS 的/S、部分厂商的自定义参数应改用自定义安装脚本deb 卸载不干净时确认使用的是 uninstall 路径--purge而非 remove 路径普通remove -ymacOS 应用卸载后残留非 .app 文件这是 uninstall_pkg.sh 的既定行为只删 .app 与收据若需要彻底删除包内全部文件remove_pkg.sh 的策略pkgutil --files | xargs rm -d才是完整清理路径fleetd 自更新失败重点检查/opt/orbit/bin下旧二进制的删除标记逻辑与.inband_upgrade标记文件见 install_pkg_fleetd.sh。八、小结pkg/file/scripts/README.md 以简短的说明勾勒出 Fleet 默认文件脚本的骨架按install_*/uninstall_*/remove_*三类命名、以独立文件存放以便 UI 展示与高亮、由fleetd在运行时填充$INSTALLER_PATH等变量。而目录中的 17 个脚本与 pkg/file/management.go 的嵌入/分发代码共同补全了这幅图景Fleet 针对 msi、exe、pkg、deb、rpm 五种安装包格式分别实现了上传期元数据驱动的卸载与文件本身的兜底移除两条路径在 MSI 退出码、超时看门狗、静默参数差异、就地升级文件占用等工程细节上都有明确的防御性设计。理解这套默认行为是读懂 Fleet 软件部署执行结果、并在需要时编写自定义安装/卸载脚本的基础。【免费下载链接】fleetOpen device management项目地址: https://gitcode.com/GitHub_Trending/fl/fleet创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考