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:
+62
@@ -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
|
||||
}
|
||||
+143
@@ -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")
|
||||
}
|
||||
+38
@@ -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
|
||||
}
|
||||
+31
@@ -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
Reference in New Issue
Block a user