Skip to content

Self-service categories - migration - #46488

Merged
jkatz01 merged 7 commits into
feat/39018-self-service-categoriesfrom
46390-categories-migration
May 29, 2026
Merged

Self-service categories - migration#46488
jkatz01 merged 7 commits into
feat/39018-self-service-categoriesfrom
46390-categories-migration

Conversation

@jkatz01

@jkatz01 jkatz01 commented May 29, 2026

Copy link
Copy Markdown
Member

Related issue: Resolves #46390
Migration to add a non nullable team_id column. I chose this approach so that server code doesn't have to deal with a potentially null team id for categories. It first adds team_id=0 to all the existing categories, then duplicates that for every fleet so that default categories can be edited and deleted by admins.

It also renames the default categories to include the emojis in their name, which required updating some test expectations, and also mapping default names to the new ones for Fleet maintained apps. If we don't do that, FMA's manifests would have to all be updated right after 4.87 releases and every user would have to migrate immediately. This would also break existing gitops files if the names aren't mapped. The alternative would be to keep the names unchanged, and add custom logic in various places to insert the emojis in the backend and frontend.

Checklist for submitter

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

Testing

Database migrations

  • Checked schema for all modified table for columns that will auto-update timestamps during migration.
  • Confirmed that updating the timestamps is acceptable, and will not cause unwanted side effects.
  • Ensured the correct collation is explicitly set for character columns (COLLATE utf8mb4_unicode_ci).

jkatz01 added 6 commits May 28, 2026 18:56
- add LegacySoftwareCategoryNames map + TranslateLegacySoftwareCategoryNames
  helper in server/fleet/software.go; call it inside GetSoftwareCategoryIDs
  and GetSoftwareCategoryNameToIDMap so callers passing pre-migration plain
  names (FMA inputs, existing admin YAMLs) still resolve to the renamed rows
- testing_utils schema loader: pass --default-character-set=utf8mb4 to the
  mysql CLI; without it the seeded emoji rows in schema.sql get double-encoded
  on import
- update affected test assertions and fixtures to expect the emoji-prefixed
  category names directly
- nolint:gosec G115 on uint conversions in the migration test
Schema.sql conflict was due to migration_status_tables row from the merged
branch's migrations; bumped 20260529184804 to 20260529201240 and regenerated
schema.sql so all merged migrations appear in order.
@codecov

codecov Bot commented May 29, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 81.44330% with 18 lines in your changes missing coverage. Please review.
✅ Project coverage is 66.79%. Comparing base (4504b11) to head (2b89485).
⚠️ Report is 11 commits behind head on feat/39018-self-service-categories.

Files with missing lines Patch % Lines
...es/20260529203429_AddTeamIDToSoftwareCategories.go 78.04% 10 Missing and 8 partials ⚠️
Additional details and impacted files
@@                         Coverage Diff                         @@
##           feat/39018-self-service-categories   #46488   +/-   ##
===================================================================
  Coverage                               66.78%   66.79%           
===================================================================
  Files                                    2804     2805    +1     
  Lines                                  223567   223664   +97     
  Branches                                11308    11308           
===================================================================
+ Hits                                   149310   149389   +79     
- Misses                                  60695    60706   +11     
- Partials                                13562    13569    +7     
Flag Coverage Δ
backend 68.52% <81.44%> (+<0.01%) ⬆️
backend-activity 86.35% <ø> (ø)

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@jkatz01

jkatz01 commented May 29, 2026

Copy link
Copy Markdown
Member Author

Some tests are failing due to unrelated reasons. The fix for them is not in this branch. I updated feat/39018-self-service-categories so hopefully that won't happen again.

    integration_mdm_profiles_test.go:8397: 
        	Error Trace:	/home/runner/work/fleet/fleet/server/service/integration_mdm_profiles_test.go:8397
        	            				/home/runner/work/fleet/fleet/server/service/integration_mdm_profiles_test.go:8459
        	            				/home/runner/work/fleet/fleet/server/service/integration_mdm_profiles_test.go:8342
        	Error:      	Received unexpected error:
        	            	scep exchange: parse pki response: pkcs7: signing time "2026-05-29T21:01:30Z" is outside of certificate validity "2016-05-29T13:47:05Z" to "2026-05-29T13:47:08Z"
        	Test:       	TestIntegrationsMDM/TestWindowsUserSCEPProfile
        	Messages:   	SCEP exchange for "w911794ec-5ba1-11f1-9968-66727c44e564" failed
ts=2026-05-29T21:01:30Z level=debug msg="cleanup orphaned software titles" rows_affected=0 took=494.454µs
    --- FAIL: TestIntegrationsMDM/TestWindowsUserSCEPProfile (0.20s)

=== FAIL: ee/orbit/pkg/scep TestClient_FetchCert/bad_challenge_password (0.45s)
    scep_test.go:165: 
        	Error Trace:	/home/runner/work/fleet/fleet/ee/orbit/pkg/scep/scep_test.go:165
        	Error:      	Error "parse PKIMessage response: pkcs7: signing time \"2026-05-29T20:59:26Z\" is outside of certificate validity \"2016-05-29T13:47:05Z\" to \"2026-05-29T13:47:08Z\"" does not contain "PKIMessage CSR request failed"
        	Test:       	TestClient_FetchCert/bad_challenge_password
        	Messages:   	FetchAndSaveCert should fail with bad challenge password
    --- FAIL: TestClient_FetchCert/bad_challenge_password (0.45s)

@qodo-free-for-open-source-projects

Copy link
Copy Markdown

CI Feedback 🧐

A test triggered by this PR failed. Here is an AI-generated analysis of the failure:

Action: aggregate-result

Failed stage: Check for failures [❌]

Failed test name: main-mysql8.0.44, integration-mdm-mysql8.0.44

Failure summary:

The action failed because the status aggregation step detected failed test jobs and exited with code
1.
- The status file ./main-mysql8.0.44-status/status contained fail, so job main-mysql8.0.44 was
marked as failed.
- The status file ./integration-mdm-mysql8.0.44-status/status contained fail, so
job integration-mdm-mysql8.0.44 was marked as failed.
- The script then printed ❌ One or more test
jobs failed: main-mysql8.0.44, integration-mdm-mysql8.0.44 and exited 1, producing ##[error]Process
completed with exit code 1.

Relevant error logs:
1:  ##[group]Runner Image Provisioner
2:  Hosted Compute Agent
...

119:  Artifact download completed successfully.
120:  Extracting artifact entry: /home/runner/work/fleet/fleet/vuln-mysql8.0.44-status/status
121:  Extracting artifact entry: /home/runner/work/fleet/fleet/fast-status/status
122:  Artifact download completed successfully.
123:  Artifact download completed successfully.
124:  Extracting artifact entry: /home/runner/work/fleet/fleet/integration-core-mysql8.0.44-status/status
125:  Extracting artifact entry: /home/runner/work/fleet/fleet/main-mysql8.0.44-status/status
126:  Artifact download completed successfully.
127:  Artifact download completed successfully.
128:  Extracting artifact entry: /home/runner/work/fleet/fleet/integration-enterprise-mysql8.0.44-status/status
129:  Artifact download completed successfully.
130:  Extracting artifact entry: /home/runner/work/fleet/fleet/mysql-mysql8.0.44-status/status
131:  Artifact download completed successfully.
132:  Total of 10 artifact(s) downloaded
133:  Download artifact has finished successfully
134:  ##[group]Run failed_tests=""
135:  �[36;1mfailed_tests=""�[0m
136:  �[36;1mstatus_count=0�[0m
137:  �[36;1m# Find all status files (they are in directories like 'fleetctl-mysql8.0.44-status/status')�[0m
138:  �[36;1mfor status_file in $(find ./ -type f -name 'status'); do�[0m
139:  �[36;1m  status_count=$((status_count + 1))�[0m
140:  �[36;1m  # Extract test name from parent directory (e.g., 'fleetctl-mysql8.0.44-status')�[0m
141:  �[36;1m  test_dir=$(basename $(dirname "$status_file"))�[0m
142:  �[36;1m  # Remove '-status' suffix to get the test name�[0m
143:  �[36;1m  test_name="${test_dir%-status}"�[0m
144:  �[36;1m  status_content=$(cat "$status_file")�[0m
145:  �[36;1m  echo "Processing: $status_file (Test: $test_name) with status content: $status_content"�[0m
146:  �[36;1m  if grep -q "fail" "$status_file"; then�[0m
147:  �[36;1m    echo "  ❌ Test failed: $test_name"�[0m
148:  �[36;1m    failed_tests="${failed_tests}${test_name}, "�[0m
149:  �[36;1m  else�[0m
150:  �[36;1m    echo "  ✅ Test passed: $test_name"�[0m
151:  �[36;1m  fi�[0m
152:  �[36;1mdone�[0m
153:  �[36;1mif [[ $status_count -eq 0 ]]; then�[0m
154:  �[36;1m  echo "❌ ERROR: No status files found! This indicates a workflow issue."�[0m
155:  �[36;1m  exit 1�[0m
156:  �[36;1mfi�[0m
157:  �[36;1mif [[ -n "$failed_tests" ]]; then�[0m
158:  �[36;1m  echo "❌ One or more test jobs failed: ${failed_tests%, }"�[0m
159:  �[36;1m  exit 1�[0m
160:  �[36;1mfi�[0m
161:  �[36;1mecho "✅ All test jobs succeeded."�[0m
162:  shell: /usr/bin/bash --noprofile --norc -e -o pipefail {0}
163:  ##[endgroup]
164:  Processing: ./integration-core-mysql8.0.44-status/status (Test: integration-core-mysql8.0.44) with status content: success
165:  ✅ Test passed: integration-core-mysql8.0.44
166:  Processing: ./vuln-mysql8.0.44-status/status (Test: vuln-mysql8.0.44) with status content: success
167:  ✅ Test passed: vuln-mysql8.0.44
168:  Processing: ./service-mysql8.0.44-status/status (Test: service-mysql8.0.44) with status content: success
169:  ✅ Test passed: service-mysql8.0.44
170:  Processing: ./main-mysql8.0.44-status/status (Test: main-mysql8.0.44) with status content: fail
171:  ❌ Test failed: main-mysql8.0.44
172:  Processing: ./fleetctl-mysql8.0.44-status/status (Test: fleetctl-mysql8.0.44) with status content: success
173:  ✅ Test passed: fleetctl-mysql8.0.44
174:  Processing: ./integration-enterprise-mysql8.0.44-status/status (Test: integration-enterprise-mysql8.0.44) with status content: success
175:  ✅ Test passed: integration-enterprise-mysql8.0.44
176:  Processing: ./fast-status/status (Test: fast) with status content: success
177:  ✅ Test passed: fast
178:  Processing: ./mysql-mysql8.0.44-status/status (Test: mysql-mysql8.0.44) with status content: success
179:  ✅ Test passed: mysql-mysql8.0.44
180:  Processing: ./integration-mdm-mysql8.0.44-status/status (Test: integration-mdm-mysql8.0.44) with status content: fail
181:  ❌ Test failed: integration-mdm-mysql8.0.44
182:  Processing: ./scripts-status/status (Test: scripts) with status content: success
183:  ✅ Test passed: scripts
184:  ❌ One or more test jobs failed: main-mysql8.0.44, integration-mdm-mysql8.0.44
185:  ##[error]Process completed with exit code 1.
186:  Post job cleanup.

@jkatz01
jkatz01 marked this pull request as ready for review May 29, 2026 21:10
@jkatz01
jkatz01 requested a review from a team as a code owner May 29, 2026 21:10

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude Code Review

This repository is configured for manual code reviews. Comment @claude review to trigger a review and subscribe this PR to future pushes, or @claude review once for a one-time review.

Tip: disable this comment in your organization's Code Review settings.

@jkatz01

jkatz01 commented May 29, 2026

Copy link
Copy Markdown
Member Author

@claude review once

Comment on lines 6766 to 6770
return []uint{}, nil
}
names = fleet.TranslateLegacySoftwareCategoryNames(names)

stmt := `SELECT id FROM software_categories WHERE name IN (?)`

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Both GetSoftwareCategoryIDs (software.go:6766) and GetSoftwareCategoryNameToIDMap (software.go:6789) query software_categories by name with no team_id filter, but the migration backfills N+1 rows per default name (team_id=0 + one per pre-existing team). After upgrading a deployment that has any teams, GetSoftwareCategoryIDs returns N+1 IDs per requested name, breaking the len(catIDs) == len(payload.Categories) validation at 5 call sites and rejecting valid category assignments with "some or all of the categories provided don't exist" (ee/server/service/software_installers.go:411, :2723; ee/server/service/vpp.go:348, :936, :1276). GetSoftwareCategoryNameToIDMap silently overwrites in its result loop, so FMA installer category links (ee/server/service/maintained_apps.go:187) end up pointing at a random team's category row — silent cross-team data corruption. Both queries need a team_id parameter and filter (with a team_id=0 fallback for the legacy plain-name translation).

Extended reasoning...

Why this fails after the migration

The new migration's backfill step at server/datastore/mysql/migrations/tables/20260529203429_AddTeamIDToSoftwareCategories.go does:

INSERT INTO software_categories (name, team_id)
SELECT sc.name, t.id FROM software_categories sc
CROSS JOIN teams t WHERE sc.team_id = 0

So in a production upgrade with N existing teams, each default category name (e.g. 🌎 Browsers) ends up with N+1 rows: one at team_id=0 and one per pre-existing team. The migration test itself asserts this — assertTeamCategories requires "team %d should have all 6 default categories".

Failure mode 1 — length-mismatch validation rejects valid input

GetSoftwareCategoryIDs at server/datastore/mysql/software.go:6770:

stmt := `SELECT id FROM software_categories WHERE name IN (?)`

No team_id predicate. With N≥1 teams, SELECT id ... WHERE name IN ('🌎 Browsers') returns N+1 rows. All five callers then do:

catIDs, err := svc.ds.GetSoftwareCategoryIDs(ctx, payload.Categories)
if len(catIDs) != len(payload.Categories) {
    return ctxerr.Wrap(..., "some or all of the categories provided don't exist")
}

at ee/server/service/software_installers.go:411, :2723; ee/server/service/vpp.go:348, :936, :1276. Length never matches → BadRequest for any installer category assignment.

Failure mode 2 — silent cross-team data corruption

GetSoftwareCategoryNameToIDMap (software.go:6789-6816) has the same name-only query, then:

for _, cat := range categories {
    result[cat.Name] = cat.ID
}

When the SELECT returns N+1 rows for the same name, this loop overwrites — whichever row MySQL returns last wins (no ORDER BY, so effectively non-deterministic). The follow-up legacy-translation loop then propagates that arbitrary ID under the plain-name key too.

ee/server/service/maintained_apps.go:187 calls this for FMA installer category linking. The link rows in software_installer_software_categories get inserted pointing at the wrong team's category row — invisible failure. The UX guarantee from the test ("deleting team A's category cascades to team A's installer links only") silently breaks.

Step-by-step proof

  1. Production deploys 4.86 with 3 teams (IDs 1, 2, 3). software_categories has 6 rows (team_id=0).
  2. Migration 20260529203429 runs.
  3. After backfill, software_categories contains 4×6=24 rows. SELECT * FROM software_categories WHERE name = '🌎 Browsers' returns 4 rows (team_id=0,1,2,3 with IDs 2, 8, 14, 20 for instance).
  4. Admin edits a software installer on team 1, sends PATCH /api/.../titles/X/package with categories=['🌎 Browsers'].
  5. updateSoftwareInstaller calls GetSoftwareCategoryIDs(ctx, ['🌎 Browsers']) → returns [2, 8, 14, 20] (len=4).
  6. len(catIDs) != len(payload.Categories) → 4 != 1 → returns 400 "some or all of the categories provided don't exist".
  7. Identically for VPP batch-associate and in-house installer flows.

For maintained_apps.go: an FMA installer is created on team 1 with Categories=['💻 Productivity']. GetSoftwareCategoryNameToIDMap runs the bare query, gets rows for team_id=0/1/2/3, loops and overwrites. The final result['💻 Productivity'] is whatever MySQL returned last (likely team_id=3's row, ID 21). The installer's link row gets inserted with software_category_id=21, but the installer is on team 1. If team 3 is later deleted, the FK cascade removes the link — even though the installer is on team 1.

Why CI didn't catch it

schema.sql is a post-migration dump containing only the 6 team_id=0 rows. Integration tests create teams after loading schema.sql, and NewTeam (server/datastore/mysql/teams.go:33) has no per-team category backfill — that's a separate gap. Only the migration itself, against pre-existing teams in a production database, creates the per-team copies. The migration test proves backfill happens, but doesn't exercise these getters.

Fix

Both GetSoftwareCategoryIDs and GetSoftwareCategoryNameToIDMap need a team_id parameter, the SELECT needs AND team_id = ?, and the legacy plain-name translation path needs a team_id=0 fallback (since the renamed default rows live at team_id=0). All five software_installers.go/vpp.go call sites and the maintained_apps.go caller need to thread the relevant team through.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is valid feedback, but it will be addressed in the next PR for the API endpoints for #46391

Claude:

Point 1 (lookup signatures):** Endpoints branch has GetSoftwareCategoryIDs(ctx, teamID uint, names) and GetSoftwareCategoryNameToIDMap(ctx, teamID uint, names). All 5 call sites in software_installers.go / vpp.go / maintained_apps.go pass the team_id through. Plus the LegacySoftwareCategoryNames map gives the dual-keyed result we already discussed for the plain→emoji translation fallback. So the multi-row collapse can't happen there.

Comment on lines +67 to +81
if _, err := tx.Exec(`
INSERT INTO software_categories (name, team_id)
SELECT sc.name, t.id
FROM software_categories sc
CROSS JOIN teams t
WHERE sc.team_id = 0
ORDER BY t.id, FIELD(sc.name,
'🌎 Browsers',
'👬 Communication',
'🧰 Developer tools',
'💻 Productivity',
'🔐 Security',
'🛟 Support')
`); err != nil {
return errors.Wrap(err, "backfilling per-fleet default categories")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 The migration's second step backfills 6 default categories per team via CROSS JOIN teams, but ds.NewTeam (server/datastore/mysql/teams.go:33) has no matching hook, so teams created after this migration runs won't have their own copies of the defaults. This leaves the schema invariant 'every team has its own copies of the 6 defaults' broken from day one for any post-migration team — the PR description's promise that defaults are duplicated 'for every fleet so that default categories can be edited and deleted by admins' holds only for teams that existed at migration time. Marked as a nit because no read path enforces team_id yet (categories continue to resolve to the team_id=0 row by name), so this isn't user-visible until per-team isolation lands; suggest pairing the team-creation backfill with that follow-up.

Extended reasoning...

What the migration sets up. This PR adds team_id to software_categories, renames the 6 defaults to emoji-prefixed names at team_id=0, then issues a one-time INSERT ... SELECT ... CROSS JOIN teams that duplicates those 6 defaults for every team that exists at migration time, and re-points existing installer/VPP/in-house link rows from the team_id=0 row to the team's own row. The migration test (20260529203429_AddTeamIDToSoftwareCategories_test.go) verifies this for two pre-existing teams.

What it doesn't set up. ds.NewTeam (server/datastore/mysql/teams.go:33-67) still only INSERTs into the teams table and calls saveTeamSecretsDB. No hook in this PR mirrors the migration's per-team backfill at team-creation time. NewSoftwareCategory (software.go:6752) only accepts a name (no team_id argument), so no API surface in the PR can create per-team default rows either.

Step-by-step. Apply the migration on a fresh dev DB → 6 rows at team_id=0 plus N×6 rows for the N existing teams. Now POST /api/latest/fleet/teams to create team_new. Re-query SELECT name FROM software_categories WHERE team_id = team_new.idzero rows. The migration test exercises pre-existing teams only; if you add an execNoErrLastID(t, db,``INSERT INTO teams (name) VALUES (?)``, "team-c") after applyNext and re-run assertTeamCategories(teamC), it fails: Len(rows, 6) returns 0.

Why the refutation's scope point is fair. Today's read paths (GetSoftwareCategoryIDs, GetSoftwareCategoryNameToIDMap in software.go) don't filter by team_id — they resolve names to whichever row matches, which in practice is the team_id=0 row. So a brand-new team without its own copies still resolves "💻 Productivity" to the global id=1, and installer/VPP/in-house links land on the shared row. No end-user-visible breakage yet.

Why it's still worth flagging. The migration explicitly establishes the invariant 'every team has its own 6 defaults'. The PR description states 'duplicates that for every fleet so that default categories can be edited and deleted by admins' — a contract that silently fails for every post-migration team. The moment a follow-up PR lands that (a) filters reads by team_id, (b) adds an admin edit/delete category API, or (c) re-runs a similar name-join re-pointing pass, new-team installer links have nothing to bind to and the failure becomes user-visible (or worse — editing the "team's" default would mutate the shared team_id=0 row across every team). Pairing the team-creation backfill with that follow-up keeps the invariant intact from the moment per-team isolation matters.

Suggested fix. Extend ds.NewTeam to insert the 6 default rows for the new team's id within the same transaction, mirroring the migration's backfill:

INSERT INTO software_categories (name, team_id)
SELECT name, ? FROM software_categories WHERE team_id = 0

Place it inside the existing withRetryTxx callback alongside saveTeamSecretsDB so a partial creation can't leave a team without its defaults.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is valid feedback, but it will be addressed in the next PR for the API endpoints for #46391

Claude:

Point 2 (NewTeam seeding):** Endpoints branch's ds.NewTeam ends with ds.BatchNewSoftwareCategories(ctx, team.ID, fleet.DefaultSelfServiceCategoryNames) so every team created post-migration is automatically seeded with its own copy of the defaults.

@jkatz01
jkatz01 merged commit 57d78c5 into feat/39018-self-service-categories May 29, 2026
38 of 41 checks passed
@jkatz01
jkatz01 deleted the 46390-categories-migration branch May 29, 2026 22:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants