diff --git a/appconfig/default.yaml b/appconfig/default.yaml index 527d3657..fd81cacc 100644 --- a/appconfig/default.yaml +++ b/appconfig/default.yaml @@ -101,6 +101,13 @@ backends: url: "https://gitlab.test.com" token: file|/path/to/gitlab_token parent_group_id: 111111 + - name: astro + type: "astro" + enabled: true + connection: + api_token: file|/path/to/astro_token + organization_id: "your-organization-id" + base_url: "https://api.astro.test.com" apiServer: address: "0.0.0.0:8080" diff --git a/pkg/clients/astro/client.go b/pkg/clients/astro/client.go new file mode 100644 index 00000000..9204b20a --- /dev/null +++ b/pkg/clients/astro/client.go @@ -0,0 +1,166 @@ +/* +Copyright 2026. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package astro + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "time" + + "github.com/gojek/heimdall/v7" + "github.com/redhat-data-and-ai/usernaut/pkg/request" + "github.com/redhat-data-and-ai/usernaut/pkg/request/httpclient" +) + +// NewClient creates a new Astro client with the given configuration +func NewClient(connection map[string]interface{}, poolCfg httpclient.ConnectionPoolConfig, + hystrixCfg httpclient.HystrixResiliencyConfig) (*AstroClient, error) { + + // Extract connection parameters + apiToken, _ := connection["api_token"].(string) + organizationID, _ := connection["organization_id"].(string) + baseURL, _ := connection["base_url"].(string) + + if apiToken == "" || organizationID == "" { + return nil, errors.New("missing required astro connection params: api_token and organization_id") + } + + if baseURL == "" { + baseURL = DefaultBaseURL + } + + // Construct full base URL with organization ID + baseURL = fmt.Sprintf("%s/v1/organizations/%s", baseURL, organizationID) + + config := AstroConfig{ + APIToken: apiToken, + BaseURL: baseURL, + } + + client, err := httpclient.InitializeClient( + "astro", + poolCfg, + hystrixCfg, + heimdall.NewRetrier(heimdall.NewConstantBackoff(100*time.Millisecond, 50*time.Millisecond)), 3, + nil, + ) + if err != nil { + return nil, fmt.Errorf("failed to initialize http client: %w", err) + } + + return &AstroClient{ + config: &config, + client: client, + }, nil +} + +// prepareRequest creates and configures a request with common Astro headers +func (c *AstroClient) prepareRequest(ctx context.Context, endpoint, method string, + body interface{}) (request.IRequester, error) { + var requestBody []byte + if body != nil && (method != http.MethodGet && method != http.MethodDelete) { + var err error + requestBody, err = json.Marshal(body) + if err != nil { + return nil, err + } + } + + url := c.config.BaseURL + endpoint + req, err := request.NewRequest(ctx, method, url, requestBody) + if err != nil { + return nil, err + } + + // Set Astro-specific headers + headers := map[string]string{ + "Authorization": "Bearer " + c.config.APIToken, + "Content-Type": "application/json", + "Accept": "application/json", + } + req.SetHeaders(headers) + + return req, nil +} + +// makeRequest uses the common request package for HTTP requests +func (c *AstroClient) makeRequest(ctx context.Context, endpoint, + method string, body interface{}) ([]byte, int, error) { + req, err := c.prepareRequest(ctx, endpoint, method, body) + if err != nil { + return nil, 0, err + } + + return req.MakeRequest(c.client, method, "astro") +} + +// makeRequestWithHeader uses the common request package for HTTP requests +// and returns headers (with logging, tracing, etc.) +func (c *AstroClient) makeRequestWithHeader(ctx context.Context, endpoint, + method string, body interface{}) ([]byte, http.Header, int, error) { + req, err := c.prepareRequest(ctx, endpoint, method, body) + if err != nil { + return nil, nil, 0, err + } + + return req.MakeRequestWithHeader(c.client, method, "astro") +} + +// fetchAllWithPagination handles paginated requests using offset-based pagination +func (c *AstroClient) fetchAllWithPagination(ctx context.Context, + baseEndpoint string, processPage func([]byte) (int, error)) error { + offset := 0 + limit := DefaultPageLimit + + for { + // Build endpoint with pagination parameters + endpoint := fmt.Sprintf("%s?offset=%d&limit=%d", baseEndpoint, offset, limit) + + resp, _, status, err := c.makeRequestWithHeader(ctx, endpoint, http.MethodGet, nil) + if err != nil { + return err + } + + if status != http.StatusOK { + return fmt.Errorf("failed to fetch data from %s, status: %s, body: %s", + endpoint, http.StatusText(status), string(resp)) + } + + // Process page and get count of items returned + count, err := processPage(resp) + if err != nil { + return err + } + + // If fewer items than limit, we've reached the end + if count < limit { + break + } + + offset += limit + } + + return nil +} + +// GetConfig returns the client configuration +func (c *AstroClient) GetConfig() *AstroConfig { + return c.config +} diff --git a/pkg/clients/astro/client_test.go b/pkg/clients/astro/client_test.go new file mode 100644 index 00000000..51525d70 --- /dev/null +++ b/pkg/clients/astro/client_test.go @@ -0,0 +1,538 @@ +/* +Copyright 2026. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package astro + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/redhat-data-and-ai/usernaut/pkg/common/structs" + "github.com/redhat-data-and-ai/usernaut/pkg/request/httpclient" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// createTestClient creates an AstroClient with a mock server +func createTestClient(t *testing.T, handler http.Handler) (*AstroClient, *httptest.Server) { + t.Helper() + server := httptest.NewServer(handler) + + client := &AstroClient{ + config: &AstroConfig{ + APIToken: "test-token", + BaseURL: server.URL + "/v1/organizations/test-org-id", + }, + client: server.Client(), + } + + return client, server +} + +func TestNewClient(t *testing.T) { + t.Run("Success", func(t *testing.T) { + connection := map[string]interface{}{ + "api_token": "test-token", + "organization_id": "test-org-id", + } + poolCfg := testPoolConfig() + hystrixCfg := testHystrixConfig() + + client, err := NewClient(connection, poolCfg, hystrixCfg) + require.NoError(t, err) + assert.NotNil(t, client) + assert.Equal(t, "test-token", client.config.APIToken) + assert.Equal(t, DefaultBaseURL+"/v1/organizations/test-org-id", client.config.BaseURL) + }) + + t.Run("CustomBaseURL", func(t *testing.T) { + connection := map[string]interface{}{ + "api_token": "test-token", + "organization_id": "test-org-id", + "base_url": "https://custom.api.com", + } + poolCfg := testPoolConfig() + hystrixCfg := testHystrixConfig() + + client, err := NewClient(connection, poolCfg, hystrixCfg) + require.NoError(t, err) + assert.Equal(t, "https://custom.api.com/v1/organizations/test-org-id", client.config.BaseURL) + }) + + t.Run("MissingAPIToken", func(t *testing.T) { + connection := map[string]interface{}{ + "organization_id": "test-org-id", + } + poolCfg := testPoolConfig() + hystrixCfg := testHystrixConfig() + + _, err := NewClient(connection, poolCfg, hystrixCfg) + assert.Error(t, err) + assert.Contains(t, err.Error(), "api_token") + }) + + t.Run("MissingOrgID", func(t *testing.T) { + connection := map[string]interface{}{ + "api_token": "test-token", + } + poolCfg := testPoolConfig() + hystrixCfg := testHystrixConfig() + + _, err := NewClient(connection, poolCfg, hystrixCfg) + assert.Error(t, err) + assert.Contains(t, err.Error(), "organization_id") + }) +} + +func TestFetchAllUsers(t *testing.T) { + ctx := context.Background() + + t.Run("Success", func(t *testing.T) { + usersResp := AstroUsersResponse{ + Users: []AstroUser{ + { + ID: "user-1", + Username: "user1@redhat.com", + FullName: "User One", + OrganizationRole: "ORGANIZATION_MEMBER", + }, + { + ID: "user-2", + Username: "user2@redhat.com", + FullName: "User Two", + OrganizationRole: "ORGANIZATION_MEMBER", + }, + }, + } + + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodGet, r.Method) + assert.Contains(t, r.URL.Path, "/v1/organizations/test-org-id/users") + assert.Equal(t, "Bearer test-token", r.Header.Get("Authorization")) + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(usersResp) + }) + + client, server := createTestClient(t, handler) + defer server.Close() + + usersByID, usersByEmail, err := client.FetchAllUsers(ctx) + require.NoError(t, err) + assert.Len(t, usersByID, 2) + assert.Len(t, usersByEmail, 2) + + assert.Equal(t, "user-1", usersByID["user-1"].ID) + assert.Equal(t, "user1@redhat.com", usersByID["user-1"].Email) + assert.Equal(t, "User", usersByID["user-1"].FirstName) + assert.Equal(t, "One", usersByID["user-1"].LastName) + + assert.Equal(t, "user-2", usersByEmail["user2@redhat.com"].ID) + }) + + t.Run("EmptyResponse", func(t *testing.T) { + usersResp := AstroUsersResponse{Users: []AstroUser{}} + + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(usersResp) + }) + + client, server := createTestClient(t, handler) + defer server.Close() + + usersByID, usersByEmail, err := client.FetchAllUsers(ctx) + require.NoError(t, err) + assert.Len(t, usersByID, 0) + assert.Len(t, usersByEmail, 0) + }) +} + +func TestCreateUser(t *testing.T) { + ctx := context.Background() + + t.Run("Success", func(t *testing.T) { + inviteResp := CreateInviteResponse{ + UserID: "new-user-id", + } + + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method) + assert.Contains(t, r.URL.Path, "/invites") + + var req CreateInviteRequest + _ = json.NewDecoder(r.Body).Decode(&req) + assert.Equal(t, "newuser@redhat.com", req.InviteeEmail) + assert.Equal(t, DefaultOrganizationRole, req.Role) + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + _ = json.NewEncoder(w).Encode(inviteResp) + }) + + client, server := createTestClient(t, handler) + defer server.Close() + + user := &structs.User{ + Email: "newuser@redhat.com", + UserName: "newuser@redhat.com", + } + + created, err := client.CreateUser(ctx, user) + require.NoError(t, err) + assert.Equal(t, "new-user-id", created.ID) + assert.Equal(t, "newuser@redhat.com", created.Email) + }) + + t.Run("MissingEmail", func(t *testing.T) { + client, server := createTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) + defer server.Close() + + user := &structs.User{UserName: "testuser"} + _, err := client.CreateUser(ctx, user) + assert.Error(t, err) + assert.Contains(t, err.Error(), "email is required") + }) +} + +func TestDeleteUser(t *testing.T) { + ctx := context.Background() + + t.Run("Success", func(t *testing.T) { + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method) + assert.Contains(t, r.URL.Path, "/users/user-to-delete/roles") + + var req UpdateUserRoleRequest + _ = json.NewDecoder(r.Body).Decode(&req) + assert.Nil(t, req.OrganizationRole) + + w.WriteHeader(http.StatusOK) + }) + + client, server := createTestClient(t, handler) + defer server.Close() + + err := client.DeleteUser(ctx, "user-to-delete") + require.NoError(t, err) + }) + + t.Run("NotFound", func(t *testing.T) { + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"error": "User not found"}`)) + }) + + client, server := createTestClient(t, handler) + defer server.Close() + + err := client.DeleteUser(ctx, "nonexistent-user") + assert.Error(t, err) + }) +} + +func TestFetchAllTeams(t *testing.T) { + ctx := context.Background() + + t.Run("Success", func(t *testing.T) { + teamsResp := AstroTeamsResponse{ + Teams: []AstroTeam{ + { + ID: "team-1", + Name: "dataverse-aggregate-test", + Description: "Test team", + OrganizationRole: "ORGANIZATION_MEMBER", + }, + { + ID: "team-2", + Name: "dataverse-aggregate-prod", + Description: "Prod team", + OrganizationRole: "ORGANIZATION_MEMBER", + }, + }, + } + + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodGet, r.Method) + assert.Contains(t, r.URL.Path, "/teams") + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(teamsResp) + }) + + client, server := createTestClient(t, handler) + defer server.Close() + + teams, err := client.FetchAllTeams(ctx) + require.NoError(t, err) + assert.Len(t, teams, 2) + + assert.Equal(t, "dataverse-aggregate-test", teams["team-1"].Name) + assert.Equal(t, "Test team", teams["team-1"].Description) + }) +} + +func TestCreateTeam(t *testing.T) { + ctx := context.Background() + + t.Run("Success", func(t *testing.T) { + createdTeam := AstroTeam{ + ID: "new-team-id", + Name: "new-team", + Description: "New team description", + OrganizationRole: "ORGANIZATION_MEMBER", + } + + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method) + assert.Contains(t, r.URL.Path, "/teams") + + var req CreateTeamRequest + _ = json.NewDecoder(r.Body).Decode(&req) + assert.Equal(t, "new-team", req.Name) + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + _ = json.NewEncoder(w).Encode(createdTeam) + }) + + client, server := createTestClient(t, handler) + defer server.Close() + + team := &structs.Team{ + Name: "new-team", + Description: "New team description", + } + + created, err := client.CreateTeam(ctx, team) + require.NoError(t, err) + assert.Equal(t, "new-team-id", created.ID) + assert.Equal(t, "new-team", created.Name) + }) + + t.Run("AlreadyExistsFetchesByName", func(t *testing.T) { + existingTeam := AstroTeam{ + ID: "existing-team-id", + Name: "existing-team", + Description: "Existing team", + OrganizationRole: "ORGANIZATION_MEMBER", + } + + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch { + case r.Method == http.MethodPost: + w.WriteHeader(http.StatusBadRequest) + body := `{"message":"Invalid request: Team with name 'existing-team' already exists in the organization",` + + `"statusCode":400,"requestId":"test-req"}` + _, _ = w.Write([]byte(body)) + case r.Method == http.MethodGet: + assert.Equal(t, "existing-team", r.URL.Query().Get("names")) + _ = json.NewEncoder(w).Encode(AstroTeamsResponse{ + Teams: []AstroTeam{existingTeam}, + }) + default: + t.Fatalf("unexpected method %s", r.Method) + } + }) + + client, server := createTestClient(t, handler) + defer server.Close() + + created, err := client.CreateTeam(ctx, &structs.Team{Name: "existing-team"}) + require.NoError(t, err) + assert.Equal(t, "existing-team-id", created.ID) + assert.Equal(t, "existing-team", created.Name) + }) +} + +func TestDeleteTeamByID(t *testing.T) { + ctx := context.Background() + + t.Run("Success", func(t *testing.T) { + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodDelete, r.Method) + assert.Contains(t, r.URL.Path, "/teams/team-to-delete") + + w.WriteHeader(http.StatusNoContent) + }) + + client, server := createTestClient(t, handler) + defer server.Close() + + err := client.DeleteTeamByID(ctx, "team-to-delete") + require.NoError(t, err) + }) +} + +func TestFetchTeamMembersByTeamID(t *testing.T) { + ctx := context.Background() + + t.Run("Success", func(t *testing.T) { + membersResp := AstroTeamMembersResponse{ + TeamMembers: []AstroTeamMember{ + { + UserID: "user-1", + Username: "user1@redhat.com", + FullName: "User One", + }, + { + UserID: "user-2", + Username: "user2@redhat.com", + FullName: "User Two", + }, + }, + } + + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodGet, r.Method) + assert.Contains(t, r.URL.Path, "/teams/team-1/members") + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(membersResp) + }) + + client, server := createTestClient(t, handler) + defer server.Close() + + members, err := client.FetchTeamMembersByTeamID(ctx, "team-1") + require.NoError(t, err) + assert.Len(t, members, 2) + + assert.Equal(t, "user-1", members["user-1"].ID) + assert.Equal(t, "user1@redhat.com", members["user-1"].Email) + }) +} + +func TestAddUserToTeam(t *testing.T) { + ctx := context.Background() + + t.Run("Success", func(t *testing.T) { + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method) + assert.Contains(t, r.URL.Path, "/teams/team-1/members") + + var req AddTeamMembersRequest + _ = json.NewDecoder(r.Body).Decode(&req) + assert.Contains(t, req.MemberIDs, "user-1") + assert.Contains(t, req.MemberIDs, "user-2") + + w.WriteHeader(http.StatusOK) + }) + + client, server := createTestClient(t, handler) + defer server.Close() + + err := client.AddUserToTeam(ctx, "team-1", []string{"user-1", "user-2"}) + require.NoError(t, err) + }) + + t.Run("EmptyUserList", func(t *testing.T) { + client, server := createTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Fatal("Should not make request for empty user list") + })) + defer server.Close() + + err := client.AddUserToTeam(ctx, "team-1", []string{}) + require.NoError(t, err) + }) +} + +func TestRemoveUserFromTeam(t *testing.T) { + ctx := context.Background() + + t.Run("Success", func(t *testing.T) { + callCount := 0 + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodDelete, r.Method) + callCount++ + w.WriteHeader(http.StatusNoContent) + }) + + client, server := createTestClient(t, handler) + defer server.Close() + + err := client.RemoveUserFromTeam(ctx, "team-1", []string{"user-1", "user-2"}) + require.NoError(t, err) + assert.Equal(t, 2, callCount) // Should make 2 calls, one per user + }) + + t.Run("UserNotFoundIsOK", func(t *testing.T) { + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + }) + + client, server := createTestClient(t, handler) + defer server.Close() + + err := client.RemoveUserFromTeam(ctx, "team-1", []string{"nonexistent-user"}) + require.NoError(t, err) + }) +} + +func TestReconcileGroupParams(t *testing.T) { + ctx := context.Background() + + t.Run("NoOp", func(t *testing.T) { + client, server := createTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Fatal("Should not make any request") + })) + defer server.Close() + + params := structs.TeamParams{ + Property: "test", + Value: []string{"value1", "value2"}, + } + + err := client.ReconcileGroupParams(ctx, "team-1", params) + require.NoError(t, err) + }) +} + +func TestGetConfig(t *testing.T) { + client := &AstroClient{ + config: &AstroConfig{ + APIToken: "test-token", + BaseURL: "https://api.test.com", + }, + } + + config := client.GetConfig() + assert.Equal(t, "test-token", config.APIToken) + assert.Equal(t, "https://api.test.com", config.BaseURL) +} + +// Helper functions for test configuration +func testPoolConfig() httpclient.ConnectionPoolConfig { + return httpclient.ConnectionPoolConfig{ + Timeout: 10000, + KeepAliveTimeout: 60000, + MaxIdleConnections: 10, + } +} + +func testHystrixConfig() httpclient.HystrixResiliencyConfig { + return httpclient.HystrixResiliencyConfig{ + MaxConcurrentRequests: 100, + RequestVolumeThreshold: 20, + CircuitBreakerSleepWindow: 5000, + ErrorPercentThreshold: 50, + CircuitBreakerTimeout: 10000, + } +} diff --git a/pkg/clients/astro/team_membership.go b/pkg/clients/astro/team_membership.go new file mode 100644 index 00000000..3cba17ea --- /dev/null +++ b/pkg/clients/astro/team_membership.go @@ -0,0 +1,156 @@ +/* +Copyright 2026. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package astro + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "strings" + + "github.com/redhat-data-and-ai/usernaut/pkg/common/structs" + "github.com/redhat-data-and-ai/usernaut/pkg/logger" + "github.com/sirupsen/logrus" +) + +// FetchTeamMembersByTeamID fetches team members for a given team ID with pagination support +func (c *AstroClient) FetchTeamMembersByTeamID(ctx context.Context, + teamID string) (map[string]*structs.User, error) { + log := logger.Logger(ctx).WithFields(logrus.Fields{ + "service": "astro", + "teamID": teamID, + }) + log.Info("fetching team members by team ID") + + members := make(map[string]*structs.User) + baseEndpoint := fmt.Sprintf("/teams/%s/members", teamID) + + err := c.fetchAllWithPagination(ctx, baseEndpoint, func(resp []byte) (int, error) { + var membersResp AstroTeamMembersResponse + if err := json.Unmarshal(resp, &membersResp); err != nil { + return 0, fmt.Errorf("failed to parse team members response: %w", err) + } + + for _, member := range membersResp.TeamMembers { + firstName, lastName := splitFullName(member.FullName) + + members[member.UserID] = &structs.User{ + ID: member.UserID, + UserName: strings.ToLower(member.Username), + Email: strings.ToLower(member.Username), // In Astro, username is the email + FirstName: firstName, + LastName: lastName, + DisplayName: member.FullName, + } + } + return len(membersResp.TeamMembers), nil + }) + + if err != nil { + log.WithError(err).Error("error fetching team members by team ID") + return nil, err + } + + log.WithField("member_count", len(members)).Info("found team members") + return members, nil +} + +// AddUserToTeam adds users to a team +func (c *AstroClient) AddUserToTeam(ctx context.Context, teamID string, userIDs []string) error { + log := logger.Logger(ctx).WithFields(logrus.Fields{ + "service": "astro", + "teamID": teamID, + "user_count": len(userIDs), + }) + log.Info("adding users to team") + + if len(userIDs) == 0 { + log.Info("no users to add") + return nil + } + + endpoint := fmt.Sprintf("/teams/%s/members", teamID) + + payload := AddTeamMembersRequest{ + MemberIDs: userIDs, + } + + resp, status, err := c.makeRequest(ctx, endpoint, http.MethodPost, payload) + if err != nil { + log.WithError(err).Error("error adding users to team") + return fmt.Errorf("failed to add users to team %s: %w", teamID, err) + } + + if status != http.StatusOK && status != http.StatusCreated && status != http.StatusNoContent { + return fmt.Errorf("failed to add users to team %s, status: %s, body: %s", + teamID, http.StatusText(status), string(resp)) + } + + log.Info("users added to team successfully") + return nil +} + +// RemoveUserFromTeam removes users from a team +func (c *AstroClient) RemoveUserFromTeam(ctx context.Context, teamID string, userIDs []string) error { + log := logger.Logger(ctx).WithFields(logrus.Fields{ + "service": "astro", + "teamID": teamID, + "user_count": len(userIDs), + }) + log.Info("removing users from team") + + if len(userIDs) == 0 { + log.Info("no users to remove") + return nil + } + + // Astro requires removing users one at a time + for _, userID := range userIDs { + endpoint := fmt.Sprintf("/teams/%s/members/%s", teamID, userID) + + resp, status, err := c.makeRequest(ctx, endpoint, http.MethodDelete, nil) + if err != nil { + log.WithError(err).WithField("userID", userID).Error("error removing user from team") + return fmt.Errorf("failed to remove user %s from team %s: %w", userID, teamID, err) + } + + // 404 is acceptable - user might already be removed + if status != http.StatusOK && status != http.StatusNoContent && status != http.StatusNotFound { + return fmt.Errorf("failed to remove user %s from team %s, status: %s, body: %s", + userID, teamID, http.StatusText(status), string(resp)) + } + + log.WithField("userID", userID).Debug("user removed from team") + } + + log.Info("users removed from team successfully") + return nil +} + +// ReconcileGroupParams reconciles backend-specific parameters for a group/team. +// For Astro, this could be used to manage workspace/deployment role assignments in the future. +func (c *AstroClient) ReconcileGroupParams( + ctx context.Context, teamID string, groupParams structs.TeamParams) error { + log := logger.Logger(ctx).WithFields(logrus.Fields{ + "service": "astro", + "teamID": teamID, + }) + + log.Debug("ReconcileGroupParams called - no implementation needed for Astro at this time") + return nil +} diff --git a/pkg/clients/astro/teams.go b/pkg/clients/astro/teams.go new file mode 100644 index 00000000..cae19cea --- /dev/null +++ b/pkg/clients/astro/teams.go @@ -0,0 +1,207 @@ +/* +Copyright 2026. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package astro + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + "strings" + + "github.com/redhat-data-and-ai/usernaut/pkg/common/structs" + "github.com/redhat-data-and-ai/usernaut/pkg/logger" + "github.com/sirupsen/logrus" +) + +// astroTeamToStruct converts an AstroTeam to a structs.Team +func astroTeamToStruct(team AstroTeam) structs.Team { + return structs.Team{ + ID: team.ID, + Name: team.Name, + Description: team.Description, + Role: team.OrganizationRole, + } +} + +// FetchAllTeams fetches all teams from Astro using REST API with pagination +func (c *AstroClient) FetchAllTeams(ctx context.Context) (map[string]structs.Team, error) { + log := logger.Logger(ctx).WithField("service", "astro") + + log.Info("fetching all teams") + teams := make(map[string]structs.Team) + + baseEndpoint := "/teams" + + err := c.fetchAllWithPagination(ctx, baseEndpoint, func(resp []byte) (int, error) { + var teamsResp AstroTeamsResponse + if err := json.Unmarshal(resp, &teamsResp); err != nil { + return 0, fmt.Errorf("failed to parse teams response: %w", err) + } + + for _, team := range teamsResp.Teams { + structTeam := astroTeamToStruct(team) + teams[team.ID] = structTeam + } + return len(teamsResp.Teams), nil + }) + + if err != nil { + log.WithError(err).Error("error fetching list of teams") + return nil, err + } + + log.WithField("total_teams_count", len(teams)).Info("found teams") + return teams, nil +} + +// FetchTeamDetails fetches details for a specific team +func (c *AstroClient) FetchTeamDetails(ctx context.Context, teamID string) (*structs.Team, error) { + log := logger.Logger(ctx).WithFields(logrus.Fields{ + "service": "astro", + "teamID": teamID, + }) + + log.Info("fetching team details") + + endpoint := fmt.Sprintf("/teams/%s", teamID) + resp, status, err := c.makeRequest(ctx, endpoint, http.MethodGet, nil) + if err != nil { + return nil, err + } + + if status == http.StatusNotFound { + return nil, fmt.Errorf("team with ID %s not found", teamID) + } + + if status != http.StatusOK { + return nil, fmt.Errorf("failed to fetch team %s: status %d, body: %s", teamID, status, string(resp)) + } + + var team AstroTeam + if err := json.Unmarshal(resp, &team); err != nil { + return nil, fmt.Errorf("failed to parse team response: %w", err) + } + + log.Info("successfully fetched team details") + result := astroTeamToStruct(team) + return &result, nil +} + +// fetchTeamByName looks up a team by name using the Astro names filter. +func (c *AstroClient) fetchTeamByName(ctx context.Context, name string) (*structs.Team, error) { + log := logger.Logger(ctx).WithFields(logrus.Fields{ + "service": "astro", + "teamName": name, + }) + + endpoint := fmt.Sprintf("/teams?names=%s&limit=1", url.QueryEscape(name)) + resp, status, err := c.makeRequest(ctx, endpoint, http.MethodGet, nil) + if err != nil { + return nil, err + } + if status != http.StatusOK { + return nil, fmt.Errorf("failed to fetch team by name %s: status %s, body: %s", + name, http.StatusText(status), string(resp)) + } + + var teamsResp AstroTeamsResponse + if err := json.Unmarshal(resp, &teamsResp); err != nil { + return nil, fmt.Errorf("failed to parse teams response: %w", err) + } + + for _, team := range teamsResp.Teams { + if team.Name == name { + result := astroTeamToStruct(team) + log.WithField("teamID", result.ID).Info("found existing team by name") + return &result, nil + } + } + + return nil, fmt.Errorf("team not found by name: %s", name) +} + +// CreateTeam creates a new team in Astro +func (c *AstroClient) CreateTeam(ctx context.Context, team *structs.Team) (*structs.Team, error) { + log := logger.Logger(ctx).WithFields(logrus.Fields{ + "service": "astro", + "teamName": team.Name, + }) + + log.Info("creating team") + endpoint := "/teams" + + payload := CreateTeamRequest{ + Name: team.Name, + Description: team.Description, + OrganizationRole: DefaultOrganizationRole, + } + + resp, status, err := c.makeRequest(ctx, endpoint, http.MethodPost, payload) + if err != nil { + log.WithError(err).Error("error creating team") + return nil, err + } + + // Astro returns 400 with "already exists" for duplicate team names. + if status == http.StatusConflict || + (status == http.StatusBadRequest && strings.Contains(strings.ToLower(string(resp)), "already exists")) { + log.Info("team already exists, fetching existing team by name") + return c.fetchTeamByName(ctx, team.Name) + } + + if status != http.StatusOK && status != http.StatusCreated { + return nil, fmt.Errorf("failed to create team, status: %s, body: %s", + http.StatusText(status), string(resp)) + } + + var createdTeam AstroTeam + if err := json.Unmarshal(resp, &createdTeam); err != nil { + return nil, fmt.Errorf("failed to parse create team response: %w", err) + } + + log.WithField("teamID", createdTeam.ID).Info("team created successfully") + + result := astroTeamToStruct(createdTeam) + return &result, nil +} + +// DeleteTeamByID deletes a team from Astro +func (c *AstroClient) DeleteTeamByID(ctx context.Context, teamID string) error { + log := logger.Logger(ctx).WithFields(logrus.Fields{ + "service": "astro", + "teamID": teamID, + }) + + log.Info("deleting team") + endpoint := fmt.Sprintf("/teams/%s", teamID) + + resp, status, err := c.makeRequest(ctx, endpoint, http.MethodDelete, nil) + if err != nil { + log.WithError(err).Error("error deleting team") + return fmt.Errorf("failed to delete team: %w", err) + } + + if status != http.StatusOK && status != http.StatusNoContent { + return fmt.Errorf("failed to delete team, status: %s, body: %s", + http.StatusText(status), string(resp)) + } + + log.Info("team deleted successfully") + return nil +} diff --git a/pkg/clients/astro/types.go b/pkg/clients/astro/types.go new file mode 100644 index 00000000..63eee6bd --- /dev/null +++ b/pkg/clients/astro/types.go @@ -0,0 +1,109 @@ +/* +Copyright 2026. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package astro + +import "github.com/gojek/heimdall/v7" + +// AstroConfig holds the configuration for Astro client +type AstroConfig struct { + APIToken string + BaseURL string +} + +// AstroClient is the client for interacting with Astro REST API +type AstroClient struct { + config *AstroConfig + client heimdall.Doer +} + +// AstroUser represents a user object from Astro API response +type AstroUser struct { + ID string `json:"id"` + Username string `json:"username"` + FullName string `json:"fullName,omitempty"` + OrganizationRole string `json:"organizationRole,omitempty"` +} + +// AstroUsersResponse represents the response from list users API +type AstroUsersResponse struct { + Users []AstroUser `json:"users"` +} + +// AstroTeam represents a team object from Astro API response +type AstroTeam struct { + ID string `json:"id"` + Name string `json:"name"` + OrganizationRole string `json:"organizationRole,omitempty"` + Description string `json:"description,omitempty"` +} + +// AstroTeamsResponse represents the response from list teams API +type AstroTeamsResponse struct { + Teams []AstroTeam `json:"teams"` +} + +// AstroTeamMember represents a team member from Astro API response +type AstroTeamMember struct { + UserID string `json:"userId"` + Username string `json:"username,omitempty"` + FullName string `json:"fullName,omitempty"` +} + +// AstroTeamMembersResponse represents the response from list team members API +type AstroTeamMembersResponse struct { + TeamMembers []AstroTeamMember `json:"teamMembers"` +} + +// CreateTeamRequest represents the request body for creating a team +type CreateTeamRequest struct { + Name string `json:"name"` + Description string `json:"description,omitempty"` + OrganizationRole string `json:"organizationRole,omitempty"` +} + +// CreateInviteRequest represents the request body for inviting a user +type CreateInviteRequest struct { + InviteeEmail string `json:"inviteeEmail"` + Role string `json:"role"` +} + +// CreateInviteResponse represents the response from invite user API +type CreateInviteResponse struct { + UserID string `json:"userId"` +} + +// AddTeamMembersRequest represents the request body for adding members to a team +type AddTeamMembersRequest struct { + MemberIDs []string `json:"memberIds"` +} + +// UpdateUserRoleRequest represents the request body for updating user role (used for deletion) +type UpdateUserRoleRequest struct { + OrganizationRole *string `json:"organizationRole"` +} + +// Astro API constants +const ( + // Default pagination limit for Astro API + DefaultPageLimit = 1000 + + // Default organization role for new users and teams + DefaultOrganizationRole = "ORGANIZATION_MEMBER" + + // Default base URL for Astro API + DefaultBaseURL = "https://api.astronomer.io" +) diff --git a/pkg/clients/astro/users.go b/pkg/clients/astro/users.go new file mode 100644 index 00000000..f0bcf69e --- /dev/null +++ b/pkg/clients/astro/users.go @@ -0,0 +1,218 @@ +/* +Copyright 2026. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package astro + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "strings" + + "github.com/redhat-data-and-ai/usernaut/pkg/common/structs" + "github.com/redhat-data-and-ai/usernaut/pkg/logger" + "github.com/sirupsen/logrus" +) + +// splitFullName splits a display name into first and last name on the first space. +func splitFullName(fullName string) (firstName, lastName string) { + if fullName == "" { + return "", "" + } + parts := strings.SplitN(fullName, " ", 2) + firstName = parts[0] + if len(parts) > 1 { + lastName = parts[1] + } + return firstName, lastName +} + +// astroUserToStruct converts an AstroUser to a structs.User +func astroUserToStruct(user *AstroUser) *structs.User { + firstName, lastName := splitFullName(user.FullName) + + return &structs.User{ + ID: user.ID, + UserName: strings.ToLower(user.Username), + Email: strings.ToLower(user.Username), // In Astro, username is the email + FirstName: firstName, + LastName: lastName, + DisplayName: user.FullName, + Role: user.OrganizationRole, + } +} + +// FetchAllUsers fetches all users from Astro using REST API with pagination +// Returns 2 maps: 1st map keyed by ID, 2nd map keyed by email +func (c *AstroClient) FetchAllUsers(ctx context.Context) (map[string]*structs.User, + map[string]*structs.User, error) { + log := logger.Logger(ctx).WithField("service", "astro") + + log.Info("fetching all users") + resultByID := make(map[string]*structs.User) + resultByEmail := make(map[string]*structs.User) + + baseEndpoint := "/users" + + err := c.fetchAllWithPagination(ctx, baseEndpoint, func(resp []byte) (int, error) { + var usersResp AstroUsersResponse + if err := json.Unmarshal(resp, &usersResp); err != nil { + return 0, fmt.Errorf("failed to parse users response: %w", err) + } + + for i := range usersResp.Users { + structUser := astroUserToStruct(&usersResp.Users[i]) + resultByID[structUser.ID] = structUser + if structUser.Email != "" { + resultByEmail[structUser.Email] = structUser + } + } + return len(usersResp.Users), nil + }) + + if err != nil { + log.WithError(err).Error("error fetching list of users") + return nil, nil, err + } + + log.WithField("total_user_count", len(resultByID)).Info("found users") + return resultByID, resultByEmail, nil +} + +// FetchUserDetails fetches details for a specific user using REST API +func (c *AstroClient) FetchUserDetails(ctx context.Context, userID string) (*structs.User, error) { + log := logger.Logger(ctx).WithFields(logrus.Fields{ + "service": "astro", + "userID": userID, + }) + log.Info("fetching user details by ID") + + endpoint := fmt.Sprintf("/users/%s", userID) + resp, status, err := c.makeRequest(ctx, endpoint, http.MethodGet, nil) + if err != nil { + return nil, err + } + + if status == http.StatusNotFound { + return nil, fmt.Errorf("user with ID %s not found", userID) + } + + if status != http.StatusOK { + return nil, fmt.Errorf("failed to fetch user %s: status %d, body: %s", userID, status, string(resp)) + } + + var user AstroUser + if err := json.Unmarshal(resp, &user); err != nil { + return nil, fmt.Errorf("failed to parse user response: %w", err) + } + + log.Info("successfully fetched user details") + return astroUserToStruct(&user), nil +} + +// CreateUser creates a new user in Astro by sending an invitation +// In Astro, users are created via invitations +func (c *AstroClient) CreateUser(ctx context.Context, user *structs.User) (*structs.User, error) { + log := logger.Logger(ctx).WithFields(logrus.Fields{ + "service": "astro", + "user": user.Email, + }) + + log.Info("creating user (sending invitation)") + endpoint := "/invites" + + if user.Email == "" { + return nil, fmt.Errorf("email is required for Astro user creation") + } + + payload := CreateInviteRequest{ + InviteeEmail: user.Email, + Role: DefaultOrganizationRole, + } + + resp, status, err := c.makeRequest(ctx, endpoint, http.MethodPost, payload) + if err != nil { + log.WithError(err).Error("error creating user invitation") + return nil, err + } + + // Handle conflict - user might already exist + if status == http.StatusConflict { + log.Info("user already exists, fetching existing user") + // Try to find the user in the existing users list + _, usersByEmail, fetchErr := c.FetchAllUsers(ctx) + if fetchErr != nil { + return nil, fetchErr + } + if existingUser, ok := usersByEmail[strings.ToLower(user.Email)]; ok { + return existingUser, nil + } + return nil, fmt.Errorf("user conflict but couldn't find existing user: %s", user.Email) + } + + if status != http.StatusOK && status != http.StatusCreated { + return nil, fmt.Errorf("failed to create user invitation, status: %s, body: %s", + http.StatusText(status), string(resp)) + } + + var inviteResp CreateInviteResponse + if err := json.Unmarshal(resp, &inviteResp); err != nil { + return nil, fmt.Errorf("failed to parse invite response: %w", err) + } + + log.WithField("userID", inviteResp.UserID).Info("user invitation sent successfully") + + email := strings.ToLower(user.Email) + // Return the created user with the ID from the response + return &structs.User{ + ID: inviteResp.UserID, + Email: email, + UserName: email, + Role: DefaultOrganizationRole, + }, nil +} + +// DeleteUser removes a user from Astro by setting their organization role to null +// Astro doesn't have a direct delete endpoint; instead, we remove the org-level role +func (c *AstroClient) DeleteUser(ctx context.Context, userID string) error { + log := logger.Logger(ctx).WithFields(logrus.Fields{ + "service": "astro", + "userID": userID, + }) + + log.Info("deleting user (removing organization role)") + endpoint := fmt.Sprintf("/users/%s/roles", userID) + + // Set organizationRole to null to remove the user + payload := UpdateUserRoleRequest{ + OrganizationRole: nil, + } + + resp, status, err := c.makeRequest(ctx, endpoint, http.MethodPost, payload) + if err != nil { + log.WithError(err).Error("error deleting user") + return fmt.Errorf("failed to delete user: %w", err) + } + + if status != http.StatusOK && status != http.StatusNoContent { + return fmt.Errorf("failed to delete user, status: %s, body: %s", + http.StatusText(status), string(resp)) + } + + log.Info("user deleted successfully") + return nil +} diff --git a/pkg/clients/client.go b/pkg/clients/client.go index b011c62d..ad1844e2 100644 --- a/pkg/clients/client.go +++ b/pkg/clients/client.go @@ -21,6 +21,7 @@ import ( "errors" "strings" + "github.com/redhat-data-and-ai/usernaut/pkg/clients/astro" "github.com/redhat-data-and-ai/usernaut/pkg/clients/fivetran" "github.com/redhat-data-and-ai/usernaut/pkg/clients/gitlab" redhatrover "github.com/redhat-data-and-ai/usernaut/pkg/clients/redhat_rover" @@ -111,6 +112,14 @@ func New(backendName, backendType string, backends map[string]map[string]config. return nil, err } return gitlabClient, nil + case "astro": + appConfig, err := config.GetConfig() + if err != nil { + return nil, err + } + + return astro.NewClient(backend.Connection, + appConfig.HttpClient.ConnectionPoolConfig, appConfig.HttpClient.HystrixResiliencyConfig) default: // If no valid backend type is matched, return an error return nil, ErrInvalidBackend