Skip to content
Merged
76 changes: 76 additions & 0 deletions .github/workflows/integration.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -341,3 +341,79 @@ jobs:
OUTPUT=$(./rootly-catalog-sync sync 2>&1)
echo "$OUTPUT" | grep -q "Already in sync" && echo "✓ native team idempotent"
echo "=== native teams passed ==="

- name: Integration — native owner_team
env:
ROOTLY_API_KEY: ${{ secrets.INTEGRATION_API_KEY }}
run: |
set -euo pipefail
# write_config <owner team letter> <map owner_team: yes|no>
write_config() {
local owner="group:default/ci-owner-$1-$RUN_ID" owner_map=""
if [ "$2" = yes ]; then owner_map=' owner_team: "{{ .owner }}"'; fi
cat > rootly-catalog-sync.yaml <<EOF
version: 2
sync:
- from:
inline:
entries:
- id: "group:default/ci-owner-a-$RUN_ID"
name: CI Owner Team A $RUN_ID
- id: "group:default/ci-owner-b-$RUN_ID"
name: CI Owner Team B $RUN_ID
to: team
map:
external_id: "{{ .id }}"
name: "{{ .name }}"
- from:
inline:
entries:
- id: ci-owned-svc-$RUN_ID
name: CI Owned Service $RUN_ID
owner: "$owner"
to: service
map:
external_id: "{{ .id }}"
name: "{{ .name }}"
$owner_map
- from:
inline:
entries:
- id: ci-owned-func-$RUN_ID
name: CI Owned Functionality $RUN_ID
owner: "$owner"
to: functionality
map:
external_id: "{{ .id }}"
name: "{{ .name }}"
$owner_map
EOF
}

write_config a yes
./rootly-catalog-sync sync
echo "✓ owner_team create"
./rootly-catalog-sync status --fail-on-drift && echo "✓ owner_team in sync"
OUTPUT=$(./rootly-catalog-sync sync 2>&1)
echo "$OUTPUT" | grep -q "Already in sync" && echo "✓ owner_team idempotent"

write_config b yes
./rootly-catalog-sync plan --output=json | python3 -c '
import json, sys
raw, dec, pos, owner_updates = sys.stdin.read(), json.JSONDecoder(), 0, 0
while (pos := raw.find("{", pos)) != -1:
plan, pos = dec.raw_decode(raw, pos)
owner_updates += sum("owner_team" in (c.get("field_diffs") or {}) for c in plan.get("changes") or [])
assert owner_updates == 2, f"expected 2 owner_team updates, got {owner_updates}"
'
echo "✓ owner_team reassignment planned"
./rootly-catalog-sync sync
./rootly-catalog-sync status --fail-on-drift && echo "✓ owner_team reassigned"

# Omitting owner_team must leave the team B owners untouched
write_config a no
./rootly-catalog-sync sync
write_config b yes
./rootly-catalog-sync status --fail-on-drift && echo "✓ owner_team preserved when omitted"

echo "=== native owner_team passed ==="
72 changes: 70 additions & 2 deletions client/native.go
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
package client

import (
"bytes"
"context"
"encoding/json"
"fmt"
"strconv"
"strings"

"github.com/oapi-codegen/nullable"
rootly "github.com/rootlyhq/rootly-go"
Expand Down Expand Up @@ -40,6 +42,9 @@ const (
attrVictorOpsID = "victor_ops_id"
attrServiceNowCiSysID = "service_now_ci_sys_id"
attrAlertsEmailEnabled = "alerts_email_enabled"
// AttrOwnerTeam is a service's or functionality's owning team. Desired values
// are resolved to a team ID before diffing; live values are owner_group_ids.
AttrOwnerTeam = "owner_team"
)

// NativeKnownAttrs returns the known writable attributes for a native resource type.
Expand All @@ -57,11 +62,13 @@ var nativeKnownAttrsMap = map[string]map[string]bool{
"github_repository_name": true, "github_repository_branch": true,
"gitlab_repository_name": true, "gitlab_repository_branch": true,
"kubernetes_deployment_name": true, attrAlertsEmailEnabled: true,
AttrOwnerTeam: true,
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
},
NativeFunctionality: {
attrDescription: true, attrColor: true, attrBackstageID: true, attrCortexID: true,
attrOpsgenieID: true, attrOpsgenieTeamID: true, attrOpslevelID: true,
attrPagerdutyID: true, attrServiceNowCiSysID: true,
AttrOwnerTeam: true,
},
NativeEnvironment: {
attrDescription: true, attrColor: true, "position": true,
Expand Down Expand Up @@ -349,6 +356,7 @@ func serviceToLive(id string, s rootly.Service, propIDToSlug map[string]string)
setNullableStr(s.GitlabRepositoryBranch, "gitlab_repository_branch", ent.Fields)
setNullableStr(s.KubernetesDeploymentName, "kubernetes_deployment_name", ent.Fields)
setNullableBool(s.AlertsEmailEnabled, attrAlertsEmailEnabled, ent.Fields)
setOwnerTeam(s.OwnerGroupIDs, ent.Fields)
readProperties(s.Properties, propIDToSlug, ent.Fields)

return ent
Expand Down Expand Up @@ -409,11 +417,25 @@ func functionalityToLive(id string, f rootly.Functionality, propIDToSlug map[str
setNullableStr(f.OpsgenieTeamID, attrOpsgenieTeamID, ent.Fields)
setNullableStr(f.PagerdutyID, attrPagerdutyID, ent.Fields)
setNullableStr(f.ServiceNowCiSysID, attrServiceNowCiSysID, ent.Fields)
setOwnerTeam(f.OwnerGroupIDs, ent.Fields)
readProperties(f.Properties, propIDToSlug, ent.Fields)

return ent
}

// HasOwnerTeam reports whether resources of type t support the owner_team built-in.
func HasOwnerTeam(t string) bool {
return t == NativeService || t == NativeFunctionality
}

func setOwnerTeam(ids nullable.Nullable[[]string], fields map[string]string) {
if ids.IsSpecified() && !ids.IsNull() {
if v := ids.MustGet(); len(v) > 0 {
fields[AttrOwnerTeam] = strings.Join(v, ",")
}
}
}

func (c *Client) listEnvironments(ctx context.Context, propIDToSlug map[string]string) ([]catalog.LiveEntity, error) {
var entities []catalog.LiveEntity
page := 1
Expand Down Expand Up @@ -676,13 +698,55 @@ func (c *Client) bulkUpsertServices(ctx context.Context, batch []catalog.Desired
sdkEntities[j].Fields = catalogFields(e, known)
}

resp, err := c.sdk.BulkUpsertServicesWithApplicationVndAPIPlusJSONBodyWithResponse(ctx, body)
payload, err := withOwners(body, batch)
if err != nil {
return nil, fmt.Errorf("bulk upsert %s: %w", resourceTypePlural("service"), err)
}
resp, err := c.sdk.BulkUpsertServicesWithBodyWithResponse(ctx, "application/vnd.api+json", bytes.NewReader(payload))
if err != nil {
return nil, fmt.Errorf("bulk upsert %s: %w", resourceTypePlural("service"), err)
}
return parseBulkUpsertResponse(resp.StatusCode(), resp.Body)
}

// withOwners encodes a bulk upsert body, adding owner_group_ids to entities that
// map an owner_team. rootly-go's bulk upsert types don't model owner_group_ids yet.
func withOwners(body any, batch []catalog.DesiredEntity) ([]byte, error) {
raw, err := json.Marshal(body)
if err != nil {
return nil, err
}
hasOwners := false
for _, e := range batch {
if e.Fields[AttrOwnerTeam] != "" {
hasOwners = true
break
}
}
if !hasOwners {
return raw, nil
}

var doc struct {
Entities []map[string]json.RawMessage `json:"entities"`
}
if err := json.Unmarshal(raw, &doc); err != nil {
return nil, err
}
for j, e := range batch {
owner := e.Fields[AttrOwnerTeam]
if owner == "" {
continue
}
ids, err := json.Marshal(strings.Split(owner, ","))
if err != nil {
return nil, err
}
doc.Entities[j]["owner_group_ids"] = ids
}
return json.Marshal(doc)
}

func setServiceAttrs(body *rootly.BulkUpsertServices, j int, fields map[string]string) {
ent := &body.Entities[j]
for k, v := range fields {
Expand Down Expand Up @@ -759,7 +823,11 @@ func (c *Client) bulkUpsertFunctionalities(ctx context.Context, batch []catalog.
sdkEntities[j].Fields = catalogFields(e, known)
}

resp, err := c.sdk.BulkUpsertFunctionalitiesWithApplicationVndAPIPlusJSONBodyWithResponse(ctx, body)
payload, err := withOwners(body, batch)
if err != nil {
return nil, fmt.Errorf("bulk upsert %s: %w", resourceTypePlural("functionality"), err)
}
resp, err := c.sdk.BulkUpsertFunctionalitiesWithBodyWithResponse(ctx, "application/vnd.api+json", bytes.NewReader(payload))
if err != nil {
return nil, fmt.Errorf("bulk upsert %s: %w", resourceTypePlural("functionality"), err)
}
Expand Down
89 changes: 89 additions & 0 deletions client/native_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ import (
"net/http/httptest"
"testing"

"github.com/oapi-codegen/nullable"
rootly "github.com/rootlyhq/rootly-go"

"github.com/rootlyhq/rootly-catalog-sync/catalog"
)

Expand Down Expand Up @@ -318,3 +321,89 @@ func TestIsNativeResource(t *testing.T) {
})
}
}

func TestBulkUpsertNative_ServiceOwnerTeam(t *testing.T) {
var body map[string]any
mux := http.NewServeMux()
mux.HandleFunc("/v1/services/bulk_upsert", func(w http.ResponseWriter, r *http.Request) {
_ = json.NewDecoder(r.Body).Decode(&body)
_ = json.NewEncoder(w).Encode(map[string]any{"data": []any{}})
})
srv := httptest.NewServer(mux)
defer srv.Close()

c := New("test-key", WithBaseURL(srv.URL), WithMaxRetries(0))
ents := []catalog.DesiredEntity{
{ExternalID: "owned", Name: "Owned", Fields: map[string]string{AttrOwnerTeam: "team-1", "description": "d"}},
{ExternalID: "unowned", Name: "Unowned", Fields: map[string]string{}},
}
if _, err := c.BulkUpsertNative(context.Background(), NativeService, ents); err != nil {
t.Fatalf("unexpected error: %v", err)
}

entities := body["entities"].([]any)
owned := entities[0].(map[string]any)
ids, ok := owned["owner_group_ids"].([]any)
if !ok || len(ids) != 1 || ids[0] != "team-1" {
t.Errorf("expected owner_group_ids [team-1], got %v", owned["owner_group_ids"])
}
if owned["description"] != "d" {
t.Errorf("expected other attrs preserved, got %v", owned["description"])
}
if owned["fields"] != nil {
t.Errorf("owner_team must not be sent as a catalog field, got %v", owned["fields"])
}
if _, present := entities[1].(map[string]any)["owner_group_ids"]; present {
t.Error("expected owner_group_ids omitted for an entity without owner_team")
}
}

func TestServiceToLive_OwnerTeam(t *testing.T) {
svc := rootly.Service{Name: "svc", OwnerGroupIDs: nullable.NewNullableWithValue([]string{"team-1", "team-2"})}
if got := serviceToLive("id", svc, nil).Fields[AttrOwnerTeam]; got != "team-1,team-2" {
t.Errorf("expected owner_team=team-1,team-2, got %q", got)
}
if _, ok := serviceToLive("id", rootly.Service{Name: "svc"}, nil).Fields[AttrOwnerTeam]; ok {
t.Error("expected no owner_team for a service without owners")
}
}

func TestBulkUpsertNative_FunctionalityOwnerTeam(t *testing.T) {
var body map[string]any
mux := http.NewServeMux()
mux.HandleFunc("/v1/functionalities/bulk_upsert", func(w http.ResponseWriter, r *http.Request) {
_ = json.NewDecoder(r.Body).Decode(&body)
_ = json.NewEncoder(w).Encode(map[string]any{"data": []any{}})
})
srv := httptest.NewServer(mux)
defer srv.Close()

c := New("test-key", WithBaseURL(srv.URL), WithMaxRetries(0))
ents := []catalog.DesiredEntity{
{ExternalID: "owned", Name: "Owned", Fields: map[string]string{AttrOwnerTeam: "team-1", "description": "d"}},
{ExternalID: "unowned", Name: "Unowned", Fields: map[string]string{}},
}
if _, err := c.BulkUpsertNative(context.Background(), NativeFunctionality, ents); err != nil {
t.Fatalf("unexpected error: %v", err)
}

entities := body["entities"].([]any)
owned := entities[0].(map[string]any)
ids, ok := owned["owner_group_ids"].([]any)
if !ok || len(ids) != 1 || ids[0] != "team-1" {
t.Errorf("expected owner_group_ids [team-1], got %v", owned["owner_group_ids"])
}
if owned["description"] != "d" || owned["fields"] != nil {
t.Errorf("expected description kept and no catalog fields, got %v", owned)
}
if _, present := entities[1].(map[string]any)["owner_group_ids"]; present {
t.Error("expected owner_group_ids omitted for an entity without owner_team")
}
}

func TestFunctionalityToLive_OwnerTeam(t *testing.T) {
f := rootly.Functionality{Name: "f", OwnerGroupIDs: nullable.NewNullableWithValue([]string{"team-1"})}
if got := functionalityToLive("id", f, nil).Fields[AttrOwnerTeam]; got != "team-1" {
t.Errorf("expected owner_team=team-1, got %q", got)
}
}
Loading
Loading