From 4dd822f07711bea963fb62f6e51609c6c37021eb Mon Sep 17 00:00:00 2001 From: Neil Martin Date: Mon, 11 May 2026 11:08:12 +0100 Subject: [PATCH] feat(backup): add inventory preload CSV export and app store app backup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add backup support for three resource types: - Inventory preload records via single CSV download (GET /v2/inventory-preload/csv) rather than paginated JSON list+get, writing inventory-preloads/inventory-preload-all.csv - Mac App Store apps (classic-mac-apps → mac-apps/) - Mobile device apps (classic-mobile-apps → mobile-apps/) Also fixes the filter guard in runBackup to allow non-standard filter names (inventory-preloads, blueprints, compliance-benchmarks) that are handled outside BackupResources. Adds isKnownBackupFilter and nonStandardBackupFilters to pro_resources.go so BackupFilterNames stays accurate for shell completion. Adds serialNumber as the name field override for inventory-preloads so --name lookups work on the generated command. Closes #200 Closes #201 Co-Authored-By: Claude Sonnet 4.6 --- generator/parser/parser.go | 3 + .../commands/pro/generated/backup_registry.go | 2 +- .../pro/generated/inventory_preloads.go | 18 ++-- internal/commands/pro_backup.go | 80 +++++++++++++++--- internal/commands/pro_backup_test.go | 83 +++++++++++++++++++ internal/commands/pro_resources.go | 42 +++++++++- internal/commands/pro_resources_test.go | 8 +- 7 files changed, 210 insertions(+), 26 deletions(-) diff --git a/generator/parser/parser.go b/generator/parser/parser.go index b1f0de1c..3a2f8c71 100644 --- a/generator/parser/parser.go +++ b/generator/parser/parser.go @@ -216,6 +216,9 @@ var resourceNameFieldOverrides = map[string]string{ // (DeleteUserCommand, UnlockUserAccountCommand) that live in the same // spec as request bodies. Force-clear it. "mdm-commands": "", + // inventory-preloads records are keyed by serialNumber, not a "name" field. + // Override so --name lookups and backup file naming both use serial number. + "inventory-preloads": "serialNumber", } // resourceNameLookupPathOverrides maps resource names to an alternate list path diff --git a/internal/commands/pro/generated/backup_registry.go b/internal/commands/pro/generated/backup_registry.go index 44f87a57..ea5679b6 100644 --- a/internal/commands/pro/generated/backup_registry.go +++ b/internal/commands/pro/generated/backup_registry.go @@ -112,7 +112,7 @@ var BackupEndpoints = map[string]BackupEndpoint{ "enrollment-settings": {ListPath: "/v3/enrollment/access-groups", GetPath: "/v3/enrollment/access-groups/{id}", IsClassic: false, WrapperKey: "", SingularKey: "", ListSubset: "", NameField: "", IDField: ""}, "groups": {ListPath: "/v1/groups", GetPath: "/v1/groups/{id}", IsClassic: false, WrapperKey: "", SingularKey: "", ListSubset: "", NameField: "groupName", IDField: "groupPlatformId"}, "inventory-informations": {ListPath: "/v1/inventory-information", GetPath: "", IsClassic: false, WrapperKey: "", SingularKey: "", ListSubset: "", NameField: "", IDField: ""}, - "inventory-preloads": {ListPath: "/v2/inventory-preload/records", GetPath: "/v2/inventory-preload/records/{id}", IsClassic: false, WrapperKey: "", SingularKey: "", ListSubset: "", NameField: "", IDField: ""}, + "inventory-preloads": {ListPath: "/v2/inventory-preload/records", GetPath: "/v2/inventory-preload/records/{id}", IsClassic: false, WrapperKey: "", SingularKey: "", ListSubset: "", NameField: "serialNumber", IDField: ""}, "jamf-connects": {ListPath: "/v1/jamf-connect/config-profiles", GetPath: "", IsClassic: false, WrapperKey: "", SingularKey: "", ListSubset: "", NameField: "", IDField: ""}, "jamf-packages": {ListPath: "/v2/jamf-package", GetPath: "", IsClassic: false, WrapperKey: "", SingularKey: "", ListSubset: "", NameField: "displayName", IDField: ""}, "jamf-pro-informations": {ListPath: "/v2/jamf-pro-information", GetPath: "", IsClassic: false, WrapperKey: "", SingularKey: "", ListSubset: "", NameField: "", IDField: ""}, diff --git a/internal/commands/pro/generated/inventory_preloads.go b/internal/commands/pro/generated/inventory_preloads.go index 6db1dd13..31e01a3e 100644 --- a/internal/commands/pro/generated/inventory_preloads.go +++ b/internal/commands/pro/generated/inventory_preloads.go @@ -199,7 +199,7 @@ func newInventoryPreloadsGetCmd(ctx *registry.CLIContext) *cobra.Command { // Resolve resource ID from positional arg, --name, or lookup flags var resolvedID string if flagName != "" { - rid, err := resolveNameToID(reqCtx, ctx.Client, "/v2/inventory-preload/records", "name", "id", flagName) + rid, err := resolveNameToID(reqCtx, ctx.Client, "/v2/inventory-preload/records", "serialNumber", "id", flagName) if err != nil { return err } @@ -380,7 +380,7 @@ func newInventoryPreloadsUpdateCmd(ctx *registry.CLIContext) *cobra.Command { // Resolve resource ID from positional arg, --name, or lookup flags var resolvedID string if flagName != "" { - rid, err := resolveNameToID(reqCtx, ctx.Client, "/v2/inventory-preload/records", "name", "id", flagName) + rid, err := resolveNameToID(reqCtx, ctx.Client, "/v2/inventory-preload/records", "serialNumber", "id", flagName) if err != nil { return err } @@ -480,7 +480,7 @@ func newInventoryPreloadsDeleteCmd(ctx *registry.CLIContext) *cobra.Command { } else { var rid string if rid == "" { - id, err := resolveNameToIDForApply(reqCtx, ctx.Client, "/v2/inventory-preload/records", "name", "id", entry, noInputBulk) + id, err := resolveNameToIDForApply(reqCtx, ctx.Client, "/v2/inventory-preload/records", "serialNumber", "id", entry, noInputBulk) if err != nil { return fmt.Errorf("resolving %q: %w", entry, err) } @@ -545,12 +545,12 @@ func newInventoryPreloadsDeleteCmd(ctx *registry.CLIContext) *cobra.Command { var resolvedByName string if flagName != "" { noInput, _ := cmd.Flags().GetBool("no-input") - rid, err := resolveNameToIDForApply(reqCtx, ctx.Client, "/v2/inventory-preload/records", "name", "id", flagName, noInput) + rid, err := resolveNameToIDForApply(reqCtx, ctx.Client, "/v2/inventory-preload/records", "serialNumber", "id", flagName, noInput) if err != nil { return err } if rid == "" { - return fmt.Errorf("no inventory-preload found with name %q", flagName) + return fmt.Errorf("no inventory-preload found with serialNumber %q", flagName) } resolvedID = rid resolvedByName = flagName @@ -1284,7 +1284,7 @@ func newInventoryPreloadsApplyCmd(ctx *registry.CLIContext) *cobra.Command { Short: "Create or replace a inventory-preload by name", Long: `Create or replace a inventory-preload. Reads JSON or YAML from --from-file or stdin. -The name field in the input is used to check if the resource +The serialNumber field in the input is used to check if the resource already exists. If it does, the resource is replaced (with confirmation). If not, a new resource is created.`, Example: ` # Apply a inventory-preload from a JSON file @@ -1346,14 +1346,14 @@ If not, a new resource is created.`, } // Extract name from JSON input - name, err := extractJSONField(data, "name") + name, err := extractJSONField(data, "serialNumber") if err != nil { - return fmt.Errorf("input must include a %q field: %w", "name", err) + return fmt.Errorf("input must include a %q field: %w", "serialNumber", err) } // Check if resource exists by name (read-only, runs even in dry-run) noInput, _ := cmd.Flags().GetBool("no-input") - id, err := resolveNameToIDForApply(reqCtx, ctx.Client, "/v2/inventory-preload/records", "name", "id", name, noInput) + id, err := resolveNameToIDForApply(reqCtx, ctx.Client, "/v2/inventory-preload/records", "serialNumber", "id", name, noInput) if err != nil { return err } diff --git a/internal/commands/pro_backup.go b/internal/commands/pro_backup.go index 90ba72bb..3674b789 100644 --- a/internal/commands/pro_backup.go +++ b/internal/commands/pro_backup.go @@ -6,6 +6,8 @@ import ( "context" "encoding/json" "fmt" + "io" + "net/http" "os" "path/filepath" "strings" @@ -154,7 +156,18 @@ func runBackup(ctx context.Context, cliCtx *registry.CLIContext, opts backupOpti return err } if len(defs) == 0 { - return fmt.Errorf("no resources match filter %q", opts.Resources) + // Allow filters that resolve only to non-standard resources (inventory-preloads, + // blueprints, compliance-benchmarks) — those are handled after this loop. + allUnknown := true + for _, n := range nameFilter { + if isKnownBackupFilter(n) { + allUnknown = false + break + } + } + if allUnknown { + return fmt.Errorf("no resources match filter %q", opts.Resources) + } } ext := ".yaml" @@ -250,26 +263,35 @@ func runBackup(ctx context.Context, cliCtx *registry.CLIContext, opts backupOpti } } - // Platform resources (blueprints, compliance-benchmarks) via SDK - if cliCtx.PlatformSDKClient != nil { - wantPlatform := func(name string) bool { - if len(nameFilter) == 0 { + // wantFilter reports whether a named resource should be included given the + // user's --resources filter. Empty filter means include everything. + wantFilter := func(name string) bool { + if len(nameFilter) == 0 { + return true + } + for _, n := range nameFilter { + if n == name { return true } - for _, n := range nameFilter { - if n == name { - return true - } - } - return false } + return false + } + + // Inventory preload — single CSV download, not a JSON list+get resource. + if wantFilter("inventory-preloads") { + n, errs := backupInventoryPreloadCSV(ctx, client, opts) + totalExported += n + failures = append(failures, errs...) + } - if wantPlatform("blueprints") { + // Platform resources (blueprints, compliance-benchmarks) via SDK + if cliCtx.PlatformSDKClient != nil { + if wantFilter("blueprints") { n, errs := backupBlueprints(ctx, cliCtx, opts, newMeta) totalExported += n failures = append(failures, errs...) } - if wantPlatform("compliance-benchmarks") { + if wantFilter("compliance-benchmarks") { n, errs := backupBenchmarks(ctx, cliCtx, opts, newMeta) totalExported += n failures = append(failures, errs...) @@ -537,6 +559,38 @@ func normalizeViaJSON(v any) (map[string]any, error) { return out, nil } +// backupInventoryPreloadCSV downloads all inventory preload records as a single +// CSV file from /v2/inventory-preload/csv. The Jamf Pro API returns the complete +// dataset in one request — there is no paginated JSON list+get for this resource. +func backupInventoryPreloadCSV(ctx context.Context, client registry.HTTPClient, opts backupOptions) (int, []backupFailure) { + subDir := filepath.Join(opts.OutputDir, "inventory-preloads") + if err := os.MkdirAll(subDir, 0o755); err != nil { + return 0, []backupFailure{{Resource: "inventory-preloads", Path: subDir, Error: err.Error()}} + } + + csvCtx := registry.WithAccept(ctx, "text/csv") + resp, err := client.Do(csvCtx, "GET", "/v2/inventory-preload/csv", nil) + if err != nil { + return 0, []backupFailure{{Resource: "inventory-preloads", Path: "/v2/inventory-preload/csv", Error: err.Error()}} + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + return 0, []backupFailure{{Resource: "inventory-preloads", Path: "/v2/inventory-preload/csv", Error: fmt.Sprintf("HTTP %d", resp.StatusCode)}} + } + + data, err := io.ReadAll(resp.Body) + if err != nil { + return 0, []backupFailure{{Resource: "inventory-preloads", Path: "/v2/inventory-preload/csv", Error: err.Error()}} + } + + outPath := filepath.Join(subDir, "inventory-preload-all.csv") + if err := os.WriteFile(outPath, data, 0o644); err != nil { + return 0, []backupFailure{{Resource: "inventory-preloads", Path: outPath, Error: err.Error()}} + } + return 1, nil +} + // backupBlueprints exports all blueprints via the Platform SDK. func backupBlueprints(ctx context.Context, cliCtx *registry.CLIContext, opts backupOptions, newMeta func(string) backupMeta) (int, []backupFailure) { bp := blueprints.New(cliCtx.PlatformSDKClient) diff --git a/internal/commands/pro_backup_test.go b/internal/commands/pro_backup_test.go index edf8cd58..efa9d684 100644 --- a/internal/commands/pro_backup_test.go +++ b/internal/commands/pro_backup_test.go @@ -395,6 +395,89 @@ func TestExtractID(t *testing.T) { } } +func TestBackupInventoryPreloadCSV_Success(t *testing.T) { + csvBody := "Serial Number,Asset Tag\nC02XG0XXJHX2,ASSET001\nC02YH1ZZJHX3,ASSET002\n" + mock := &backupMockClient{ + responses: map[string]overviewMockResponse{ + "/v2/inventory-preload/csv": {200, csvBody}, + }, + } + + outDir := t.TempDir() + n, failures := backupInventoryPreloadCSV(context.Background(), mock, backupOptions{OutputDir: outDir}) + + if len(failures) != 0 { + t.Fatalf("expected no failures, got %v", failures) + } + if n != 1 { + t.Errorf("expected 1 exported, got %d", n) + } + + outPath := filepath.Join(outDir, "inventory-preloads", "inventory-preload-all.csv") + content, err := os.ReadFile(outPath) + if err != nil { + t.Fatalf("expected CSV file at %s: %v", outPath, err) + } + if string(content) != csvBody { + t.Errorf("CSV content mismatch: got %q, want %q", string(content), csvBody) + } +} + +func TestBackupInventoryPreloadCSV_HTTPError(t *testing.T) { + mock := &backupMockClient{ + responses: map[string]overviewMockResponse{ + "/v2/inventory-preload/csv": {403, `{"httpStatus":403,"errors":[]}`}, + }, + } + + outDir := t.TempDir() + n, failures := backupInventoryPreloadCSV(context.Background(), mock, backupOptions{OutputDir: outDir}) + + if n != 0 { + t.Errorf("expected 0 exported on error, got %d", n) + } + if len(failures) == 0 { + t.Fatal("expected failures on HTTP 403") + } + if failures[0].Resource != "inventory-preloads" { + t.Errorf("failure resource = %q, want %q", failures[0].Resource, "inventory-preloads") + } + if !strings.Contains(failures[0].Error, "403") { + t.Errorf("failure error should mention 403, got %q", failures[0].Error) + } +} + +func TestBackup_InventoryPreloadFilter(t *testing.T) { + csvBody := "Serial Number\nABC123\n" + mock := &backupMockClient{ + responses: map[string]overviewMockResponse{ + "/v2/inventory-preload/csv": {200, csvBody}, + }, + } + + oldURL := serverURL + serverURL = "https://test.jamfcloud.com" + defer func() { serverURL = oldURL }() + + outDir := t.TempDir() + cliCtx := ®istry.CLIContext{Client: mock} + + err := runBackup(context.Background(), cliCtx, backupOptions{ + OutputDir: outDir, + Format: "yaml", + Resources: "inventory-preloads", + Concurrency: 2, + }) + if err != nil { + t.Fatalf("runBackup error: %v", err) + } + + outPath := filepath.Join(outDir, "inventory-preloads", "inventory-preload-all.csv") + if _, err := os.Stat(outPath); os.IsNotExist(err) { + t.Error("inventory-preload-all.csv should exist") + } +} + func TestUnwrapClassicDetail(t *testing.T) { // Single-key wrapper wrapped := map[string]any{ diff --git a/internal/commands/pro_resources.go b/internal/commands/pro_resources.go index 8a8a516e..6fada8c8 100644 --- a/internal/commands/pro_resources.go +++ b/internal/commands/pro_resources.go @@ -4,6 +4,7 @@ package commands import ( "fmt" + "sort" "github.com/Jamf-Concepts/jamf-cli/internal/commands/pro/generated" ) @@ -67,6 +68,10 @@ var BackupResources = []BackupResource{ // record, so each site is written directly without a fan-out fetch. {Key: "sites", FilterName: "sites", SubDir: "sites", ListOnly: true}, + // App Store applications — classic only + {Key: "classic-mac-apps", FilterName: "mac-apps", SubDir: "mac-apps"}, + {Key: "classic-mobile-apps", FilterName: "mobile-apps", SubDir: "mobile-apps"}, + // Packages, printers, dock items — classic only {Key: "classic-packages", FilterName: "packages", SubDir: "packages"}, {Key: "classic-printers", FilterName: "printers", SubDir: "printers"}, @@ -123,10 +128,38 @@ func ResolveBackupResources(filter []string) ([]ResolvedBackupResource, error) { return out, nil } +// isKnownBackupFilter returns true if name matches any curated BackupResource +// FilterName or any non-standard filter handled outside BackupResources. Used +// by runBackup to distinguish "no results because it's a non-standard resource" +// from "no results because the user typed a garbage filter name". +func isKnownBackupFilter(name string) bool { + for _, r := range BackupResources { + if r.FilterName == name { + return true + } + } + for _, n := range nonStandardBackupFilters { + if n == name { + return true + } + } + return false +} + +// nonStandardBackupFilters lists filter names for backup resources that are +// handled outside BackupResources (CSV downloads, SDK-backed resources, etc.). +// These appear in BackupFilterNames so shell completion and help text stay +// accurate even though they have no entry in the curated list. +var nonStandardBackupFilters = []string{ + "inventory-preloads", // downloaded as a single CSV via /v2/inventory-preload/csv + "blueprints", // Platform SDK + "compliance-benchmarks", // Platform SDK +} + // BackupFilterNames returns the unique set of FilterName values (sorted) — used // for CLI help text and completion hints. func BackupFilterNames() []string { - seen := make(map[string]bool, len(BackupResources)) + seen := make(map[string]bool, len(BackupResources)+len(nonStandardBackupFilters)) var names []string for _, r := range BackupResources { if !seen[r.FilterName] { @@ -134,5 +167,12 @@ func BackupFilterNames() []string { names = append(names, r.FilterName) } } + for _, n := range nonStandardBackupFilters { + if !seen[n] { + seen[n] = true + names = append(names, n) + } + } + sort.Strings(names) return names } diff --git a/internal/commands/pro_resources_test.go b/internal/commands/pro_resources_test.go index 76aa2790..e3d2551b 100644 --- a/internal/commands/pro_resources_test.go +++ b/internal/commands/pro_resources_test.go @@ -134,8 +134,12 @@ func TestBackupFilterNames(t *testing.T) { if len(names) == 0 { t.Fatal("BackupFilterNames should not be empty") } - // Must contain the canonical user-facing filter tokens. - required := []string{"policies", "profiles", "scripts", "extension-attributes", "accounts"} + // Must contain both curated and non-standard filter tokens. + required := []string{ + "policies", "profiles", "scripts", "extension-attributes", "accounts", + "mac-apps", "mobile-apps", + "inventory-preloads", "blueprints", "compliance-benchmarks", + } for _, r := range required { if !slices.Contains(names, r) { t.Errorf("BackupFilterNames missing %q (got %v)", r, names)