Skip to content

Commit b5286a3

Browse files
authored
Set GitOps custom display name for software (#35871)
Implements #33781. GitOps workflow support for custom software display names.
1 parent 3a218ed commit b5286a3

File tree

17 files changed

+298
-4
lines changed

17 files changed

+298
-4
lines changed

cmd/fleetctl/fleetctl/generate_gitops.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1523,6 +1523,10 @@ func (cmd *GenerateGitopsCommand) generateSoftware(filePath string, teamID uint,
15231523
softwareSpec["categories"] = softwareTitle.SoftwarePackage.Categories
15241524
}
15251525

1526+
if softwareTitle.DisplayName != "" {
1527+
softwareSpec["display_name"] = softwareTitle.DisplayName
1528+
}
1529+
15261530
// each package is listed once in software, so we can pull icon directly here
15271531
if downloadIcons && softwareTitle.IconUrl != nil && strings.HasPrefix(*softwareTitle.IconUrl, "/api") {
15281532
fileName := fmt.Sprintf("lib/%s/icons/%s", teamFilename, filenamePrefix+"-icon.png")
@@ -1551,6 +1555,10 @@ func (cmd *GenerateGitopsCommand) generateSoftware(filePath string, teamID uint,
15511555
softwareSpec["categories"] = softwareTitle.AppStoreApp.Categories
15521556
}
15531557

1558+
if softwareTitle.DisplayName != "" {
1559+
softwareSpec["display_name"] = softwareTitle.DisplayName
1560+
}
1561+
15541562
if downloadIcons && softwareTitle.IconUrl != nil && strings.HasPrefix(*softwareTitle.IconUrl, "/api") {
15551563
fileName := fmt.Sprintf("lib/%s/icons/%s", teamFilename, filenamePrefix+"-icon.png")
15561564
path := fmt.Sprintf("../%s", fileName)
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
name: "${TEST_TEAM_NAME}"
2+
team_settings:
3+
secrets:
4+
- secret: "ABC"
5+
features:
6+
enable_host_users: true
7+
enable_software_inventory: true
8+
host_expiry_settings:
9+
host_expiry_enabled: true
10+
host_expiry_window: 30
11+
agent_options:
12+
controls:
13+
policies:
14+
queries:
15+
software:
16+
app_store_apps:
17+
- app_store_id: "12345"
18+
display_name: "This display name is way too long and exceeds the maximum allowed length of 255 characters for the display_name field in the database. This validation is important because the database schema uses VARCHAR(255) and we need to ensure data integrity by catching this error early during YAML parsing before it gets to the database layer where it would cause a database constraint error which is harder to debug"
19+
self_service: true
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
name: "${TEST_TEAM_NAME}"
2+
team_settings:
3+
secrets:
4+
- secret: "ABC"
5+
features:
6+
enable_host_users: true
7+
enable_software_inventory: true
8+
host_expiry_settings:
9+
host_expiry_enabled: true
10+
host_expiry_window: 30
11+
agent_options:
12+
controls:
13+
policies:
14+
queries:
15+
software:
16+
packages:
17+
- url: ${SOFTWARE_INSTALLER_URL}/ruby.deb
18+
display_name: "This display name is way too long and exceeds the maximum allowed length of 255 characters for the display_name field in the database. This validation is important because the database schema uses VARCHAR(255) and we need to ensure data integrity by catching this error early during YAML parsing before it gets to the database layer where it would cause a database constraint error which is harder to debug"
19+
install_script:
20+
path: lib/install_ruby.sh
21+
uninstall_script:
22+
path: lib/uninstall_ruby.sh
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
name: "${TEST_TEAM_NAME}"
2+
team_settings:
3+
secrets:
4+
- secret: "ABC"
5+
features:
6+
enable_host_users: true
7+
enable_software_inventory: true
8+
host_expiry_settings:
9+
host_expiry_enabled: true
10+
host_expiry_window: 30
11+
agent_options:
12+
controls:
13+
policies:
14+
queries:
15+
software:
16+
packages:
17+
- url: ${SOFTWARE_INSTALLER_URL}/ruby.deb
18+
display_name: "Ruby Development Environment"
19+
install_script:
20+
path: lib/install_ruby.sh
21+
uninstall_script:
22+
path: lib/uninstall_ruby.sh
23+
- url: ${SOFTWARE_INSTALLER_URL}/other.deb
24+
self_service: true
25+
display_name: "Custom Package Name"
26+
app_store_apps:
27+
- app_store_id: "1"
28+
display_name: "My Custom VPP App"
29+
self_service: true

cmd/fleetctl/integrationtest/gitops/gitops_enterprise_integration_test.go

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2201,3 +2201,123 @@ team_settings:
22012201
require.NoError(t, err)
22022202
require.Len(t, titles, 0)
22032203
}
2204+
2205+
// TestGitOpsSoftwareDisplayName tests that display names for software packages and VPP apps
2206+
// are properly applied via GitOps.
2207+
func (s *enterpriseIntegrationGitopsTestSuite) TestGitOpsSoftwareDisplayName() {
2208+
t := s.T()
2209+
ctx := context.Background()
2210+
2211+
user := s.createGitOpsUser(t)
2212+
fleetctlConfig := s.createFleetctlConfig(t, user)
2213+
2214+
const (
2215+
globalTemplate = `
2216+
agent_options:
2217+
controls:
2218+
org_settings:
2219+
server_settings:
2220+
server_url: $FLEET_URL
2221+
org_info:
2222+
org_name: Fleet
2223+
secrets:
2224+
policies:
2225+
queries:
2226+
`
2227+
2228+
noTeamTemplate = `name: No team
2229+
controls:
2230+
policies:
2231+
software:
2232+
packages:
2233+
- url: ${SOFTWARE_INSTALLER_URL}/ruby.deb
2234+
display_name: Custom Ruby Name
2235+
`
2236+
2237+
teamTemplate = `
2238+
controls:
2239+
software:
2240+
packages:
2241+
- url: ${SOFTWARE_INSTALLER_URL}/ruby.deb
2242+
display_name: Team Custom Ruby
2243+
queries:
2244+
policies:
2245+
agent_options:
2246+
name: %s
2247+
team_settings:
2248+
secrets: [{"secret":"enroll_secret"}]
2249+
`
2250+
)
2251+
2252+
globalFile, err := os.CreateTemp(t.TempDir(), "*.yml")
2253+
require.NoError(t, err)
2254+
_, err = globalFile.WriteString(globalTemplate)
2255+
require.NoError(t, err)
2256+
err = globalFile.Close()
2257+
require.NoError(t, err)
2258+
2259+
noTeamFile, err := os.CreateTemp(t.TempDir(), "*.yml")
2260+
require.NoError(t, err)
2261+
_, err = noTeamFile.WriteString(noTeamTemplate)
2262+
require.NoError(t, err)
2263+
err = noTeamFile.Close()
2264+
require.NoError(t, err)
2265+
noTeamFilePath := filepath.Join(filepath.Dir(noTeamFile.Name()), "no-team.yml")
2266+
err = os.Rename(noTeamFile.Name(), noTeamFilePath)
2267+
require.NoError(t, err)
2268+
2269+
teamName := uuid.NewString()
2270+
teamFile, err := os.CreateTemp(t.TempDir(), "*.yml")
2271+
require.NoError(t, err)
2272+
_, err = teamFile.WriteString(fmt.Sprintf(teamTemplate, teamName))
2273+
require.NoError(t, err)
2274+
err = teamFile.Close()
2275+
require.NoError(t, err)
2276+
2277+
// Set the required environment variables
2278+
t.Setenv("FLEET_URL", s.Server.URL)
2279+
testing_utils.StartSoftwareInstallerServer(t)
2280+
2281+
// Apply configs
2282+
_ = fleetctl.RunAppForTest(t,
2283+
[]string{"gitops", "--config", fleetctlConfig.Name(), "-f", globalFile.Name(), "-f", noTeamFilePath, "-f", teamFile.Name(), "--dry-run"})
2284+
_ = fleetctl.RunAppForTest(t,
2285+
[]string{"gitops", "--config", fleetctlConfig.Name(), "-f", globalFile.Name(), "-f", noTeamFilePath, "-f", teamFile.Name()})
2286+
2287+
// get the team ID
2288+
team, err := s.DS.TeamByName(ctx, teamName)
2289+
require.NoError(t, err)
2290+
2291+
// Verify display name for no team
2292+
noTeamTitles, _, _, err := s.DS.ListSoftwareTitles(ctx, fleet.SoftwareTitleListOptions{AvailableForInstall: true, TeamID: ptr.Uint(0)},
2293+
fleet.TeamFilter{User: test.UserAdmin})
2294+
require.NoError(t, err)
2295+
require.Len(t, noTeamTitles, 1)
2296+
require.NotNil(t, noTeamTitles[0].SoftwarePackage)
2297+
noTeamTitleID := noTeamTitles[0].ID
2298+
2299+
// Verify the display name is stored in the database for no team
2300+
var noTeamDisplayName string
2301+
mysql.ExecAdhocSQL(t, s.DS, func(q sqlx.ExtContext) error {
2302+
return sqlx.GetContext(ctx, q, &noTeamDisplayName,
2303+
"SELECT display_name FROM software_title_display_names WHERE team_id = ? AND software_title_id = ?",
2304+
0, noTeamTitleID)
2305+
})
2306+
require.Equal(t, "Custom Ruby Name", noTeamDisplayName)
2307+
2308+
// Verify display name for team
2309+
teamTitles, _, _, err := s.DS.ListSoftwareTitles(ctx, fleet.SoftwareTitleListOptions{TeamID: &team.ID}, fleet.TeamFilter{User: test.UserAdmin})
2310+
require.NoError(t, err)
2311+
require.Len(t, teamTitles, 1)
2312+
require.NotNil(t, teamTitles[0].SoftwarePackage)
2313+
teamTitleID := teamTitles[0].ID
2314+
2315+
// Verify the display name is stored in the database for team
2316+
var teamDisplayName string
2317+
mysql.ExecAdhocSQL(t, s.DS, func(q sqlx.ExtContext) error {
2318+
return sqlx.GetContext(ctx, q, &teamDisplayName,
2319+
"SELECT display_name FROM software_title_display_names WHERE team_id = ? AND software_title_id = ?",
2320+
team.ID, teamTitleID)
2321+
})
2322+
require.Equal(t, "Team Custom Ruby", teamDisplayName)
2323+
}

cmd/fleetctl/integrationtest/gitops/software_test.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,10 @@ func TestGitOpsTeamSoftwareInstallers(t *testing.T) {
5757
{"testdata/gitops/team_software_installer_valid_exclude.yml", ""},
5858
{"testdata/gitops/team_software_installer_invalid_unknown_label.yml",
5959
"Please create the missing labels, or update your settings to not refer to these labels."},
60+
// display_name tests
61+
{"testdata/gitops/team_software_installer_with_display_name.yml", ""},
62+
{"testdata/gitops/team_software_installer_display_name_too_long.yml", "display_name is too long (max 255 characters)"},
63+
{"testdata/gitops/team_software_app_store_display_name_too_long.yml", "display_name is too long (max 255 characters)"},
6064
// team tests for setup experience software/script
6165
{"testdata/gitops/team_setup_software_valid.yml", ""},
6266
{"testdata/gitops/team_setup_software_on_package.yml", ""},

ee/server/service/software_installers.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2075,6 +2075,7 @@ func (svc *Service) softwareBatchUpload(
20752075
LabelsExcludeAny: p.LabelsExcludeAny,
20762076
ValidatedLabels: p.ValidatedLabels,
20772077
Categories: p.Categories,
2078+
DisplayName: p.DisplayName,
20782079
}
20792080

20802081
var extraInstallers []*fleet.UploadSoftwareInstallerPayload

ee/server/service/vpp.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ func (svc *Service) BatchAssociateVPPApps(ctx context.Context, teamName string,
8181
LabelsExcludeAny: payload.LabelsExcludeAny,
8282
LabelsIncludeAny: payload.LabelsIncludeAny,
8383
Categories: payload.Categories,
84+
DisplayName: payload.DisplayName,
8485
}, {
8586
AppStoreID: payload.AppStoreID,
8687
SelfService: payload.SelfService,
@@ -89,6 +90,7 @@ func (svc *Service) BatchAssociateVPPApps(ctx context.Context, teamName string,
8990
LabelsExcludeAny: payload.LabelsExcludeAny,
9091
LabelsIncludeAny: payload.LabelsIncludeAny,
9192
Categories: payload.Categories,
93+
DisplayName: payload.DisplayName,
9294
}, {
9395
AppStoreID: payload.AppStoreID,
9496
SelfService: payload.SelfService,
@@ -97,6 +99,7 @@ func (svc *Service) BatchAssociateVPPApps(ctx context.Context, teamName string,
9799
LabelsExcludeAny: payload.LabelsExcludeAny,
98100
LabelsIncludeAny: payload.LabelsIncludeAny,
99101
Categories: payload.Categories,
102+
DisplayName: payload.DisplayName,
100103
}}...)
101104
}
102105

@@ -149,6 +152,7 @@ func (svc *Service) BatchAssociateVPPApps(ctx context.Context, teamName string,
149152
InstallDuringSetup: payload.InstallDuringSetup,
150153
ValidatedLabels: validatedLabels,
151154
CategoryIDs: catIDs,
155+
DisplayName: &payload.DisplayName,
152156
})
153157
}
154158

pkg/spec/gitops.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1236,6 +1236,12 @@ func parseSoftware(top map[string]json.RawMessage, result *GitOps, baseDir strin
12361236
continue
12371237
}
12381238

1239+
// Validate display_name length (matches database VARCHAR(255))
1240+
if len(item.DisplayName) > 255 {
1241+
multiError = multierror.Append(multiError, fmt.Errorf("app_store_id %q display_name is too long (max 255 characters)", item.AppStoreID))
1242+
continue
1243+
}
1244+
12391245
item = item.ResolvePaths(baseDir)
12401246

12411247
result.Software.AppStoreApps = append(result.Software.AppStoreApps, &item)
@@ -1389,6 +1395,12 @@ func parseSoftware(top map[string]json.RawMessage, result *GitOps, baseDir strin
13891395
}
13901396
}
13911397

1398+
// Validate display_name length (matches database VARCHAR(255))
1399+
if len(softwarePackageSpec.DisplayName) > 255 {
1400+
multiError = multierror.Append(multiError, fmt.Errorf("software package %q display_name is too long (max 255 characters)", softwarePackageSpec.URL))
1401+
continue
1402+
}
1403+
13921404
result.Software.Packages = append(result.Software.Packages, softwarePackageSpec)
13931405
}
13941406
}

pkg/spec/gitops_test.go

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1389,6 +1389,65 @@ func TestInvalidSoftwareInstallerHash(t *testing.T) {
13891389
assert.ErrorContains(t, err, "must be a valid lower-case hex-encoded (64-character) SHA-256 hash value")
13901390
}
13911391

1392+
func TestSoftwareDisplayNameValidation(t *testing.T) {
1393+
t.Parallel()
1394+
appConfig := &fleet.EnrichedAppConfig{}
1395+
appConfig.License = &fleet.LicenseInfo{
1396+
Tier: fleet.TierPremium,
1397+
}
1398+
1399+
// Create a string with 256 'a' characters (exceeds 255 limit)
1400+
longDisplayName := strings.Repeat("a", 256)
1401+
1402+
t.Run("package_display_name_too_long", func(t *testing.T) {
1403+
config := getTeamConfig([]string{"name", "software"})
1404+
// Use hash instead of URL to avoid script validation before display_name validation
1405+
config += `name: Test Team
1406+
software:
1407+
packages:
1408+
- hash_sha256: "abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234"
1409+
display_name: "` + longDisplayName + `"
1410+
`
1411+
path, basePath := createTempFile(t, "", config)
1412+
_, err := GitOpsFromFile(path, basePath, appConfig, nopLogf)
1413+
assert.ErrorContains(t, err, "display_name is too long (max 255 characters)")
1414+
})
1415+
1416+
t.Run("app_store_display_name_too_long", func(t *testing.T) {
1417+
config := getTeamConfig([]string{"name", "software"})
1418+
config += `name: Test Team
1419+
software:
1420+
app_store_apps:
1421+
- app_store_id: "12345"
1422+
display_name: "` + longDisplayName + `"
1423+
`
1424+
path, basePath := createTempFile(t, "", config)
1425+
_, err := GitOpsFromFile(path, basePath, appConfig, nopLogf)
1426+
assert.ErrorContains(t, err, "display_name is too long (max 255 characters)")
1427+
})
1428+
1429+
t.Run("valid_display_name", func(t *testing.T) {
1430+
config := getTeamConfig([]string{"name", "software"})
1431+
// Use hash instead of URL to avoid network calls, and no scripts required
1432+
config += `name: Test Team
1433+
software:
1434+
packages:
1435+
- hash_sha256: "abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234"
1436+
display_name: "Custom Package Name"
1437+
app_store_apps:
1438+
- app_store_id: "12345"
1439+
display_name: "Custom VPP App Name"
1440+
`
1441+
path, basePath := createTempFile(t, "", config)
1442+
result, err := GitOpsFromFile(path, basePath, appConfig, nopLogf)
1443+
require.NoError(t, err)
1444+
require.Len(t, result.Software.Packages, 1)
1445+
assert.Equal(t, "Custom Package Name", result.Software.Packages[0].DisplayName)
1446+
require.Len(t, result.Software.AppStoreApps, 1)
1447+
assert.Equal(t, "Custom VPP App Name", result.Software.AppStoreApps[0].DisplayName)
1448+
})
1449+
}
1450+
13921451
func TestWebhookPolicyIDsValidation(t *testing.T) {
13931452
t.Parallel()
13941453

0 commit comments

Comments
 (0)