47713 auld software update assets migration (#50036)

**Related issue:** Resolves #47713

- [x] Input data is properly validated, `SELECT *` is avoided, SQL
injection is prevented (using placeholders for values in statements), JS
inline code is prevented especially for url redirects, and untrusted
data interpolated into shell scripts/commands is validated against shell
metacharacters.

## Testing

- [x] Added/updated automated tests

- [x] QA'd all new/changed functionality manually

## 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`).


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

* **New Features**
* Added support for tracking available Apple OS update assets and
supported devices.
* Added per-host Apple OS update targets, deadlines, and resolution
status.
* Added configuration options for host target OS versions and deadlines.

* **Database**
* Updated the MySQL schema and migration seed data to include the new
tables and fleet variables, and to reflect updated migration/status
metadata.

* **Tests**
* Added migration tests to validate table creation, constraints,
defaults, and upsert behavior.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Andrew Mellor
2026-07-28 15:42:04 +01:00
committed by GitHub
parent 3d4cf0dfee
commit f1228c873d
5 changed files with 308 additions and 4 deletions
@@ -0,0 +1,62 @@
package tables
import (
"database/sql"
"fmt"
)
func init() {
MigrationClient.AddMigration(Up_20260727083533, Down_20260727083533)
}
func Up_20260727083533(tx *sql.Tx) error {
// apple_software_update_assets caches the set of available OS update
// versions Apple's GDMF service reports for macOS/iOS, refreshed by a
// periodic cron. first_seen_at is only set on insert; updated_at advances
// on every successful fetch even when the version set is unchanged.
_, err := tx.Exec(`
CREATE TABLE apple_software_update_assets (
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
class enum('macos','ios') CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL,
product_version varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL,
build varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '',
posting_date date DEFAULT NULL,
expiration_date date DEFAULT NULL,
supported_devices json NOT NULL,
first_seen_at datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
created_at datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
updated_at datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
PRIMARY KEY (id),
UNIQUE KEY idx_asset_class_version_build (class, product_version, build)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`)
if err != nil {
return fmt.Errorf("creating apple_software_update_assets table: %w", err)
}
// host_mdm_apple_os_updates tracks, per host, the resolved target OS
// version/deadline when automatic enforcement is set to "latest".
// resolved_at is set once the target has been recomputed for the host's
// current team/setting; target_deadline is nullable until resolved.
_, err = tx.Exec(`
CREATE TABLE host_mdm_apple_os_updates (
host_uuid varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL,
software_update_device_id varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '',
target_os_version varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '',
target_deadline datetime(6) DEFAULT NULL,
resolved_at datetime(6) DEFAULT NULL,
created_at datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
updated_at datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
PRIMARY KEY (host_uuid)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`)
if err != nil {
return fmt.Errorf("creating host_mdm_apple_os_updates table: %w", err)
}
return nil
}
func Down_20260727083533(tx *sql.Tx) error {
return nil
}
@@ -0,0 +1,143 @@
package tables
import (
"testing"
"time"
"github.com/stretchr/testify/require"
)
func TestUp_20260727083533(t *testing.T) {
db := applyUpToPrev(t)
// Apply current migration.
applyNext(t, db)
// apple_software_update_assets: insert a row, uniqueness on
// (class, product_version, build).
assetID := execNoErrLastID(t, db, `
INSERT INTO apple_software_update_assets
(class, product_version, build, posting_date, expiration_date, supported_devices)
VALUES
('macos', '15.1', '24B83', '2026-01-01', NULL, '["J123AP"]')`)
require.NotZero(t, assetID)
// expiration_date is nullable; first_seen_at and updated_at are populated by
// their defaults.
var (
gotExpirationDate *time.Time
gotFirstSeenAt time.Time
gotUpdatedAt time.Time
)
err := db.QueryRow(`
SELECT expiration_date, first_seen_at, updated_at
FROM apple_software_update_assets WHERE id = ?`, assetID,
).Scan(&gotExpirationDate, &gotFirstSeenAt, &gotUpdatedAt)
require.NoError(t, err)
require.Nil(t, gotExpirationDate)
require.False(t, gotFirstSeenAt.IsZero())
require.False(t, gotUpdatedAt.IsZero())
_, err = db.Exec(`
INSERT INTO apple_software_update_assets
(class, product_version, build, supported_devices)
VALUES
('macos', '15.1', '24B83', '["J123AP"]')`)
require.Error(t, err, "duplicate (class, product_version, build) should be rejected")
// An upsert on the same (class, product_version, build) — the shape the GDMF
// refresh uses — keeps first_seen_at from the original insert while
// updated_at advances. posting_date is changed so the row is a real update:
// MySQL leaves updated_at alone when no column value actually changes.
execNoErr(t, db, `
INSERT INTO apple_software_update_assets
(class, product_version, build, posting_date, supported_devices)
VALUES
('macos', '15.1', '24B83', '2026-01-02', '["J123AP","J456AP"]')
ON DUPLICATE KEY UPDATE
posting_date = VALUES(posting_date),
supported_devices = VALUES(supported_devices)`)
var (
gotFirstSeenAtAfterUpsert time.Time
gotUpdatedAtAfterUpsert time.Time
)
err = db.QueryRow(`
SELECT first_seen_at, updated_at
FROM apple_software_update_assets WHERE id = ?`, assetID,
).Scan(&gotFirstSeenAtAfterUpsert, &gotUpdatedAtAfterUpsert)
require.NoError(t, err)
require.True(t, gotFirstSeenAt.Equal(gotFirstSeenAtAfterUpsert),
"first_seen_at must not change on upsert")
require.True(t, gotUpdatedAtAfterUpsert.After(gotUpdatedAt),
"updated_at must advance on upsert")
// A different build for the same class/version is allowed.
execNoErr(t, db, `
INSERT INTO apple_software_update_assets
(class, product_version, build, supported_devices)
VALUES
('macos', '15.1', '24B84', '["J123AP"]')`)
// supported_devices is NOT NULL.
_, err = db.Exec(`
INSERT INTO apple_software_update_assets
(class, product_version, build, supported_devices)
VALUES
('ios', '18.1', '', NULL)`)
require.Error(t, err, "NULL supported_devices should be rejected")
// Invalid class tvos is rejected by the ENUM.
_, err = db.Exec(`
INSERT INTO apple_software_update_assets
(class, product_version, build, supported_devices)
VALUES
('tvos', '18.1', '22J1', '["J123AP"]')`)
require.Error(t, err, "class outside the enum should be rejected")
// build defaults to '' when omitted.
iosAssetID := execNoErrLastID(t, db, `
INSERT INTO apple_software_update_assets
(class, product_version, supported_devices)
VALUES
('ios', '18.1', '["iPhone16,1"]')`)
var gotBuild string
err = db.QueryRow(`
SELECT build FROM apple_software_update_assets WHERE id = ?`, iosAssetID,
).Scan(&gotBuild)
require.NoError(t, err)
require.Empty(t, gotBuild)
// host_mdm_apple_os_updates: keyed by host_uuid, defaults apply.
execNoErr(t, db, `
INSERT INTO host_mdm_apple_os_updates (host_uuid)
VALUES ('host-1-uuid')`)
// Scanning the two string columns into non-pointers also asserts they
// default to '' rather than NULL; target_deadline and resolved_at are
// nullable until the target is resolved for the host.
var (
gotTargetOSVersion string
gotSoftwareUpdateDeviceID string
gotTargetDeadline *time.Time
gotResolvedAt *time.Time
gotCreatedAt time.Time
)
err = db.QueryRow(`
SELECT target_os_version, software_update_device_id, target_deadline,
resolved_at, created_at
FROM host_mdm_apple_os_updates WHERE host_uuid = 'host-1-uuid'`,
).Scan(&gotTargetOSVersion, &gotSoftwareUpdateDeviceID, &gotTargetDeadline, &gotResolvedAt, &gotCreatedAt)
require.NoError(t, err)
require.Empty(t, gotTargetOSVersion)
require.Empty(t, gotSoftwareUpdateDeviceID)
require.Nil(t, gotTargetDeadline)
require.Nil(t, gotResolvedAt)
require.False(t, gotCreatedAt.IsZero())
_, err = db.Exec(`
INSERT INTO host_mdm_apple_os_updates (host_uuid)
VALUES ('host-1-uuid')`)
require.Error(t, err, "duplicate host_uuid should be rejected")
}
@@ -0,0 +1,38 @@
package tables
import (
"database/sql"
"fmt"
"time"
"github.com/jmoiron/sqlx"
)
func init() {
MigrationClient.AddMigration(Up_20260727084359, Down_20260727084359)
}
func Up_20260727084359(tx *sql.Tx) error {
insStmt := `
INSERT INTO fleet_variables (
name, is_prefix, created_at
) VALUES
('FLEET_VAR_HOST_TARGET_OS_VERSION', 0, :created_at),
('FLEET_VAR_HOST_TARGET_OS_DEADLINE', 0, :created_at)
`
// use a constant time so that the generated schema is deterministic
createdAt := time.Date(2026, 7, 27, 0, 0, 0, 0, time.UTC)
stmt, args, err := sqlx.Named(insStmt, map[string]any{"created_at": createdAt})
if err != nil {
return fmt.Errorf("failed to prepare insert for FLEET_VAR_HOST_TARGET_OS_VERSION/DEADLINE: %w", err)
}
_, err = tx.Exec(stmt, args...)
if err != nil {
return fmt.Errorf("failed to insert FLEET_VAR_HOST_TARGET_OS_VERSION/DEADLINE into fleet_variables: %w", err)
}
return nil
}
func Down_20260727084359(tx *sql.Tx) error {
return nil
}
@@ -0,0 +1,31 @@
package tables
import (
"testing"
"github.com/stretchr/testify/require"
)
func TestUp_20260727084359(t *testing.T) {
db := applyUpToPrev(t)
var count int
err := db.Get(&count, `SELECT COUNT(*) FROM fleet_variables WHERE name IN ('FLEET_VAR_HOST_TARGET_OS_VERSION', 'FLEET_VAR_HOST_TARGET_OS_DEADLINE')`)
require.NoError(t, err)
require.Equal(t, 0, count)
applyNext(t, db)
err = db.Get(&count, `SELECT COUNT(*) FROM fleet_variables WHERE name IN ('FLEET_VAR_HOST_TARGET_OS_VERSION', 'FLEET_VAR_HOST_TARGET_OS_DEADLINE')`)
require.NoError(t, err)
require.Equal(t, 2, count)
var isPrefix bool
err = db.Get(&isPrefix, `SELECT is_prefix FROM fleet_variables WHERE name = 'FLEET_VAR_HOST_TARGET_OS_VERSION'`)
require.NoError(t, err)
require.False(t, isPrefix)
err = db.Get(&isPrefix, `SELECT is_prefix FROM fleet_variables WHERE name = 'FLEET_VAR_HOST_TARGET_OS_DEADLINE'`)
require.NoError(t, err)
require.False(t, isPrefix)
}
File diff suppressed because one or more lines are too long