Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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
Comment thread
MagnusHJensen marked this conversation as resolved.
`)
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
}
Original file line number Diff line number Diff line change
@@ -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")
}
Original file line number Diff line number Diff line change
@@ -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
}
Original file line number Diff line number Diff line change
@@ -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)
}
Loading
Loading