fleet/cmd/maintained-apps/validate/app_commander.go
Konstantin Sykulev b1a392d672
FMA test automation (#31210)
For #29183

# Checklist for submitter

- [x] Added/updated automated tests
- [x] Manual QA for all new/changed functionality


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **New Features**
* Introduced automated validation workflows for maintained applications
on both macOS and Windows, ensuring apps can be installed, verified, and
uninstalled as expected.
* Added new command-line tool to validate maintained apps, providing
detailed reporting on validation results.
* Enhanced detection and handling of pre-installed applications during
validation.
* Improved post-installation steps for macOS, including quarantine
removal and system refresh.

* **Chores**
* Added new continuous integration workflows to automate application
validation on pull requests for relevant files.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-31 15:23:36 -05:00

152 lines
4.5 KiB
Go

//go:build darwin || windows
package main
import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"github.com/fleetdm/fleet/v4/pkg/file"
"github.com/fleetdm/fleet/v4/server/fleet"
kitlog "github.com/go-kit/log"
"github.com/go-kit/log/level"
)
type AppCommander struct {
Name string
Slug string
UniqueIdentifier string
Version string
AppPath string
UninstallScript string
InstallScript string
cfg *Config
appLogger kitlog.Logger
}
func (ac *AppCommander) isFrozen() (bool, error) {
var inputPath string
switch ac.cfg.operatingSystem {
case "darwin":
inputPath = "ee/maintained-apps/inputs/homebrew"
case "windows":
inputPath = "ee\\maintained-apps\\inputs\\winget"
}
parts := strings.Split(ac.Slug, "/")
if len(parts) != 2 {
return false, fmt.Errorf("invalid slug format: %s, expected <name>/<platform>", ac.Slug)
}
inputPath = filepath.Join(inputPath, parts[0]+".json")
fileBytes, err := os.ReadFile(inputPath)
if err != nil {
return false, fmt.Errorf("reading app input file: %w", err)
}
var input struct {
Frozen bool `json:"frozen"`
}
if err := json.Unmarshal(fileBytes, &input); err != nil {
return false, fmt.Errorf("unmarshal app input file: %w", err)
}
return input.Frozen, nil
}
func (ac *AppCommander) extractAppVersion(installerTFR *fleet.TempFileReader) error {
if ac.Version == "latest" {
meta, err := file.ExtractInstallerMetadata(installerTFR)
if err != nil {
return err
}
ac.Version = meta.Version
err = installerTFR.Rewind()
if err != nil {
return err
}
}
return nil
}
func (ac *AppCommander) uninstallPreInstalled(ctx context.Context) {
level.Info(ac.appLogger).Log("msg", "App is marked as pre-installed, attempting to run uninstall script...")
_, _, listError := ac.expectToChangeFileSystem(
func() error {
uninstalled := ac.uninstallApp(ctx)
if !uninstalled {
level.Error(ac.appLogger).Log("msg", "Failed to uninstall pre-installed app")
}
return nil
},
)
if listError != nil {
level.Error(ac.appLogger).Log("msg", fmt.Sprintf("Error listing %s directory: %v", ac.cfg.installationSearchDirectory, listError))
}
}
func (ac *AppCommander) uninstallApp(ctx context.Context) bool {
level.Info(ac.appLogger).Log("msg", "Executing uninstall script for app...")
output, err := executeScript(ac.cfg, ac.UninstallScript)
if err != nil {
level.Error(ac.appLogger).Log("msg", fmt.Sprintf("Error uninstalling app: %v", err))
level.Error(ac.appLogger).Log("msg", fmt.Sprintf("Output: %s", output))
return false
}
level.Debug(ac.appLogger).Log("msg", fmt.Sprintf("Output: %s", output))
existance, err := appExists(ctx, ac.appLogger, ac.Name, ac.UniqueIdentifier, ac.Version, ac.AppPath)
if err != nil {
level.Error(ac.appLogger).Log("msg", fmt.Sprintf("Error checking if app exists after uninstall: %v", err))
return false
}
if existance {
level.Error(ac.appLogger).Log("msg", fmt.Sprintf("App version '%s' was found after uninstall", ac.Version))
return false
}
return true
}
func (ac *AppCommander) expectToChangeFileSystem(changer func() error) (string, error, error) {
var preListError, postListError, listError error
appListPre, err := listDirectoryContents(ac.cfg.installationSearchDirectory)
if err != nil {
preListError = fmt.Errorf("Error listing %s directory: %v", ac.cfg.installationSearchDirectory, err)
}
changerError := changer()
appListPost, err := listDirectoryContents(ac.cfg.installationSearchDirectory)
if err != nil {
postListError = fmt.Errorf("Error listing %s directory: %v", ac.cfg.installationSearchDirectory, err)
}
appPath, changed := detectApplicationChange(ac.cfg.installationSearchDirectory, appListPre, appListPost)
if appPath == "" {
level.Warn(ac.appLogger).Log("msg", fmt.Sprintf("no changes detected in %s directory after running application script.", ac.cfg.installationSearchDirectory))
} else {
if changed {
level.Info(ac.appLogger).Log("msg", fmt.Sprintf("New application detected at: %s", appPath))
} else {
level.Info(ac.appLogger).Log("msg", fmt.Sprintf("Application removal detected at: %s", appPath))
}
}
switch {
case preListError != nil && postListError != nil:
listError = fmt.Errorf("app pre list: %v, app post list: %v", preListError, postListError)
case preListError != nil:
listError = fmt.Errorf("app pre list: %v", preListError)
case postListError != nil:
listError = fmt.Errorf("app post list: %v", postListError)
}
return appPath, changerError, listError
}