Skip to content

Commit

Permalink
fix: update install script for FMAs to improve re-install process (#2…
Browse files Browse the repository at this point in the history
…5238)

> For #24148

# Checklist for submitter

If some of the following don't apply, delete the relevant line.

<!-- Note that API documentation changes are now addressed by the
product design team. -->

- [x] Changes file added for user-visible changes in `changes/`,
`orbit/changes/` or `ee/fleetd-chrome/changes`.
See [Changes
files](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/Committing-Changes.md#changes-files)
for more information.
- [x] Input data is properly validated, `SELECT *` is avoided, SQL
injection is prevented (using placeholders for values in statements)
- [x] If database migrations are included, checked table schema to
confirm autoupdate
- For database migrations:
- [x] Checked schema for all modified table for columns that will
auto-update timestamps during migration.
- [x] Confirmed that updating the timestamps is acceptable, and will not
cause unwanted side effects.
- [x] Ensured the correct collation is explicitly set for character
columns (`COLLATE utf8mb4_unicode_ci`).
- [x] Added/updated automated tests
- [x] A detailed QA plan exists on the associated ticket (if it isn't
there, work with the product group's QA engineer to add it)
- [x] Manual QA for all new/changed functionality
  • Loading branch information
jahzielv authored Jan 9, 2025
1 parent 9cb59c2 commit 863a37a
Show file tree
Hide file tree
Showing 16 changed files with 770 additions and 3 deletions.
1 change: 1 addition & 0 deletions changes/24148-re-install-fma
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- Updated Fleet-maintained app install scripts for non-PKG-based installers to allow the apps to be installed over an existing installation.
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
package tables

import (
"database/sql"
"errors"
"fmt"
"slices"
"strings"

"github.com/jmoiron/sqlx"
"github.com/jmoiron/sqlx/reflectx"
)

func init() {
MigrationClient.AddMigration(Up_20250109150150, Down_20250109150150)
}

const quitApplicationFunc = `
quit_application() {
local bundle_id="$1"
local timeout_duration=10
# check if the application is running
if ! osascript -e "application id \"$bundle_id\" is running" 2>/dev/null; then
return
fi
local console_user
console_user=$(stat -f "%Su" /dev/console)
if [[ $EUID -eq 0 && "$console_user" == "root" ]]; then
echo "Not logged into a non-root GUI; skipping quitting application ID '$bundle_id'."
return
fi
echo "Quitting application '$bundle_id'..."
# try to quit the application within the timeout period
local quit_success=false
SECONDS=0
while (( SECONDS < timeout_duration )); do
if osascript -e "tell application id \"$bundle_id\" to quit" >/dev/null 2>&1; then
if ! pgrep -f "$bundle_id" >/dev/null 2>&1; then
echo "Application '$bundle_id' quit successfully."
quit_success=true
break
fi
fi
sleep 1
done
if [[ "$quit_success" = false ]]; then
echo "Application '$bundle_id' did not quit."
fi
}
`

func Up_20250109150150(tx *sql.Tx) error {
var scriptsToModify []struct {
InstallScriptContents string `db:"contents"`
AppName string `db:"name"`
BundleID string `db:"bundle_identifier"`
ScriptContentID uint `db:"script_content_id"`
Token string `db:"token"`
}

// Note: we're not updating any install scripts that have been edited by users, only the
// "original" script contents for FMAs that are created when the fleet_library_apps table is populated.
selectStmt := `
SELECT
sc.contents AS contents,
fla.name AS name,
fla.bundle_identifier AS bundle_identifier,
sc.id AS script_content_id,
fla.token AS token
FROM
fleet_library_apps fla
JOIN script_contents sc
ON fla.install_script_content_id = sc.id
WHERE fla.token IN (?)
`

// This is the list of Fleet-maintained apps we want to update ("token" is an ID found on the brew
// metadata)
appTokens := []string{"1password", "brave-browser", "docker", "figma", "google-chrome", "visual-studio-code", "firefox", "notion", "slack", "whatsapp", "postman"}

stmt, args, err := sqlx.In(selectStmt, appTokens)
if err != nil {
return fmt.Errorf("building SQL in statement for selecting fleet maintained apps: %w", err)
}

txx := sqlx.Tx{Tx: tx, Mapper: reflectx.NewMapperFunc("db", sqlx.NameMapper)}
if err := txx.Select(&scriptsToModify, stmt, args...); err != nil {
// if this migration is running on a brand-new Fleet deployment, then there won't be
// anything in the fleet_library_apps table, so we can just exit.
if errors.Is(err, sql.ErrNoRows) {
return nil
}

return fmt.Errorf("selecting script contents: %w", err)
}

for _, sc := range scriptsToModify {
lines := strings.Split(sc.InstallScriptContents, "\n")
// Find the line where we copy the new .app file into the Applications folder. We want to
// add our changes right before that line.
var copyLineNumber int
for i, l := range lines {
if strings.Contains(l, `sudo cp -R "$TMPDIR/`) {
copyLineNumber = i
break
}
}

appFileName := fmt.Sprintf("%s.app", sc.AppName)
if sc.Token == "visual-studio-code" {
// VSCode has the name "Microsoft Visual Studio Code" in fleet_library_apps, but the
// .app name is "Visual Studio Code.app", so account for that here.
appFileName = "Visual Studio Code.app"
}

// This line will move the old version of the .app (if it exists) to the temporary directory
lines = slices.Insert(lines, copyLineNumber, fmt.Sprintf(`sudo [ -d "$APPDIR/%[1]s" ] && sudo mv "$APPDIR/%[1]s" "$TMPDIR/%[1]s.bkp"`, appFileName))
// Add a call to our "quit_application" function
lines = slices.Insert(lines, copyLineNumber, fmt.Sprintf("quit_application %s", sc.BundleID))
// Add the "quit_application" function to the script
lines = slices.Insert(lines, 2, quitApplicationFunc)

updatedScript := strings.Join(lines, "\n")

checksum := md5ChecksumScriptContent(updatedScript)

if _, err = tx.Exec(`UPDATE script_contents SET contents = ?, md5_checksum = UNHEX(?) WHERE id = ?`, strings.Join(lines, "\n"), checksum, sc.ScriptContentID); err != nil {
return fmt.Errorf("updating fma install script contents: %w", err)
}
}

return nil
}

func Down_20250109150150(tx *sql.Tx) error {
return nil
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
package tables

import (
"testing"

"github.com/jmoiron/sqlx"
"github.com/jmoiron/sqlx/reflectx"
"github.com/stretchr/testify/require"
)

func TestUp_20250109150150(t *testing.T) {
db := applyUpToPrev(t)

//
// Insert data to test the migration
//
// ...
originalContents := `
#!/bin/sh
# variables
APPDIR="/Applications/"
TMPDIR=$(dirname "$(realpath $INSTALLER_PATH)")
# extract contents
unzip "$INSTALLER_PATH" -d "$TMPDIR"
# copy to the applications folder
sudo cp -R "$TMPDIR/Figma.app" "$APPDIR"
`

tx, err := db.Begin()
require.NoError(t, err)
txx := sqlx.Tx{Tx: tx, Mapper: reflectx.NewMapperFunc("db", sqlx.NameMapper)}
installScriptID, err := getOrInsertScript(txx, originalContents)
require.NoError(t, err)
uninstallScriptID, err := getOrInsertScript(txx, "echo uninstall")
require.NoError(t, err)
boxInstallScriptID, err := getOrInsertScript(txx, "echo install")
require.NoError(t, err)
boxUninstallScriptID, err := getOrInsertScript(txx, "echo uninstall")
require.NoError(t, err)
err = tx.Commit()
require.NoError(t, err)

// Insert Figma (one of our target FMAs)
execNoErr(
t,
db,
`INSERT INTO fleet_library_apps (name, token, version, platform, installer_url, sha256, bundle_identifier, install_script_content_id, uninstall_script_content_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
"Figma",
"figma",
"124.7.4",
"darwin",
"https://desktop.figma.com/mac-arm/Figma-124.7.4.zip",
"3160c0cac00b8b81b7b62375f04b9598b11cbd9e5d42a5ad532e8b98fecc6b15",
"com.figma.Desktop",
installScriptID,
uninstallScriptID,
)

// Insert Box Drive, should be unaffected
execNoErr(
t,
db,
`INSERT INTO fleet_library_apps (name, token, version, platform, installer_url, sha256, bundle_identifier, install_script_content_id, uninstall_script_content_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
"Box Drive",
"box-drive",
"2.42.212",
"darwin",
"https://e3.boxcdn.net/desktop/releases/mac/BoxDrive-2.42.212.pkg",
"93550756150c434bc058c30b82352c294a21e978caf436ac99e0a5f431adfb6e",
"com.box.desktop",
boxInstallScriptID,
boxUninstallScriptID,
)

// Apply current migration.
applyNext(t, db)

//
// Check data, insert new entries, e.g. to verify migration is safe.
//
// ...
var scriptContents struct {
InstallScriptContents string `db:"contents"`
Checksum string `db:"md5_checksum"`
}

selectStmt := `
SELECT
sc.contents AS contents,
HEX(sc.md5_checksum) AS md5_checksum
FROM
fleet_library_apps fla
JOIN script_contents sc
ON fla.install_script_content_id = sc.id
WHERE fla.token = ?`

err = sqlx.Get(db, &scriptContents, selectStmt, "figma")
require.NoError(t, err)

expectedContents := `
#!/bin/sh
quit_application() {
local bundle_id="$1"
local timeout_duration=10
# check if the application is running
if ! osascript -e "application id \"$bundle_id\" is running" 2>/dev/null; then
return
fi
local console_user
console_user=$(stat -f "%Su" /dev/console)
if [[ $EUID -eq 0 && "$console_user" == "root" ]]; then
echo "Not logged into a non-root GUI; skipping quitting application ID '$bundle_id'."
return
fi
echo "Quitting application '$bundle_id'..."
# try to quit the application within the timeout period
local quit_success=false
SECONDS=0
while (( SECONDS < timeout_duration )); do
if osascript -e "tell application id \"$bundle_id\" to quit" >/dev/null 2>&1; then
if ! pgrep -f "$bundle_id" >/dev/null 2>&1; then
echo "Application '$bundle_id' quit successfully."
quit_success=true
break
fi
fi
sleep 1
done
if [[ "$quit_success" = false ]]; then
echo "Application '$bundle_id' did not quit."
fi
}
# variables
APPDIR="/Applications/"
TMPDIR=$(dirname "$(realpath $INSTALLER_PATH)")
# extract contents
unzip "$INSTALLER_PATH" -d "$TMPDIR"
# copy to the applications folder
quit_application com.figma.Desktop
sudo [ -d "$APPDIR/Figma.app" ] && sudo mv "$APPDIR/Figma.app" "$TMPDIR/Figma.app.bkp"
sudo cp -R "$TMPDIR/Figma.app" "$APPDIR"
`

expectedChecksum := md5ChecksumScriptContent(expectedContents)

require.Equal(t, expectedContents, scriptContents.InstallScriptContents)
require.Equal(t, expectedChecksum, scriptContents.Checksum)

err = sqlx.Get(db, &scriptContents, selectStmt, "box-drive")
require.NoError(t, err)
require.Equal(t, "echo install", scriptContents.InstallScriptContents)
require.Equal(t, md5ChecksumScriptContent("echo install"), scriptContents.Checksum)
}
Loading

0 comments on commit 863a37a

Please sign in to comment.