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
28 changes: 28 additions & 0 deletions abmclient/cache.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package abmclient

import (
"encoding/json"
"fmt"
"os"
"path/filepath"

"github.com/CampusTech/abm"
)

// GetPurchaseSourcesFromCache reads devices.json from cacheDir and returns
// the unique purchase sources without making any ABM API calls.
func GetPurchaseSourcesFromCache(cacheDir string) ([]PurchaseSource, error) {
data, err := os.ReadFile(filepath.Join(cacheDir, "devices.json"))
if err != nil {
return nil, fmt.Errorf("reading devices cache: %w", err)
}
var devices []Device
if err := json.Unmarshal(data, &devices); err != nil {
return nil, fmt.Errorf("parsing devices cache: %w", err)
}
orgDevices := make([]abm.OrgDevice, len(devices))
for i, d := range devices {
orgDevices[i] = d.OrgDevice
}
return collectPurchaseSources(orgDevices), nil
}
20 changes: 12 additions & 8 deletions abmclient/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -210,13 +210,8 @@ type PurchaseSource struct {
ID string // purchaseSourceId (may be empty)
}

// GetAllPurchaseSources fetches all devices and returns the unique purchase sources.
func (c *Client) GetAllPurchaseSources(ctx context.Context) ([]PurchaseSource, error) {
devices, _, err := c.abm.FetchAllOrgDevices(ctx)
if err != nil {
return nil, fmt.Errorf("fetching devices: %w", err)
}

// collectPurchaseSources extracts unique purchase sources from a slice of ABM devices.
func collectPurchaseSources(devices []abm.OrgDevice) []PurchaseSource {
seen := make(map[PurchaseSource]bool)
var sources []PurchaseSource
for _, d := range devices {
Expand All @@ -233,7 +228,16 @@ func (c *Client) GetAllPurchaseSources(ctx context.Context) ([]PurchaseSource, e
sources = append(sources, ps)
}
}
return sources, nil
return sources
}

// GetAllPurchaseSources fetches all devices and returns the unique purchase sources.
func (c *Client) GetAllPurchaseSources(ctx context.Context) ([]PurchaseSource, error) {
devices, _, err := c.abm.FetchAllOrgDevices(ctx)
if err != nil {
return nil, fmt.Errorf("fetching devices: %w", err)
}
return collectPurchaseSources(devices), nil
}

// CoverageResult holds both the "winning" AppleCare record and the full list
Expand Down
4 changes: 2 additions & 2 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -207,8 +207,8 @@ func Execute() {
cmd.Flags().Bool("dry-run", false, "Simulate without making changes")
}

// --cache-dir: download, sync
for _, cmd := range []*cobra.Command{downloadCmd, syncCmd} {
// --cache-dir: download, sync, setup
for _, cmd := range []*cobra.Command{downloadCmd, syncCmd, setupCmd} {
cmd.Flags().String("cache-dir", "", `Directory for cached API responses (default ".cache")`)
}

Expand Down
94 changes: 63 additions & 31 deletions cmd/setup.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,24 +6,27 @@ import (

"github.com/spf13/cobra"

"github.com/CampusTech/axm2snipe/abmclient"
"github.com/CampusTech/axm2snipe/config"
"github.com/CampusTech/axm2snipe/snipe"
)

// NewSetupCmd creates the setup command.
func NewSetupCmd() *cobra.Command {
return &cobra.Command{
cmd := &cobra.Command{
Use: "setup",
Short: "Create AXM custom fields in Snipe-IT",
Long: "Creates AXM custom fields in Snipe-IT, associates them with the configured fieldset, and saves the resulting field mappings to the config file.",
RunE: runSetup,
}
cmd.Flags().Bool("use-cache", true, "Use cached devices.json for purchase source discovery instead of fetching from ABM")
return cmd
}

func runSetup(cmd *cobra.Command, args []string) error {
if err := Cfg.ValidateABM(); err != nil {
return err
}
useCache, _ := cmd.Flags().GetBool("use-cache")
applyBoolFlag(cmd, "use-cache", &Cfg.Sync.UseCache)

if err := Cfg.ValidateSnipeIT(); err != nil {
return err
}
Expand All @@ -38,9 +41,17 @@ func runSetup(cmd *cobra.Command, args []string) error {
ctx, cancel := contextWithSignal()
defer cancel()

abmClient, err := newABMClient(ctx)
if err != nil {
return err
// ABM client is only needed when not using cache (for MDM servers + purchase sources)
var abmClient *abmclient.Client
if !useCache {
if err := Cfg.ValidateABM(); err != nil {
return err
}
var err error
abmClient, err = newABMClient(ctx)
if err != nil {
return err
}
}

snipeClient, err := newSnipeClient()
Expand All @@ -49,24 +60,26 @@ func runSetup(cmd *cobra.Command, args []string) error {
}

// Fetch MDM server names from ABM for the Assigned MDM Server field options
log.Info("Fetching MDM servers from ABM...")
mdmServerNames, err := abmClient.GetMDMServers(ctx)
if err != nil {
log.Warnf("Could not fetch MDM servers: %v (Assigned MDM Server field will be a text field)", err)
}

mdmServerField := snipe.FieldDef{Name: "AXM: Assigned MDM Server", Element: "text", Format: "ANY", HelpText: "MDM server assigned in Apple Business/School Manager"}
if len(mdmServerNames) > 0 {
var names []string
for _, name := range mdmServerNames {
if name != "" {
names = append(names, name)
if useCache {
log.Info("Skipping MDM server fetch (--use-cache); Assigned MDM Server field will be a text field")
} else {
log.Info("Fetching MDM servers from ABM...")
mdmServers, err := abmClient.GetMDMServers(ctx)
if err != nil {
log.Warnf("Could not fetch MDM servers: %v (Assigned MDM Server field will be a text field)", err)
} else {
var names []string
for _, name := range mdmServers {
if name != "" {
names = append(names, name)
}
}
if len(names) > 0 {
mdmServerField.Element = "listbox"
mdmServerField.FieldValues = strings.Join(names, "\n")
log.Infof("Found %d MDM servers: %s", len(names), strings.Join(names, ", "))
}
}
if len(names) > 0 {
mdmServerField.Element = "listbox"
mdmServerField.FieldValues = strings.Join(names, "\n")
log.Infof("Found %d MDM servers: %s", len(names), strings.Join(names, ", "))
}
}

Expand Down Expand Up @@ -111,14 +124,19 @@ func runSetup(cmd *cobra.Command, args []string) error {

// Build field mapping: DB column -> ABM attribute
fieldMapping := make(map[string]string)
// replaceValues is the set of ABM attribute values managed by setup —
// existing config entries with these values will be replaced so stale
// field IDs from a previous run don't accumulate.
replaceValues := make(map[string]bool)
for name, dbCol := range results {
if attr, ok := abmAttr[name]; ok {
fieldMapping[dbCol] = attr
replaceValues[attr] = true
}
}

// Save to config file
if err := config.MergeFieldMapping(ConfigFile, fieldMapping); err != nil {
// Save to config file, replacing any stale mappings for attributes we manage
if err := config.MergeFieldMapping(ConfigFile, fieldMapping, replaceValues); err != nil {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
log.Warnf("Could not save field mappings to %s: %v", ConfigFile, err)
fmt.Println("\nAdd these to your settings.yaml field_mapping manually:")
for dbCol, attr := range fieldMapping {
Expand All @@ -137,12 +155,26 @@ func runSetup(cmd *cobra.Command, args []string) error {
}
}

// Fetch purchase sources from ABM and write supplier_mapping scaffold
log.Info("Fetching purchase sources from ABM (this fetches all devices)...")
purchaseSources, err := abmClient.GetAllPurchaseSources(ctx)
if err != nil {
log.Warnf("Could not fetch purchase sources: %v", err)
} else if len(purchaseSources) > 0 {
// Fetch purchase sources and write supplier_mapping scaffold
var purchaseSources []abmclient.PurchaseSource
cacheDir := Cfg.Sync.CacheDir
if cacheDir == "" {
cacheDir = ".cache"
}
if useCache {
log.Infof("Loading purchase sources from cache (%s/devices.json)...", cacheDir)
purchaseSources, err = abmclient.GetPurchaseSourcesFromCache(cacheDir)
if err != nil {
log.Warnf("Could not load purchase sources from cache: %v", err)
}
} else {
log.Info("Fetching purchase sources from ABM (this fetches all devices)...")
purchaseSources, err = abmClient.GetAllPurchaseSources(ctx)
if err != nil {
log.Warnf("Could not fetch purchase sources: %v", err)
}
}
if len(purchaseSources) > 0 {
var entries []config.SupplierEntry
for _, ps := range purchaseSources {
if ps.Type == "MANUALLY_ADDED" {
Expand Down
19 changes: 16 additions & 3 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -155,9 +155,11 @@ func (c *Config) ValidateSnipeIT() error {
}

// MergeFieldMapping reads a YAML config file, merges new field mappings into
// sync.field_mapping (without overwriting existing entries), and writes it back.
// sync.field_mapping and writes it back. If replaceValues is non-nil, any
// existing entries whose ABM attribute value is in that set are removed first
// (used by setup to replace stale field IDs with fresh ones).
// Comments and structure are preserved via yaml.v3 node API.
func MergeFieldMapping(path string, newMappings map[string]string) error {
func MergeFieldMapping(path string, newMappings map[string]string, replaceValues map[string]bool) error {
data, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("reading config file: %w", err)
Expand All @@ -182,7 +184,18 @@ func MergeFieldMapping(path string, newMappings map[string]string) error {
// Find or create "field_mapping" mapping under sync
fmNode := findOrCreateMapping(syncNode, "field_mapping")

// Build set of existing keys
// Remove stale entries whose value is in replaceValues
if len(replaceValues) > 0 {
var kept []*yaml.Node
for i := 0; i < len(fmNode.Content)-1; i += 2 {
if !replaceValues[fmNode.Content[i+1].Value] {
kept = append(kept, fmNode.Content[i], fmNode.Content[i+1])
}
}
fmNode.Content = kept
}

// Build set of remaining keys
existing := make(map[string]bool)
for i := 0; i < len(fmNode.Content)-1; i += 2 {
existing[fmNode.Content[i].Value] = true
Expand Down
44 changes: 41 additions & 3 deletions config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -300,7 +300,7 @@ func TestMergeFieldMapping(t *testing.T) {
"_snipeit_status_2": "applecare_status",
}

if err := MergeFieldMapping(path, newMappings); err != nil {
if err := MergeFieldMapping(path, newMappings, nil); err != nil {
t.Fatal(err)
}

Expand Down Expand Up @@ -329,7 +329,7 @@ func TestMergeFieldMapping_EmptyFile(t *testing.T) {
"_snipeit_mac_1": "wifi_mac",
}

if err := MergeFieldMapping(path, newMappings); err != nil {
if err := MergeFieldMapping(path, newMappings, nil); err != nil {
t.Fatal(err)
}

Expand All @@ -355,7 +355,7 @@ func TestMergeFieldMapping_SkipsEmptyKeys(t *testing.T) {
"valid_key": "", // empty value — skip
}

if err := MergeFieldMapping(path, newMappings); err != nil {
if err := MergeFieldMapping(path, newMappings, nil); err != nil {
t.Fatal(err)
}

Expand All @@ -367,3 +367,41 @@ func TestMergeFieldMapping_SkipsEmptyKeys(t *testing.T) {
t.Errorf("should only have 1 mapping, got %d", len(cfg.Sync.FieldMapping))
}
}

func TestMergeFieldMapping_ReplaceStaleIDs(t *testing.T) {
// Simulates setup running a second time: old field ID _snipeit_color_7 should
// be replaced by new _snipeit_color_99 because "color" is in replaceValues.
// The unmanaged entry "manual_key: custom" must be preserved.
dir := t.TempDir()
path := filepath.Join(dir, "settings.yaml")

initial := `sync:
field_mapping:
_snipeit_color_7: color
manual_key: custom
`
if err := os.WriteFile(path, []byte(initial), 0644); err != nil {
t.Fatal(err)
}

newMappings := map[string]string{"_snipeit_color_99": "color"}
replaceValues := map[string]bool{"color": true}

if err := MergeFieldMapping(path, newMappings, replaceValues); err != nil {
t.Fatal(err)
}

cfg, err := Load(path)
if err != nil {
t.Fatal(err)
}
if _, ok := cfg.Sync.FieldMapping["_snipeit_color_7"]; ok {
t.Error("stale field ID should have been removed")
}
if cfg.Sync.FieldMapping["_snipeit_color_99"] != "color" {
t.Error("new field ID should be present")
}
if cfg.Sync.FieldMapping["manual_key"] != "custom" {
t.Error("unmanaged entry should be preserved")
}
}
3 changes: 3 additions & 0 deletions snipe/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -271,6 +271,9 @@ type FieldDef struct {
// SetupFields creates or updates custom fields in Snipe-IT and associates them
// with the given fieldset. Returns a map of field name -> db_column_name.
func (c *Client) SetupFields(fieldsetID int, fields []FieldDef) (map[string]string, error) {
if c.DryRun {
return nil, ErrDryRun
}
existing, _, err := c.Fields.List(nil)
if err != nil {
return nil, fmt.Errorf("listing existing fields: %w", err)
Expand Down