Files
fleet/cmd/maintained-apps/main.go
T
Allen Houchins a972ca21b0 Add "Support" default software category (#47923)
**Related issue:** Resolves #48064

Adds a new default self-service software category, rendered as **🛟
Support**, alongside the existing six defaults (Browsers, Communication,
Developer tools, Productivity, Security, Utilities).

## What changed

**Backend (Go)**
- `server/fleet/software.go` — added `🛟 Support` to
`DefaultSelfServiceCategoryNames` (seeds new fleets) and `"Support": "🛟
Support"` to `LegacySoftwareCategoryNames` (so GitOps/FMA manifests can
reference the non-emoji `Support`).
- New migration `20260619120000_AddSupportSoftwareCategory` — inserts
the global default (`team_id=0`) and backfills every existing fleet.
Timestamps pinned for deterministic schema dumps; `INSERT IGNORE` guards
the `(team_id, name)` unique key.
- `schema.sql` regenerated via `tools/dbutils`.
- `cmd/maintained-apps/main.go` — added `Support` to the FMA validator
allowlist.

**Frontend**
- `frontend/interfaces/software.ts` — added `"Support"` to the
`SoftwareCategory` union.
- `frontend/pages/hosts/details/cards/Software/SelfService/helpers.ts` —
added `{ label: "🛟 Support", value: "Support" }` to the fallback list.

**Docs**
- `docs/Configuration/yaml-files.md` — documented `Support` as a
supported GitOps category.

## Note on sort order
`ListSoftwareCategories` does `ORDER BY name` under
`utf8mb4_unicode_ci`, which sorts by the word after the (ignorable)
emoji. `🛟 Support` is therefore placed between `🔐 Security` and `🛠️
Utilities`.

# Checklist for submitter

- [x] Changes file added for user-visible changes in `changes/`.

- [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

Verified against a dockerized MySQL:
- Migration test `TestUp_20260619120000`
- `TestSoftware/SoftwareCategoryCRUD` (order-sensitive assertion)
- `TestSelfServiceCategoriesCRUD` + `TestDeviceSelfServiceCategories`
integration tests
- `cmd/maintained-apps` tests, ee categories test, `go vet`, `make
lint-go-incremental` (0 issues)
- `tools/dbutils` schema regeneration matches

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

## New Fleet configuration settings

- [x] Verified the setting is documented (GitOps `categories` supported
values in `docs/Configuration/yaml-files.md`).


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

## Summary by CodeRabbit

* **New Features**
* Introduced the "🛟 Support" category as a new self-service software
classification option. Users can now better organize support-related
applications within their software catalog. The category is available
globally across all teams, providing improved organization and discovery
capabilities for support applications alongside utilities and other
existing software categories.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-06-23 09:41:08 -05:00

200 lines
5.7 KiB
Go

package main
import (
"bytes"
"context"
"encoding/json"
"flag"
"fmt"
"log/slog"
"os"
"path"
"slices"
"strings"
maintained_apps "github.com/fleetdm/fleet/v4/ee/maintained-apps"
"github.com/fleetdm/fleet/v4/ee/maintained-apps/ingesters/homebrew"
"github.com/fleetdm/fleet/v4/ee/maintained-apps/ingesters/winget"
"github.com/fleetdm/fleet/v4/pkg/file"
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
)
func main() {
slugPtr := flag.String("slug", "", "app slug")
debugPtr := flag.Bool("debug", false, "enable debug logging")
flag.Parse()
ctx := context.Background()
logLevel := slog.LevelInfo
if *debugPtr {
logLevel = slog.LevelDebug
}
logger := slog.New(slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{Level: logLevel}))
logger.InfoContext(ctx, "starting maintained app ingestion")
ingesters := map[string]maintained_apps.Ingester{
"ee/maintained-apps/inputs/homebrew": homebrew.IngestApps,
"ee/maintained-apps/inputs/winget": winget.IngestApps,
}
for inputDir, ingest := range ingesters {
apps, err := ingest(ctx, logger, inputDir, *slugPtr)
if err != nil {
panic(err)
}
for _, app := range apps {
if app.IsEmpty() {
logger.InfoContext(ctx, "skipping manifest update due to empty output", "slug", app.Slug)
continue
}
if err := processOutput(ctx, app); err != nil {
logger.ErrorContext(ctx, "failed to process maintained app output", "err", err)
}
}
}
}
func processOutput(ctx context.Context, app *maintained_apps.FMAManifestApp) error {
// validate categories before writing any files
if err := validateCategories(ctx, app); err != nil {
// Make the validation failure very obvious on stderr.
fmt.Fprintf(
os.Stderr,
"maintained-apps: fatal error processing %s: %v\n",
app.Slug,
err,
)
// Wrap so callers still see a proper error.
return ctxerr.Wrap(ctx, err, "validating categories")
}
if err := updateAppsListFile(ctx, app); err != nil {
return ctxerr.Wrap(ctx, err, "updating apps list file")
}
app.UniqueIdentifier = "" // make sure we don't leak unique_identifier into individual app manifests
outFile := maintained_apps.FMAManifestFile{
Versions: []*maintained_apps.FMAManifestApp{app},
Refs: map[string]string{app.UninstallScriptRef: app.UninstallScript, app.InstallScriptRef: app.InstallScript},
}
var buf bytes.Buffer
encoder := json.NewEncoder(&buf)
encoder.SetEscapeHTML(false)
encoder.SetIndent("", " ")
if err := encoder.Encode(outFile); err != nil {
return ctxerr.Wrap(ctx, err, "marshaling output app manifest")
}
outBytes := buf.Bytes()
outDir := path.Join(maintained_apps.OutputPath, app.SlugAppName())
if err := os.MkdirAll(outDir, os.ModePerm); err != nil {
return ctxerr.Wrap(ctx, err)
}
outFilePath := path.Join(maintained_apps.OutputPath, fmt.Sprintf("%s.json", app.Slug))
outFileExists, err := file.Exists(outFilePath)
if err != nil {
return ctxerr.Wrap(ctx, err, "checking if output json file exists")
}
// Overwrite the file unless frozen, since right now we're only caring about 1 version (latest). If we
// care about previous data, it will be in our Git history.
if !app.Frozen || !outFileExists {
if err := os.WriteFile(outFilePath, outBytes, 0o644); err != nil {
return ctxerr.Wrap(ctx, err, "writing output json file")
}
}
return nil
}
// Match types in frontend/interfaces/software.ts
var allowedCategories = map[string]struct{}{
"Browsers": {},
"Communication": {},
"Developer tools": {},
"Productivity": {},
"Security": {},
"Support": {},
"Utilities": {},
}
func allowedCategoriesString() string {
cats := make([]string, 0, len(allowedCategories))
for c := range allowedCategories {
cats = append(cats, c)
}
slices.Sort(cats)
return strings.Join(cats, ", ")
}
// validateCategories ensures every category on the app is one of the supported values.
func validateCategories(ctx context.Context, app *maintained_apps.FMAManifestApp) error {
for _, c := range app.DefaultCategories {
if _, ok := allowedCategories[c]; !ok {
return ctxerr.New(ctx, fmt.Sprintf(
"invalid category %q for slug %s (allowed: %s)",
c, app.Slug, allowedCategoriesString(),
))
}
}
return nil
}
func updateAppsListFile(ctx context.Context, outApp *maintained_apps.FMAManifestApp) error {
appListFilePath := path.Join(maintained_apps.OutputPath, "apps.json")
inputJson, err := os.ReadFile(appListFilePath)
if err != nil {
return ctxerr.Wrap(ctx, err, "reading output apps list file")
}
var outputAppsFile maintained_apps.FMAListFile
if err := json.Unmarshal(inputJson, &outputAppsFile); err != nil {
return ctxerr.Wrap(ctx, err, "unmarshaling output apps list file")
}
var found bool
for _, a := range outputAppsFile.Apps {
if a.Slug == outApp.Slug {
found = true
break
}
}
if !found {
platform := outApp.Platform()
if platform == "" {
return ctxerr.New(ctx, fmt.Sprintf("invalid platform found for slug %s", outApp.Slug))
}
outputAppsFile.Apps = append(outputAppsFile.Apps, maintained_apps.FMAListFileApp{
Name: outApp.Name,
Slug: outApp.Slug,
Platform: platform,
UniqueIdentifier: outApp.UniqueIdentifier,
})
// Keep existing order
slices.SortFunc(outputAppsFile.Apps, func(a, b maintained_apps.FMAListFileApp) int { return strings.Compare(a.Slug, b.Slug) })
var buf bytes.Buffer
encoder := json.NewEncoder(&buf)
encoder.SetEscapeHTML(false)
encoder.SetIndent("", " ")
if err := encoder.Encode(outputAppsFile); err != nil {
return ctxerr.Wrap(ctx, err, "marshaling updated output apps file")
}
updatedFile := buf.Bytes()
if err := os.WriteFile(appListFilePath, updatedFile, 0o644); err != nil {
return ctxerr.Wrap(ctx, err, "writing updated output apps file")
}
}
return nil
}