**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 -->
200 lines
5.7 KiB
Go
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
|
|
}
|