Skip to content

Commit

Permalink
[FEATURE] Add support for managing computer groups (#20)
Browse files Browse the repository at this point in the history
* Support for static computer groups
---------

Co-authored-by: William Clot <[email protected]>
Co-authored-by: William Clot <[email protected]>
  • Loading branch information
3 people authored Aug 7, 2024
1 parent 865e929 commit 5ac589b
Show file tree
Hide file tree
Showing 8 changed files with 352 additions and 16 deletions.
1 change: 1 addition & 0 deletions classic/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import (
const (
classesContext = "classes"
computersContext = "computers"
computerGroupsContext = "computergroups"
computerExtAttrContext = "computerextensionattributes"
policiesContext = "policies"
scriptsContext = "scripts"
Expand Down
7 changes: 0 additions & 7 deletions classic/computer_entity.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,6 @@ type Computers struct {
List []BasicComputerInfo `json:"computers"`
}

// ComputerGroup represents a group a device is a member of in Jamf
type ComputerGroup struct {
ID int `json:"id,omitempty" xml:"id,omitempty"`
Name string `json:"name" xml:"name"`
IsSmart bool `json:"is_smart" xml:"is_smart,omitempty"`
}

// BasicComputerInfo represents the information returned in a list of all computers from Jamf
type BasicComputerInfo struct {
GeneralInformation
Expand Down
118 changes: 118 additions & 0 deletions classic/computer_group.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
package classic

import (
"bytes"
"context"
"encoding/xml"
"fmt"
"net/http"

"github.com/pkg/errors"
)

// ComputerGroups represents a list of computer groups in Jamf
func (j *Client) ComputerGroups() ([]BasicComputerGroupInfo, error) {
ep := fmt.Sprintf("%s/%s", j.Endpoint, computerGroupsContext)
req, err := http.NewRequestWithContext(context.Background(), "GET", ep, nil)
if err != nil {
return nil, errors.Wrap(err, "error building Jamf computer groups query request")
}
res := ComputerGroups{}
if err := j.makeAPIrequest(req, &res); err != nil {
return nil, errors.Wrapf(err, "unable to query available computer groups from %s", ep)
}
return res.List, nil
}

// ComputerGroupDetails returns the details for a specific group given its ID or Name
func (j *Client) ComputerGroupDetails(identifier any) (*ComputerGroup, error) {
ep, err := EndpointBuilder(j.Endpoint, computerGroupsContext, identifier)
if err != nil {
return nil, errors.Wrapf(err, "error building JAMF query request endpoint for computer group: %v", identifier)
}

req, err := http.NewRequestWithContext(context.Background(), "GET", ep, nil)
if err != nil {
return nil, errors.Wrapf(err, "error building JAMF query request for computer group: %v", identifier)
}

res := ComputerGroup{}
if err := j.makeAPIrequest(req, &res); err != nil {
return nil, errors.Wrapf(err, "unable to query computer group with ID: %d from %s", identifier, ep)
}
return &res, nil
}

// UpdateComputerGroupMembers will update the members of a computer group in Jamf by either group ID or group Name
func (j *Client) UpdateComputerGroupMembers(identifier any, updates *ComputerGroupBindingChanges) (*ComputerGroupDetails, error) {
ep, err := EndpointBuilder(j.Endpoint, computerGroupsContext, identifier)
if err != nil {
return nil, errors.Wrapf(err, "error building JAMF query request for computer group: %v", identifier)
}

bodyContent, err := xml.Marshal(updates)
if err != nil {
return nil, errors.Wrapf(err, "error building JAMF update payload for computer group: %v", identifier)
}

body := bytes.NewReader(bodyContent)
req, err := http.NewRequestWithContext(context.Background(), "PUT", ep, body)
if err != nil {
return nil, errors.Wrapf(err, "error building JAMF update request for computer group: %v (%s)", identifier, ep)
}

res := ComputerGroupDetails{}
if err := j.makeAPIrequest(req, &res); err != nil {
return nil, errors.Wrapf(err, "unable to process JAMF update request for computer group: %v (%s)", identifier, ep)
}

return &res, nil
}

func (j *Client) CreateComputerGroup(newGroup *ComputerGroupDetails) (*ComputerGroupDetails, error) {
ep, err := EndpointBuilder(j.Endpoint, computerGroupsContext, -1)
if err != nil {
return nil, errors.Wrap(err, "error building JAMF add computer group request endpoint")
}

if newGroup.Name == "" {
return nil, errors.New("error building JAMF add computer group request: group name is required")
}

bodyContent, err := xml.Marshal(newGroup)
if err != nil {
return nil, errors.Wrap(err, "error building JAMF add computer group payload")
}

body := bytes.NewReader(bodyContent)
req, err := http.NewRequestWithContext(context.Background(), "POST", ep, body)
if err != nil {
return nil, errors.Wrapf(err, "error building JAMF add computer group request")
}

res := ComputerGroupDetails{}
if err := j.makeAPIrequest(req, &res); err != nil {
return nil, errors.Wrap(err, "unable to process JAMF add computer group request")
}

return &res, nil
}

func (j *Client) DeleteComputerGroup(identifier any) (*ComputerGroupDetails, error) {
ep, err := EndpointBuilder(j.Endpoint, computerGroupsContext, identifier)
if err != nil {
return nil, errors.Wrapf(err, "error building JAMF delete computer group request endpoint for group: %v", identifier)
}

req, err := http.NewRequestWithContext(context.Background(), "DELETE", ep, nil)
if err != nil {
return nil, errors.Wrapf(err, "error building JAMF delete computer group request for group: %v", identifier)
}

res := ComputerGroupDetails{}
if err := j.makeAPIrequest(req, &res); err != nil {
return nil, errors.Wrapf(err, "unable to process JAMF delete computer group request for group: %v", identifier)
}

return &res, nil
}
36 changes: 36 additions & 0 deletions classic/computer_group_entity.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package classic

import "encoding/xml"

type ComputerGroups struct {
List []BasicComputerGroupInfo `json:"computer_groups" xml:"computer_groups>computer_group,omitempty"`
Size int `json:"size" xml:"size"`
}

// ComputerGroup represents a group a device is a member of in Jamf
type ComputerGroup struct {
Info ComputerGroupDetails `json:"computer_group" xml:"computer_group,omitempty"`
}

// BasicComputerGroupInfo represents the information returned in a list of all
// computer groups from Jamf
type BasicComputerGroupInfo struct {
ID int `json:"id,omitempty" xml:"id,omitempty"`
Name string `json:"name,omitempty" xml:"name"`
IsSmart bool `json:"is_smart" xml:"is_smart"`
}

// ComputerGroupDetails represents the detailed information for a specific computer group
type ComputerGroupDetails struct {
XMLName xml.Name `json:"computer_group" xml:"computer_group,omitempty"`
BasicComputerGroupInfo
Computers []BasicComputerInfo `json:"computers" xml:"computers>computer,omitempty"`
}

// ComputerGroupBindingChanges represents the changes to a computer group binding when
// updating the members of a computer group in Jamf
type ComputerGroupBindingChanges struct {
XMLName xml.Name `json:"-" xml:"computer_group,omitempty"`
Additions []GeneralInformation `xml:"computer_additions>computer"`
Removals []GeneralInformation `xml:"computer_deletions>computer"`
}
182 changes: 182 additions & 0 deletions classic/computer_group_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
package classic_test

import (
"encoding/xml"
"fmt"
"io"
"net/http"
"net/http/httptest"
"testing"

jamf "github.com/DataDog/jamf-api-client-go/classic"
"github.com/stretchr/testify/assert"
)

var COMPUTER_GROUPS_BASE_API_ENDPOINT = "/JSSResource/computergroups"

func computerGroupsResponseMocks(t *testing.T) *httptest.Server {
var resp string
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.RequestURI {
case COMPUTER_GROUPS_BASE_API_ENDPOINT:
fmt.Fprintf(w, `{
"computer_groups": [
{
"id": 1,
"name": "Test Group 1",
"is_smart": false
},
{
"id": 2,
"name": "Test Group 2",
"is_smart": true
},
{
"id": 3,
"name": "Test Group 3",
"is_smart": false
}]
}`)
case fmt.Sprintf("%s/id/1", COMPUTER_GROUPS_BASE_API_ENDPOINT), fmt.Sprintf("%s/id/-1", COMPUTER_GROUPS_BASE_API_ENDPOINT), fmt.Sprintf("%s/name/Test%sGroup%s1", COMPUTER_GROUPS_BASE_API_ENDPOINT, "%20", "%20"):
switch r.Method {
case "GET":
w.Header().Add("Content-Type", "application/xml")
mockGroup := &jamf.ComputerGroup{
Info: jamf.ComputerGroupDetails{
BasicComputerGroupInfo: jamf.BasicComputerGroupInfo{
ID: 1,
Name: "Test Group 1",
IsSmart: false,
},
Computers: []jamf.BasicComputerInfo{
{
GeneralInformation: jamf.GeneralInformation{
ID: 1,
Name: "Test Computer 1",
},
},
},
},
}
groupData, err := xml.MarshalIndent(mockGroup, "", " ")
if err != nil {
fmt.Fprintf(w, err.Error())
}
fmt.Fprintf(w, string(groupData))
case "POST":
w.Header().Add("Content-Type", "application/xml")
data, err := io.ReadAll(r.Body)
if err != nil {
fmt.Fprintf(w, err.Error())
}
groupContents := &jamf.ComputerGroupDetails{}
err = xml.Unmarshal(data, groupContents)
if err != nil {
fmt.Fprintf(w, err.Error())
}
groupData, err := xml.MarshalIndent(groupContents, "", " ")
if err != nil {
fmt.Fprintf(w, err.Error())
}
fmt.Fprintf(w, string(groupData))
default:
w.Header().Add("Content-Type", "application/xml")
mockGroup := &jamf.ComputerGroupDetails{
BasicComputerGroupInfo: jamf.BasicComputerGroupInfo{
ID: 1,
Name: "Test Group 1",
IsSmart: false,
},
Computers: []jamf.BasicComputerInfo{
{
GeneralInformation: jamf.GeneralInformation{
ID: 1,
Name: "Test Computer 1",
},
},
},
}
groupData, err := xml.MarshalIndent(mockGroup, "", " ")
if err != nil {
fmt.Fprintf(w, err.Error())
}
fmt.Fprintf(w, string(groupData))
}
default:
http.Error(w, fmt.Sprintf("bad Jamf API %s call to %s", r.Method, r.URL), http.StatusInternalServerError)
return
}
_, err := w.Write([]byte(resp))
assert.Nil(t, err)
}))
}

func TestListAllComputerGroups(t *testing.T) {
server := computerGroupsResponseMocks(t)
defer server.Close()
j, err := jamf.NewClient(server.URL, "test", "test", server.Client(), jamf.WithTokenAuth())
assert.Nil(t, err)
grps, err := j.ComputerGroups()
assert.Nil(t, err)
assert.Equal(t, 3, len(grps))
assert.Equal(t, 1, grps[0].ID)
assert.Equal(t, "Test Group 1", grps[0].Name)
assert.Equal(t, false, grps[0].IsSmart)
assert.Equal(t, 2, grps[1].ID)
assert.Equal(t, "Test Group 2", grps[1].Name)
assert.Equal(t, true, grps[1].IsSmart)
assert.Equal(t, 3, grps[2].ID)
assert.Equal(t, "Test Group 3", grps[2].Name)
assert.Equal(t, false, grps[2].IsSmart)
}

func TestQuerySpecificComputerGroups(t *testing.T) {
server := computerGroupsResponseMocks(t)
defer server.Close()
j, err := jamf.NewClient(server.URL, "test", "test", server.Client(), jamf.WithTokenAuth())
assert.Nil(t, err)
grp, err := j.ComputerGroupDetails(1)
assert.Nil(t, err)
assert.Equal(t, 1, grp.Info.ID)
assert.Equal(t, "Test Group 1", grp.Info.Name)
assert.Equal(t, false, grp.Info.IsSmart)
assert.Equal(t, 1, grp.Info.Computers[0].ID)
assert.Equal(t, "Test Computer 1", grp.Info.Computers[0].Name)
}

func TestCreateComputerGroup(t *testing.T) {
server := computerGroupsResponseMocks(t)
defer server.Close()
j, err := jamf.NewClient(server.URL, "test", "test", server.Client(), jamf.WithTokenAuth())
assert.Nil(t, err)
grp := &jamf.ComputerGroupDetails{
BasicComputerGroupInfo: jamf.BasicComputerGroupInfo{
Name: "Unit Test Group",
IsSmart: false,
},
Computers: []jamf.BasicComputerInfo{},
}
createdGrp, err := j.CreateComputerGroup(grp)
assert.Nil(t, err)
assert.Equal(t, "Unit Test Group", createdGrp.Name)
assert.Equal(t, false, createdGrp.IsSmart)
assert.Equal(t, 0, len(createdGrp.Computers))
}

func TestDeleteComputerGroup(t *testing.T) {
server := computerGroupsResponseMocks(t)
defer server.Close()
j, err := jamf.NewClient(server.URL, "test", "test", server.Client(), jamf.WithTokenAuth())
assert.Nil(t, err)
deletedGrp, err := j.DeleteComputerGroup(1)
assert.Nil(t, err)
assert.Equal(t, 1, deletedGrp.ID)
assert.Equal(t, "Test Group 1", deletedGrp.Name)
assert.Equal(t, false, deletedGrp.IsSmart)

deletedGrp, err = j.DeleteComputerGroup("Test%20Group%201")
assert.Nil(t, err)
assert.Equal(t, 1, deletedGrp.ID)
assert.Equal(t, "Test Group 1", deletedGrp.Name)
assert.Equal(t, false, deletedGrp.IsSmart)
}
2 changes: 1 addition & 1 deletion classic/policy_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,7 @@ func TestUpdatePolicy(t *testing.T) {
Name: "Test Policy",
},
Scope: &jamf.Scope{
ComputerGroups: []*jamf.ComputerGroup{
ComputerGroups: []*jamf.BasicComputerGroupInfo{
{
Name: "Test Smart Group",
},
Expand Down
16 changes: 8 additions & 8 deletions classic/scope_entity.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,14 @@ package classic

// Scope represents the scope of a related Jamf configuration setting or Policy
type Scope struct {
AllComputers bool `json:"all_computers" xml:"all_computers,omitempty"`
Computers []*BasicComputerInfo `json:"computers" xml:"computers>computer,omitempty"`
ComputerGroups []*ComputerGroup `json:"computer_groups" xml:"computer_groups>computer_group,omitempty"`
Buildings []*Building `json:"buildings" xml:"buildings,omitempty"`
Departments []*Department `json:"departments" xml:"departments,omitempty"`
LimitToUsers *UserGroupLimitations `json:"limit_to_users" xml:"limit_to_users,omitempty"`
Limitations *Limitations `json:"limitations" xml:"limitations,omitempty"`
Exclusions *Exclusions `json:"exclusions" xml:"exclusions,omitempty"`
AllComputers bool `json:"all_computers" xml:"all_computers,omitempty"`
Computers []*BasicComputerInfo `json:"computers" xml:"computers>computer,omitempty"`
ComputerGroups []*BasicComputerGroupInfo `json:"computer_groups" xml:"computer_groups>computer_group,omitempty"`
Buildings []*Building `json:"buildings" xml:"buildings,omitempty"`
Departments []*Department `json:"departments" xml:"departments,omitempty"`
LimitToUsers *UserGroupLimitations `json:"limit_to_users" xml:"limit_to_users,omitempty"`
Limitations *Limitations `json:"limitations" xml:"limitations,omitempty"`
Exclusions *Exclusions `json:"exclusions" xml:"exclusions,omitempty"`
}

// Building represents a building configured in Jamf that a setting can be scoped to
Expand Down
Loading

0 comments on commit 5ac589b

Please sign in to comment.