Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions generator/parser/parser.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion internal/commands/pro/generated/backup_registry.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

18 changes: 9 additions & 9 deletions internal/commands/pro/generated/inventory_preloads.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

80 changes: 67 additions & 13 deletions internal/commands/pro_backup.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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...)
Expand Down Expand Up @@ -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)
Expand Down
83 changes: 83 additions & 0 deletions internal/commands/pro_backup_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 := &registry.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{
Expand Down
42 changes: 41 additions & 1 deletion internal/commands/pro_resources.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ package commands

import (
"fmt"
"sort"

"github.com/Jamf-Concepts/jamf-cli/internal/commands/pro/generated"
)
Expand Down Expand Up @@ -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"},
Expand Down Expand Up @@ -123,16 +128,51 @@ 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] {
seen[r.FilterName] = true
names = append(names, r.FilterName)
}
}
for _, n := range nonStandardBackupFilters {
if !seen[n] {
seen[n] = true
names = append(names, n)
}
}
sort.Strings(names)
return names
}
8 changes: 6 additions & 2 deletions internal/commands/pro_resources_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down