diff --git a/internal/cli/commands.go b/internal/cli/commands.go index c875aaa2..44465eb8 100644 --- a/internal/cli/commands.go +++ b/internal/cli/commands.go @@ -90,6 +90,7 @@ func makeChildCommand(root RootCommand, desc map[string]cmdutils.Descriptor) *co &cobra.Group{ID: "configuration-manager", Title: "Configuration Manager Commands:"}, &cobra.Group{ID: "operations-manager", Title: "Operations Manager Commands:"}, &cobra.Group{ID: "lifecycle-manager", Title: "Lifecycle Manager Commands:"}, + &cobra.Group{ID: "flow-agent", Title: "Flow Agent Commands:"}, ) } diff --git a/internal/flags/agent_projects.go b/internal/flags/agent_projects.go new file mode 100644 index 00000000..e843ad54 --- /dev/null +++ b/internal/flags/agent_projects.go @@ -0,0 +1,25 @@ +// Copyright 2024 Itential Inc. All Rights Reserved +// Unauthorized copying of this file, via any medium is strictly prohibited +// Proprietary and confidential + +package flags + +import "github.com/spf13/cobra" + +// AgentProjectImportOptions holds options for the `import agent-project` command. +type AgentProjectImportOptions struct { + Members []string + ConflictMode string +} + +func (o *AgentProjectImportOptions) Flags(cmd *cobra.Command) { + cmd.Flags().StringArrayVar(&o.Members, "member", o.Members, "Configure one or more project members") + // Left unset by default (empty string) so the runner can distinguish "not specified" from + // an explicit choice: --replace implies conflict-mode=replace unless --conflict-mode overrides it. + cmd.Flags().StringVar(&o.ConflictMode, "conflict-mode", "", "How to handle a collision with an existing project (keep-both or replace); defaults to \"replace\" if --replace is set, otherwise \"keep-both\"") +} + +// AgentProjectExportOptions holds options for the `export agent-project` command. +type AgentProjectExportOptions struct{} + +func (o *AgentProjectExportOptions) Flags(_ *cobra.Command) {} diff --git a/internal/flags/agent_projects_test.go b/internal/flags/agent_projects_test.go new file mode 100644 index 00000000..288e0dc2 --- /dev/null +++ b/internal/flags/agent_projects_test.go @@ -0,0 +1,23 @@ +// Copyright 2024 Itential Inc. All Rights Reserved +// Unauthorized copying of this file, via any medium is strictly prohibited +// Proprietary and confidential + +package flags + +import ( + "testing" + + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" +) + +func TestAgentProjectImportOptions_Flags(t *testing.T) { + checkFlags(t, &AgentProjectImportOptions{}, []string{"member"}) +} + +func TestAgentProjectExportOptions_Flags(t *testing.T) { + opts := &AgentProjectExportOptions{} + cmd := &cobra.Command{} + opts.Flags(cmd) + assert.NotNil(t, opts) +} diff --git a/internal/handlers/agent_projects.go b/internal/handlers/agent_projects.go new file mode 100644 index 00000000..0621f27e --- /dev/null +++ b/internal/handlers/agent_projects.go @@ -0,0 +1,21 @@ +// Copyright 2024 Itential Inc. All Rights Reserved +// Unauthorized copying of this file, via any medium is strictly prohibited +// Proprietary and confidential + +package handlers + +import ( + "github.com/itential/ipctl/internal/flags" + "github.com/itential/ipctl/internal/runners" +) + +func NewAgentProjectHandler(rt *Runtime, desc Descriptors) AssetHandler { + return NewAssetHandler( + runners.NewAgentProjectRunner(rt.GetClient(), rt.GetConfig()), + desc[agentProjectsDescriptor], + &AssetHandlerFlags{ + Import: &flags.AgentProjectImportOptions{}, + Export: &flags.AgentProjectExportOptions{}, + }, + ) +} diff --git a/internal/handlers/descriptors.go b/internal/handlers/descriptors.go index cc3c0a80..f67eda67 100644 --- a/internal/handlers/descriptors.go +++ b/internal/handlers/descriptors.go @@ -49,6 +49,8 @@ const ( configurationParsersDescriptor = "configuration_parsers" gctreesDescriptor = "gctrees" + agentProjectsDescriptor = "agent_projects" + serverDescriptor = "server" localAAADescriptor = "localaaa" diff --git a/internal/handlers/descriptors/agent_projects.yaml b/internal/handlers/descriptors/agent_projects.yaml new file mode 100644 index 00000000..f349dc31 --- /dev/null +++ b/internal/handlers/descriptors/agent_projects.yaml @@ -0,0 +1,75 @@ +# Copyright 2025 Itential Inc. All Rights Reserved +# Unauthorized copying of this file, via any medium is strictly prohibited +# Proprietary and confidential +--- +get: + use: agent-projects + group: flow-agent + + description: | + Display one or more agent projects. + + The `get agent-projects` command will retrieve all configured agent projects + from the FlowAI server and display them in table format by default. If + there are no configured agent projects, the command will return an empty table. + + example: | + # Display all configured agent projects + $ ipctl get agent-projects + +describe: + use: agent-project + group: flow-agent + + description: | + Display details about an agent project. + + The `describe agent-project ` command will retrieve the specified + agent project from the FlowAI server, if it exists, and display it. + If the specified agent project does not exist, this command will return + an error. + + example: | + # Display details about an agent project called `my-agents` + $ ipctl describe agent-project "my-agents" + +import: + use: agent-project + group: flow-agent + + description: | + Import an agent project. + + The `import agent-project` command will import an agent project bundle + into the FlowAI service. The path argument must point to a JSON file + containing an exported agent project bundle. + + If an agent project with the same name already exists on the server, + this command will return an error. + + example: | + # Import an agent project from a file + $ ipctl import agent-project path/to/my-agents.agent-project.json + +export: + use: agent-project + group: flow-agent + + exact_args: 1 + + description: | + Export an agent project. + + The `export agent-project ` command will export the specified agent + project bundle from the FlowAI service. The exported bundle is saved as + a JSON file named after the project. + + If a `--path` or `--repository` option is provided, the file will be + written to the specified location or committed to the Git repository. + + example: | + # Export an agent project to the current directory + $ ipctl export agent-project "my-agents" + + # Export an agent project to a specific directory + $ ipctl export agent-project "my-agents" --path /tmp/exports diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go index c3ac79e6..fe757ca8 100644 --- a/internal/handlers/handlers.go +++ b/internal/handlers/handlers.go @@ -140,6 +140,9 @@ func NewHandler(rt *Runtime) Handler { // Lifecycle Manager handlers NewModelHandler(rt, descriptors), + // Flow Agent handlers + NewAgentProjectHandler(rt, descriptors), + NewServerHandler(rt, descriptors), } diff --git a/internal/runners/agent_projects.go b/internal/runners/agent_projects.go new file mode 100644 index 00000000..183e601e --- /dev/null +++ b/internal/runners/agent_projects.go @@ -0,0 +1,461 @@ +// Copyright 2024 Itential Inc. All Rights Reserved +// Unauthorized copying of this file, via any medium is strictly prohibited +// Proprietary and confidential + +package runners + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "slices" + "strings" + + "github.com/itential/ipctl/internal/config" + "github.com/itential/ipctl/internal/flags" + "github.com/itential/ipctl/internal/logging" + "github.com/itential/ipctl/pkg/client" + "github.com/itential/ipctl/pkg/resources" + "github.com/itential/ipctl/pkg/services" +) + +// AgentProjectRunner orchestrates CLI commands for agent project management. +// It implements the Reader, Importer, and Exporter interfaces. +type AgentProjectRunner struct { + BaseRunner + resource resources.AgentProjectResourcer + accounts *services.AccountService + groups *services.GroupService + userSettings *services.UserSettingsService +} + +// NewAgentProjectRunner creates a new AgentProjectRunner with the provided client and config. +func NewAgentProjectRunner(c client.Client, cfg config.Provider) *AgentProjectRunner { + return &AgentProjectRunner{ + BaseRunner: NewBaseRunner(c, cfg), + resource: resources.NewAgentProjectResource(services.NewAgentProjectService(c)), + accounts: services.NewAccountService(c), + groups: services.NewGroupService(c), + userSettings: services.NewUserSettingsService(c), + } +} + +////////////////////////////////////////////////////////////////////////////// +// Reader Interface +// + +// Get retrieves all agent projects and returns them for display. +func (r *AgentProjectRunner) Get(in Request) (*Response, error) { + logging.Trace() + + projects, err := r.resource.GetAll() + if err != nil { + return nil, err + } + + return &Response{ + Keys: []string{"name", "description"}, + Object: projects, + }, nil +} + +// Describe retrieves detailed information about a specific agent project. +func (r *AgentProjectRunner) Describe(in Request) (*Response, error) { + logging.Trace() + + project, err := r.resource.GetByName(in.Args[0]) + if err != nil { + return nil, err + } + + createdBy := extractAgentProjectUsername(project.CreatedBy, "unknown") + updatedBy := extractAgentProjectUsername(project.LastUpdatedBy, "unknown") + + output := []string{ + fmt.Sprintf("Name: %s (%s)", project.Name, project.Id), + fmt.Sprintf("Description: %s", project.Description), + fmt.Sprintf("Created: %s, by: %s", project.Created, createdBy), + fmt.Sprintf("Updated: %s, by: %s", project.LastUpdated, updatedBy), + fmt.Sprintf("Components: %d", len(project.Components)), + } + + return &Response{ + Text: strings.Join(output, "\n"), + Object: project, + }, nil +} + +////////////////////////////////////////////////////////////////////////////// +// Importer Interface +// + +// Import imports an agent project bundle from a local file or Git repository. +// Optionally adds members to the imported project if specified via the --member flag. +func (r *AgentProjectRunner) Import(in Request) (*Response, error) { + logging.Trace() + + common := in.Common.(*flags.AssetImportCommon) + options := in.Options.(*flags.AgentProjectImportOptions) + + path, err := importGetPathFromRequest(in) + if err != nil { + return nil, err + } + + wd := filepath.Dir(path) + + if common.Repository != "" { + defer os.RemoveAll(wd) + } + + var bundle services.AgentProjectBundle + + if err := importLoadFromDisk(path, &bundle); err != nil { + return nil, err + } + + // conflictModeExplicit tracks whether the caller explicitly chose a --conflict-mode + // (e.g. "keep-both") rather than relying on the --replace-derived default. When explicit, + // the pre-import existence check below must be skipped so the server can perform its own + // conflict handling (such as server-side duplication for keep-both) on a name collision. + conflictModeExplicit := options.ConflictMode != "" + + if !common.Replace && !conflictModeExplicit { + existing, err := r.resource.GetByName(bundle.Name) + if err == nil && existing != nil { + return nil, fmt.Errorf("agent project %q already exists, use --replace to overwrite", bundle.Name) + } + } + + conflictMode := options.ConflictMode + if conflictMode == "" { + // --conflict-mode wasn't explicitly set: --replace implies "replace", otherwise default to "keep-both". + if common.Replace { + conflictMode = "replace" + } else { + conflictMode = "keep-both" + } + } + + if conflictMode != "keep-both" && conflictMode != "replace" { + return nil, fmt.Errorf("invalid --conflict-mode %q (must be 'keep-both' or 'replace')", conflictMode) + } + + imported, err := r.resource.Import(bundle, conflictMode) + if err != nil { + return nil, err + } + + if err := r.updateMembers(imported.Id, options.Members); err != nil { + // Cleanup: delete the partially imported project. This is only safe when the import + // created a brand-new project. In "replace" mode, the import overwrote an existing + // project in place, so deleting it would destroy the user's only remaining copy of + // their data rather than rolling back a fresh creation. + if conflictMode != "replace" { + if delErr := r.resource.Delete(imported.Id); delErr != nil { + logging.Error(delErr, "failed to cleanup agent project %s after member update error", imported.Id) + } + } else { + logging.Error(err, "member update failed after replacing agent project %s; skipping cleanup delete to avoid destroying the replaced project", imported.Id) + } + return nil, fmt.Errorf("failed to update agent project members: %w", err) + } + + return &Response{ + Text: fmt.Sprintf("Successfully imported agent project `%s` (%s)", imported.Name, imported.Id), + }, nil +} + +////////////////////////////////////////////////////////////////////////////// +// Exporter Interface +// + +// Export exports an agent project bundle to a local file or Git repository. +func (r *AgentProjectRunner) Export(in Request) (*Response, error) { + logging.Trace() + + name := in.Args[0] + + project, err := r.resource.GetByName(name) + if err != nil { + return nil, err + } + + bundle, err := r.resource.Export(project.Id) + if err != nil { + return nil, err + } + + b, err := json.Marshal(bundle) + if err != nil { + return nil, err + } + + var exported map[string]interface{} + if err := json.Unmarshal(b, &exported); err != nil { + return nil, err + } + + fn := fmt.Sprintf("%s.agent-project.json", normalizeFilename(name)) + + if err := exportAssetFromRequest(in, exported, fn); err != nil { + return nil, err + } + + return &Response{ + Text: fmt.Sprintf("Successfully exported agent project `%s`", bundle.Name), + }, nil +} + +////////////////////////////////////////////////////////////////////////////// +// Private helpers +// + +// extractAgentProjectUsername safely extracts a username from a user object. +func extractAgentProjectUsername(userObj any, fallback string) string { + if userObj == nil { + return fallback + } + + userMap, ok := userObj.(map[string]interface{}) + if !ok { + return fallback + } + + username, ok := userMap["username"].(string) + if !ok { + return fallback + } + + return username +} + +// AgentProjectMemberSpec represents an agent project member specification parsed +// from CLI flags. It is used internally for parsing member specifications from +// CLI flags and constructing AgentProjectMember objects for API calls. +// +// Type must be either "account" or "group" (use constants services.MemberTypeAccount or services.MemberTypeGroup). +// Access must be one of "owner", "editor", "operator", or "viewer" (use constants services.MemberRole*). +// Name is the username (for accounts) or group name (for groups). +type AgentProjectMemberSpec struct { + Type string // "account" or "group" + Name string // Username or group name + Access string // "owner", "editor", "operator", or "viewer" +} + +// updateMembers adds members to an agent project after it has been created or imported. +// +// This helper method is used internally by Import to add members to an agent project. +// It parses member specifications, resolves accounts and groups, and adds them to the +// project while automatically excluding the active user. +// +// Parameters: +// - projectId: The ID of the agent project to add members to +// - projectMembers: Slice of member specification strings (format: "type=account,name=alice,access=editor") +// +// Returns: +// - nil on success +// - Error if member parsing, resolution, or addition fails +func (r *AgentProjectRunner) updateMembers(projectId string, projectMembers []string) error { + logging.Trace() + + if len(projectMembers) == 0 { + return nil // No members to update + } + + activeUser, err := r.userSettings.Get() + if err != nil { + return fmt.Errorf("failed to get active user: %w", err) + } + + members, err := r.buildAgentProjectMembers(projectMembers, activeUser.Username, r.accounts, r.groups) + if err != nil { + return err + } + + if len(members) > 0 { + if err := r.resource.AddMembers(projectId, members); err != nil { + return fmt.Errorf("failed to add members to agent project: %w", err) + } + } + + return nil +} + +// resolveAgentProjectMember resolves an AgentProjectMemberSpec into an AgentProjectMember +// by looking up the account or group and populating all required fields. +func (r *AgentProjectRunner) resolveAgentProjectMember( + member *AgentProjectMemberSpec, + accounts *services.AccountService, + groups *services.GroupService, +) (services.AgentProjectMember, error) { + switch member.Type { + case services.MemberTypeAccount: + account, err := accounts.GetByName(member.Name) + if err != nil { + return services.AgentProjectMember{}, fmt.Errorf("account %q not found: %w", member.Name, err) + } + return services.AgentProjectMember{ + Provenance: account.Provenance, + Reference: account.Id, + Role: member.Access, + Type: services.MemberTypeAccount, + Username: account.Username, + }, nil + + case services.MemberTypeGroup: + group, err := groups.GetByName(member.Name) + if err != nil { + return services.AgentProjectMember{}, fmt.Errorf("group %q not found: %w", member.Name, err) + } + return services.AgentProjectMember{ + Provenance: group.Provenance, + Reference: group.Id, + Role: member.Access, + Type: services.MemberTypeGroup, + Name: group.Name, + }, nil + + default: + return services.AgentProjectMember{}, fmt.Errorf("invalid member type %q (must be 'account' or 'group')", member.Type) + } +} + +// buildAgentProjectMembers converts member specifications into AgentProjectMember objects. +// It resolves accounts and groups by name, skips the active user, and validates +// member types and access levels. +// +// Parameters: +// - memberSpecs: Slice of member specification strings (format: "type=account,name=alice,access=editor") +// - activeUsername: Username of the currently authenticated user (will be skipped) +// - accounts: Account service for resolving account names +// - groups: Group service for resolving group names +// +// Returns: +// - Slice of AgentProjectMember objects ready for API submission +// - Error if member parsing fails, member not found, or resolution fails +func (r *AgentProjectRunner) buildAgentProjectMembers( + memberSpecs []string, + activeUsername string, + accounts *services.AccountService, + groups *services.GroupService, +) ([]services.AgentProjectMember, error) { + logging.Trace() + + if len(memberSpecs) == 0 { + return nil, nil + } + + var members []services.AgentProjectMember + + for _, spec := range memberSpecs { + member, err := parseAgentProjectMember(spec) + if err != nil { + return nil, fmt.Errorf("invalid member specification %q: %w", spec, err) + } + + // Skip active user + if member.Type == services.MemberTypeAccount && member.Name == activeUsername { + logging.Info("skipping active user %q from member list", member.Name) + continue + } + + projectMember, err := r.resolveAgentProjectMember(member, accounts, groups) + if err != nil { + return nil, fmt.Errorf("failed to resolve member %q: %w", member.Name, err) + } + + members = append(members, projectMember) + } + + return members, nil +} + +// parseAgentProjectMember parses a member specification string into an AgentProjectMemberSpec. +// The format is: "type=,name=[,access=]" +// +// Parameters: +// - member: Member specification string +// +// Returns: +// - Parsed AgentProjectMemberSpec with defaults applied +// - Error if format is invalid or required fields are missing +// +// Example valid inputs: +// - "type=account,name=alice" +// - "type=account,name=alice,access=owner" +// - "type=group,name=devops,access=editor" +func parseAgentProjectMember(member string) (*AgentProjectMemberSpec, error) { + if member == "" { + return nil, fmt.Errorf("member specification cannot be empty") + } + + parts := strings.Split(member, ",") + m := &AgentProjectMemberSpec{ + Access: services.MemberRoleEditor, // Default access level + } + + seen := make(map[string]bool, 3) + + for _, part := range parts { + part = strings.TrimSpace(part) + if part == "" { + continue + } + + tokens := strings.SplitN(part, "=", 2) + if len(tokens) != 2 { + return nil, fmt.Errorf("invalid key=value pair %q in member specification %q", part, member) + } + + key := strings.TrimSpace(tokens[0]) + value := strings.TrimSpace(tokens[1]) + + if value == "" { + return nil, fmt.Errorf("empty value for key %q in member specification %q", key, member) + } + + if seen[key] { + return nil, fmt.Errorf("duplicate key %q in member specification %q", key, member) + } + seen[key] = true + + switch key { + case "type": + m.Type = value + case "name": + m.Name = value + case "access": + m.Access = value + default: + return nil, fmt.Errorf("unknown key %q in member specification %q", key, member) + } + } + + // Validate required fields + if m.Type == "" { + return nil, fmt.Errorf("missing required 'type' field in member specification %q", member) + } + if m.Name == "" { + return nil, fmt.Errorf("missing required 'name' field in member specification %q", member) + } + + // Validate type + if m.Type != services.MemberTypeAccount && m.Type != services.MemberTypeGroup { + return nil, fmt.Errorf("invalid type %q (must be 'account' or 'group') in member specification %q", m.Type, member) + } + + // Validate access + validAccess := []string{ + services.MemberRoleOwner, + services.MemberRoleEditor, + services.MemberRoleOperator, + services.MemberRoleViewer, + } + if !slices.Contains(validAccess, m.Access) { + return nil, fmt.Errorf("invalid access %q (must be one of: owner, editor, operator, viewer) in member specification %q", m.Access, member) + } + + return m, nil +} diff --git a/pkg/resources/agent_projects.go b/pkg/resources/agent_projects.go new file mode 100644 index 00000000..2e59e232 --- /dev/null +++ b/pkg/resources/agent_projects.go @@ -0,0 +1,104 @@ +// Copyright 2024 Itential Inc. All Rights Reserved +// Unauthorized copying of this file, via any medium is strictly prohibited +// Proprietary and confidential + +package resources + +import ( + "github.com/itential/ipctl/internal/logging" + "github.com/itential/ipctl/pkg/services" +) + +// AgentProjectResource provides business logic for agent project operations. +type AgentProjectResource struct { + BaseResource + service services.AgentProjectServicer +} + +// NewAgentProjectResource creates a new AgentProjectResource with the given service. +func NewAgentProjectResource(svc services.AgentProjectServicer) AgentProjectResourcer { + return &AgentProjectResource{ + BaseResource: NewBaseResource(), + service: svc, + } +} + +// GetAll retrieves all agent projects. +func (r *AgentProjectResource) GetAll() ([]services.AgentProject, error) { + return r.service.GetAll() +} + +// Get retrieves an agent project by ID. +func (r *AgentProjectResource) Get(id string) (*services.AgentProject, error) { + return r.service.Get(id) +} + +// GetByName retrieves an agent project by name. +func (r *AgentProjectResource) GetByName(name string) (*services.AgentProject, error) { + logging.Trace() + return r.service.GetByName(name) +} + +// Export exports an agent project bundle by project ID. +func (r *AgentProjectResource) Export(id string) (*services.AgentProjectBundle, error) { + return r.service.Export(id) +} + +// Import imports an agent project bundle. +func (r *AgentProjectResource) Import(bundle services.AgentProjectBundle, conflictMode string) (*services.AgentProjectBundle, error) { + return r.service.Import(bundle, conflictMode) +} + +// Delete removes an agent project by its identifier. +// This is a pass-through to the service layer for pure API access. +func (r *AgentProjectResource) Delete(id string) error { + return r.service.Delete(id) +} + +// AddMembers adds new members to an existing agent project. +// This method implements the business logic of fetching current members, +// merging with new members, and updating the project. +// +// The PATCH API only accepts "type", "reference", and "role" per member — +// it rejects members with any additional properties (e.g. username, name, +// provenance), so members are serialized down to that minimal shape here. +// It also rejects duplicate (type, reference) pairs, so if a newly specified +// member matches one the platform already assigned (e.g. the project creator), +// the new member's role wins. +func (r *AgentProjectResource) AddMembers(projectId string, members []services.AgentProjectMember) error { + logging.Trace() + + project, err := r.service.Get(projectId) + if err != nil { + return err + } + + type memberKey struct { + Type string + Reference string + } + + merged := make(map[memberKey]services.AgentProjectMember, len(members)+len(project.Members)) + + for _, m := range project.Members { + merged[memberKey{m.Type, m.Reference}] = m + } + + for _, m := range members { + merged[memberKey{m.Type, m.Reference}] = m + } + + minimalMembers := make([]map[string]interface{}, 0, len(merged)) + for _, m := range merged { + minimalMembers = append(minimalMembers, map[string]interface{}{ + "type": m.Type, + "reference": m.Reference, + "role": m.Role, + }) + } + + data := map[string]interface{}{ + "members": minimalMembers, + } + return r.service.UpdateProject(projectId, data) +} diff --git a/pkg/resources/interfaces.go b/pkg/resources/interfaces.go index 0f2b26ec..b2db2a2c 100644 --- a/pkg/resources/interfaces.go +++ b/pkg/resources/interfaces.go @@ -6,6 +6,17 @@ package resources import "github.com/itential/ipctl/pkg/services" +// AgentProjectResourcer defines operations for agent project business logic. +type AgentProjectResourcer interface { + GetAll() ([]services.AgentProject, error) + Get(id string) (*services.AgentProject, error) + GetByName(name string) (*services.AgentProject, error) + Export(id string) (*services.AgentProjectBundle, error) + Import(bundle services.AgentProjectBundle, conflictMode string) (*services.AgentProjectBundle, error) + AddMembers(projectId string, members []services.AgentProjectMember) error + Delete(id string) error +} + // AccountResourcer defines operations for account business logic. // It provides methods for retrieving accounts with business logic applied. type AccountResourcer interface { diff --git a/pkg/services/accounts.go b/pkg/services/accounts.go index 89b946fb..145f0942 100644 --- a/pkg/services/accounts.go +++ b/pkg/services/accounts.go @@ -142,6 +142,8 @@ func (svc *AccountService) Activate(id string) error { // GetByName retrieves an account by username using client-side filtering. // DEPRECATED: Business logic method - prefer using resources.AccountResource.GetByName +// When multiple accounts share the same username, an active account is +// preferred over an inactive one. func (svc *AccountService) GetByName(name string) (*Account, error) { logging.Trace() @@ -150,11 +152,23 @@ func (svc *AccountService) GetByName(name string) (*Account, error) { return nil, err } + var inactiveMatch *Account + for i := range accounts { if accounts[i].Username == name { - return &accounts[i], nil + if !accounts[i].Inactive { + return &accounts[i], nil + } + + if inactiveMatch == nil { + inactiveMatch = &accounts[i] + } } } + if inactiveMatch != nil { + return inactiveMatch, nil + } + return nil, errors.New("account not found") } diff --git a/pkg/services/accounts_test.go b/pkg/services/accounts_test.go index 0ff57e22..43b2526d 100644 --- a/pkg/services/accounts_test.go +++ b/pkg/services/accounts_test.go @@ -133,6 +133,44 @@ func TestAccountGetByNameError(t *testing.T) { assert.Equal(t, reflect.TypeOf((*Account)(nil)), reflect.TypeOf(res)) } +func TestAccountGetByNamePrefersActive(t *testing.T) { + svc := setupAccountService() + defer testlib.Teardown() + + mockResponse := `{ + "results": [ + { + "_id": "inactive-id-1", + "email": "joksan.flores@itential.com", + "firstname": "Joksan", + "inactive": true, + "loggedIn": false, + "provenance": "Okta SAML", + "username": "joksan.flores@itential.com" + }, + { + "_id": "active-id", + "email": "joksan.flores@itential.com", + "firstname": "Joksan", + "inactive": false, + "loggedIn": true, + "provenance": "CloudAAA", + "username": "joksan.flores@itential.com" + } + ], + "total": 2 + }` + + testlib.AddGetResponseToMux("/authorization/accounts", mockResponse, 0) + + res, err := svc.GetByName("joksan.flores@itential.com") + + assert.Nil(t, err) + assert.NotNil(t, res) + assert.Equal(t, "active-id", res.Id) + assert.False(t, res.Inactive) +} + func TestAccountGetByNameNotFound(t *testing.T) { svc := setupAccountService() defer testlib.Teardown() diff --git a/pkg/services/agent_projects.go b/pkg/services/agent_projects.go new file mode 100644 index 00000000..190da016 --- /dev/null +++ b/pkg/services/agent_projects.go @@ -0,0 +1,289 @@ +// Copyright 2024 Itential Inc. All Rights Reserved +// Unauthorized copying of this file, via any medium is strictly prohibited +// Proprietary and confidential + +package services + +import ( + "errors" + "fmt" + "net/http" + + "github.com/itential/ipctl/internal/logging" + "github.com/itential/ipctl/pkg/client" +) + +const ( + agentProjectsBasePath = "/agent-project-service/projects" + agentProjectsAdminBasePath = "/agent-project-service/admin/projects" + agentProjectBundlesPath = "/agent-project-service/project-bundles" + defaultAgentProjectLimit = 100 +) + +// AgentProjectComponent represents a single component (agent) within an agent project. +type AgentProjectComponent struct { + Type string `json:"type"` + Iid int `json:"iid"` + Reference string `json:"reference"` + Folder string `json:"folder"` +} + +// AgentProjectMember represents a user or group member of an agent project. +type AgentProjectMember struct { + Type string `json:"type"` + Role string `json:"role"` + Reference string `json:"reference"` + Username string `json:"username,omitempty"` + Name string `json:"name,omitempty"` + Provenance string `json:"provenance,omitempty"` +} + +// AgentProject represents an agent project in the agent-project-service. +type AgentProject struct { + Id string `json:"_id"` + Iid int `json:"iid"` + Name string `json:"name"` + Description string `json:"description"` + Components []AgentProjectComponent `json:"components"` + Created string `json:"created"` + CreatedBy any `json:"createdBy"` + LastUpdated string `json:"lastUpdated"` + LastUpdatedBy any `json:"lastUpdatedBy"` + Members []AgentProjectMember `json:"members"` +} + +// AgentProjectBundle represents the export bundle for an agent project. +// It contains the project metadata along with all agent definitions. +type AgentProjectBundle struct { + Id string `json:"_id"` + Name string `json:"name"` + Description string `json:"description"` + AgentProjectBundleVersion int `json:"agentProjectBundleVersion"` + Created string `json:"created,omitempty"` + CreatedBy any `json:"createdBy,omitempty"` + Agents []map[string]interface{} `json:"agents"` +} + +// AgentProjectService provides operations for managing agent projects. +type AgentProjectService struct { + BaseService +} + +// NewAgentProjectService creates a new AgentProjectService with the provided client. +func NewAgentProjectService(c client.Client) *AgentProjectService { + return &AgentProjectService{BaseService: NewBaseService(c)} +} + +type getAgentProjectsResponse struct { + Message string `json:"message"` + Data struct { + Items []AgentProject `json:"items"` + Total int `json:"total"` + Skip int `json:"skip"` + Limit int `json:"limit"` + } `json:"data"` +} + +type agentProjectResponse struct { + Message string `json:"message"` + Data AgentProject `json:"data"` +} + +type agentProjectBundleResponse struct { + Message string `json:"message"` + Data AgentProjectBundle `json:"data"` +} + +// GetAll retrieves all agent projects, handling pagination automatically. +func (svc *AgentProjectService) GetAll() ([]AgentProject, error) { + logging.Trace() + + var res getAgentProjectsResponse + projects := make([]AgentProject, 0, defaultAgentProjectLimit) + limit := defaultAgentProjectLimit + skip := 0 + + for { + if err := svc.GetRequest(&Request{ + uri: agentProjectsBasePath, + params: &QueryParams{Limit: limit, Skip: skip}, + }, &res); err != nil { + return nil, fmt.Errorf("failed to retrieve agent projects (skip=%d, limit=%d): %w", skip, limit, err) + } + + projects = append(projects, res.Data.Items...) + + if len(projects) >= res.Data.Total { + break + } + + skip += limit + } + + logging.Info("Found %d agent project(s)", len(projects)) + + return projects, nil +} + +// Get retrieves a single agent project by its ID. +func (svc *AgentProjectService) Get(id string) (*AgentProject, error) { + logging.Trace() + + if id == "" { + return nil, fmt.Errorf("agent project id cannot be empty") + } + + var res agentProjectResponse + + uri := fmt.Sprintf("%s/%s", agentProjectsBasePath, id) + + if err := svc.BaseService.Get(uri, &res); err != nil { + return nil, fmt.Errorf("failed to get agent project %s: %w", id, err) + } + + return &res.Data, nil +} + +// GetByName retrieves an agent project by name using client-side filtering. +func (svc *AgentProjectService) GetByName(name string) (*AgentProject, error) { + logging.Trace() + + projects, err := svc.GetAll() + if err != nil { + return nil, err + } + + for i := range projects { + if projects[i].Name == name { + return &projects[i], nil + } + } + + return nil, errors.New("agent project not found") +} + +// Export exports an agent project bundle by project ID. +// The bundle contains the project metadata and all agent definitions. +func (svc *AgentProjectService) Export(id string) (*AgentProjectBundle, error) { + logging.Trace() + + if id == "" { + return nil, fmt.Errorf("agent project id cannot be empty") + } + + var res agentProjectBundleResponse + + uri := fmt.Sprintf("%s/%s/export", agentProjectBundlesPath, id) + + if err := svc.BaseService.Get(uri, &res); err != nil { + return nil, fmt.Errorf("failed to export agent project %s: %w", id, err) + } + + return &res.Data, nil +} + +// UpdateProject updates an agent project via PATCH request. +// +// This method accepts a map of fields to update. Common fields include: +// - members: []AgentProjectMember - replaces the entire members list +// - name: string - updates the project name +// - description: string - updates the project description +// +// To update only members, use a map with the "members" key: +// +// data := map[string]interface{}{"members": members} +// +// The projectId parameter must be a valid MongoDB ObjectId string. +// The data parameter should contain the fields to update. +// +// Returns an error if the update fails or if the project does not exist. +func (svc *AgentProjectService) UpdateProject(projectId string, data map[string]interface{}) error { + logging.Trace() + + if projectId == "" { + return fmt.Errorf("agent project id cannot be empty") + } + + if data == nil || len(data) == 0 { + return fmt.Errorf("update data cannot be nil or empty") + } + + uri := fmt.Sprintf("%s/%s", agentProjectsBasePath, projectId) + + if err := svc.Patch(uri, data, nil); err != nil { + return fmt.Errorf("failed to update agent project %s: %w", projectId, err) + } + + return nil +} + +// Delete removes an agent project by its unique identifier. +// +// This is a destructive operation that cannot be undone. All components +// and configuration within the project will be deleted. +// +// The id parameter must be a valid MongoDB ObjectId string. +// Returns an error if the project does not exist or if the deletion fails. +func (svc *AgentProjectService) Delete(id string) error { + logging.Trace() + + if id == "" { + return fmt.Errorf("agent project id cannot be empty") + } + + uri := fmt.Sprintf("%s/%s", agentProjectsAdminBasePath, id) + + if err := svc.BaseService.Delete(uri); err != nil { + return fmt.Errorf("failed to delete agent project %s: %w", id, err) + } + + return nil +} + +// Import imports an agent project bundle into the platform. +// conflictMode controls how a collision with an existing project is handled ("keep-both" or "replace"). +// providerResolutions is sent as a map of agent UUID to nil, since no provider profiles are resolved by ipctl. +func (svc *AgentProjectService) Import(bundle AgentProjectBundle, conflictMode string) (*AgentProjectBundle, error) { + logging.Trace() + + type importResponse struct { + Message string `json:"message"` + Data AgentProjectBundle `json:"data"` + } + + providerResolutions := make(map[string]interface{}, len(bundle.Agents)) + + for _, agent := range bundle.Agents { + uuid, ok := agent["_id"].(string) + if !ok || uuid == "" { + uuid, ok = agent["uuid"].(string) + } + + if !ok || uuid == "" { + logging.Info("skipping agent with no _id or uuid field when building providerResolutions") + continue + } + + providerResolutions[uuid] = nil + } + + body := map[string]interface{}{ + "bundle": bundle, + "conflictMode": conflictMode, + "providerResolutions": providerResolutions, + } + + var res importResponse + + uri := fmt.Sprintf("%s/import", agentProjectBundlesPath) + + if err := svc.PostRequest(&Request{ + uri: uri, + body: body, + expectedStatusCode: http.StatusOK, + }, &res); err != nil { + return nil, fmt.Errorf("failed to import agent project: %w", err) + } + + return &res.Data, nil +} diff --git a/pkg/services/agent_projects_test.go b/pkg/services/agent_projects_test.go new file mode 100644 index 00000000..8468a26f --- /dev/null +++ b/pkg/services/agent_projects_test.go @@ -0,0 +1,225 @@ +// Copyright 2024 Itential Inc. All Rights Reserved +// Unauthorized copying of this file, via any medium is strictly prohibited +// Proprietary and confidential + +package services + +import ( + "fmt" + "net/http" + "path/filepath" + "testing" + + "github.com/itential/ipctl/internal/testlib" + "github.com/stretchr/testify/assert" +) + +var ( + agentProjectsGetAllSuccess = "agent-project-service/getall.success.json" + agentProjectsGetSuccess = "agent-project-service/get.success.json" + agentProjectsExportSuccess = "agent-project-service/export.success.json" + agentProjectsImportSuccess = "agent-project-service/import.success.json" +) + +func setupAgentProjectService() *AgentProjectService { + return NewAgentProjectService(testlib.Setup()) +} + +func TestAgentProjectService_GetAll(t *testing.T) { + svc := setupAgentProjectService() + defer testlib.Teardown() + + for _, suite := range fixtureSuites { + response := testlib.Fixture(filepath.Join(fixtureRoot, suite, agentProjectsGetAllSuccess)) + testlib.AddGetResponseToMux("/agent-project-service/projects", response, 0) + + res, err := svc.GetAll() + + assert.Nil(t, err) + assert.NotNil(t, res) + assert.Equal(t, 2, len(res)) + assert.Equal(t, "test-agent-project-1", res[0].Name) + assert.Equal(t, "test-agent-project-2", res[1].Name) + } +} + +func TestAgentProjectService_GetAll_Error(t *testing.T) { + svc := setupAgentProjectService() + defer testlib.Teardown() + + testlib.AddGetErrorToMux("/agent-project-service/projects", "", 0) + + res, err := svc.GetAll() + + assert.NotNil(t, err) + assert.Nil(t, res) +} + +func TestAgentProjectService_Get(t *testing.T) { + svc := setupAgentProjectService() + defer testlib.Teardown() + + id := "6a1bc2d3e4f0123456789001" + + for _, suite := range fixtureSuites { + response := testlib.Fixture(filepath.Join(fixtureRoot, suite, agentProjectsGetSuccess)) + testlib.AddGetResponseToMux(fmt.Sprintf("/agent-project-service/projects/%s", id), response, 0) + + res, err := svc.Get(id) + + assert.Nil(t, err) + assert.NotNil(t, res) + assert.Equal(t, id, res.Id) + assert.Equal(t, "test-agent-project-1", res.Name) + } +} + +func TestAgentProjectService_Get_EmptyID(t *testing.T) { + svc := setupAgentProjectService() + defer testlib.Teardown() + + res, err := svc.Get("") + + assert.NotNil(t, err) + assert.Nil(t, res) + assert.Contains(t, err.Error(), "id cannot be empty") +} + +func TestAgentProjectService_Get_Error(t *testing.T) { + svc := setupAgentProjectService() + defer testlib.Teardown() + + testlib.AddGetErrorToMux("/agent-project-service/projects/bad-id", "", 0) + + res, err := svc.Get("bad-id") + + assert.NotNil(t, err) + assert.Nil(t, res) +} + +func TestAgentProjectService_GetByName(t *testing.T) { + svc := setupAgentProjectService() + defer testlib.Teardown() + + for _, suite := range fixtureSuites { + response := testlib.Fixture(filepath.Join(fixtureRoot, suite, agentProjectsGetAllSuccess)) + testlib.AddGetResponseToMux("/agent-project-service/projects", response, 0) + + res, err := svc.GetByName("test-agent-project-1") + + assert.Nil(t, err) + assert.NotNil(t, res) + assert.Equal(t, "test-agent-project-1", res.Name) + assert.Equal(t, "6a1bc2d3e4f0123456789001", res.Id) + } +} + +func TestAgentProjectService_GetByName_NotFound(t *testing.T) { + svc := setupAgentProjectService() + defer testlib.Teardown() + + for _, suite := range fixtureSuites { + response := testlib.Fixture(filepath.Join(fixtureRoot, suite, agentProjectsGetAllSuccess)) + testlib.AddGetResponseToMux("/agent-project-service/projects", response, 0) + + res, err := svc.GetByName("nonexistent-project") + + assert.NotNil(t, err) + assert.Nil(t, res) + assert.Equal(t, "agent project not found", err.Error()) + } +} + +func TestAgentProjectService_GetByName_Error(t *testing.T) { + svc := setupAgentProjectService() + defer testlib.Teardown() + + testlib.AddGetErrorToMux("/agent-project-service/projects", "", 0) + + res, err := svc.GetByName("test-agent-project-1") + + assert.NotNil(t, err) + assert.Nil(t, res) +} + +func TestAgentProjectService_Export(t *testing.T) { + svc := setupAgentProjectService() + defer testlib.Teardown() + + id := "6a1bc2d3e4f0123456789001" + + for _, suite := range fixtureSuites { + response := testlib.Fixture(filepath.Join(fixtureRoot, suite, agentProjectsExportSuccess)) + testlib.AddGetResponseToMux( + fmt.Sprintf("/agent-project-service/project-bundles/%s/export", id), + response, 0, + ) + + res, err := svc.Export(id) + + assert.Nil(t, err) + assert.NotNil(t, res) + assert.Equal(t, id, res.Id) + assert.Equal(t, "test-agent-project-1", res.Name) + assert.Equal(t, 1, len(res.Agents)) + } +} + +func TestAgentProjectService_Export_EmptyID(t *testing.T) { + svc := setupAgentProjectService() + defer testlib.Teardown() + + res, err := svc.Export("") + + assert.NotNil(t, err) + assert.Nil(t, res) + assert.Contains(t, err.Error(), "id cannot be empty") +} + +func TestAgentProjectService_Export_Error(t *testing.T) { + svc := setupAgentProjectService() + defer testlib.Teardown() + + testlib.AddGetErrorToMux("/agent-project-service/project-bundles/bad-id/export", "", 0) + + res, err := svc.Export("bad-id") + + assert.NotNil(t, err) + assert.Nil(t, res) +} + +func TestAgentProjectService_Import(t *testing.T) { + svc := setupAgentProjectService() + defer testlib.Teardown() + + for _, suite := range fixtureSuites { + response := testlib.Fixture(filepath.Join(fixtureRoot, suite, agentProjectsImportSuccess)) + testlib.AddPostResponseToMux("/agent-project-service/project-bundles/import", response, http.StatusOK) + + bundle := AgentProjectBundle{ + Name: "imported-agent-project", + Description: "An imported agent project", + Agents: []map[string]interface{}{}, + } + + res, err := svc.Import(bundle, "keep-both") + + assert.Nil(t, err) + assert.NotNil(t, res) + assert.Equal(t, "imported-agent-project", res.Name) + } +} + +func TestAgentProjectService_Import_Error(t *testing.T) { + svc := setupAgentProjectService() + defer testlib.Teardown() + + testlib.AddPostErrorToMux("/agent-project-service/project-bundles/import", "", 0) + + bundle := AgentProjectBundle{Name: "test"} + + res, err := svc.Import(bundle, "keep-both") + + assert.NotNil(t, err) + assert.Nil(t, res) +} diff --git a/pkg/services/groups.go b/pkg/services/groups.go index 0efdb997..854be237 100644 --- a/pkg/services/groups.go +++ b/pkg/services/groups.go @@ -103,6 +103,8 @@ func (svc *GroupService) Get(id string) (*Group, error) { // GetByName retrieves a group by name using client-side filtering. // DEPRECATED: Business logic method - prefer using resources.GroupResource.GetByName +// When multiple groups share the same name, an active group is preferred +// over an inactive one. func (svc *GroupService) GetByName(name string) (*Group, error) { logging.Trace() @@ -111,12 +113,24 @@ func (svc *GroupService) GetByName(name string) (*Group, error) { return nil, err } + var inactiveMatch *Group + for i := range groups { if groups[i].Name == name { - return &groups[i], nil + if !groups[i].Inactive { + return &groups[i], nil + } + + if inactiveMatch == nil { + inactiveMatch = &groups[i] + } } } + if inactiveMatch != nil { + return inactiveMatch, nil + } + return nil, errors.New("group does not exist") } diff --git a/pkg/services/groups_test.go b/pkg/services/groups_test.go index 7e5968bf..025c9780 100644 --- a/pkg/services/groups_test.go +++ b/pkg/services/groups_test.go @@ -105,6 +105,44 @@ func TestGroupGetByName(t *testing.T) { } } +func TestGroupGetByNamePrefersActive(t *testing.T) { + svc := setupGroupService() + defer testlib.Teardown() + + mockResponse := `{ + "results": [ + { + "_id": "inactive-id-1", + "provenance": "local_aaa", + "name": "duplicate_group", + "description": "Inactive duplicate", + "memberOf": [], + "assignedRoles": [], + "inactive": true + }, + { + "_id": "active-id", + "provenance": "local_aaa", + "name": "duplicate_group", + "description": "Active duplicate", + "memberOf": [], + "assignedRoles": [], + "inactive": false + } + ], + "total": 2 + }` + + testlib.AddGetResponseToMux("/authorization/groups", mockResponse, 0) + + res, err := svc.GetByName("duplicate_group") + + assert.Nil(t, err) + assert.NotNil(t, res) + assert.Equal(t, "active-id", res.Id) + assert.False(t, res.Inactive) +} + func TestGroupGetByNameError(t *testing.T) { svc := setupGroupService() defer testlib.Teardown() diff --git a/pkg/services/interfaces.go b/pkg/services/interfaces.go index 47f6a0d7..3c9fce5b 100644 --- a/pkg/services/interfaces.go +++ b/pkg/services/interfaces.go @@ -276,6 +276,18 @@ type TriggerServicer interface { Delete(id string) error } +// AgentProjectServicer defines operations for managing agent projects. +// It handles retrieval and import/export of agent project bundles. +type AgentProjectServicer interface { + GetAll() ([]AgentProject, error) + Get(id string) (*AgentProject, error) + GetByName(name string) (*AgentProject, error) + Export(id string) (*AgentProjectBundle, error) + Import(bundle AgentProjectBundle, conflictMode string) (*AgentProjectBundle, error) + UpdateProject(projectId string, data map[string]interface{}) error + Delete(id string) error +} + // WorkflowServicer defines operations for managing workflow assets. // It handles CRUD operations, import/export, and workflow execution. type WorkflowServicer interface { diff --git a/pkg/services/testdata/2023.2/agent-project-service/export.success.json b/pkg/services/testdata/2023.2/agent-project-service/export.success.json new file mode 100644 index 00000000..0d942202 --- /dev/null +++ b/pkg/services/testdata/2023.2/agent-project-service/export.success.json @@ -0,0 +1,22 @@ +{ + "message": "Successfully exported agent project", + "data": { + "_id": "6a1bc2d3e4f0123456789001", + "name": "test-agent-project-1", + "description": "First test agent project", + "agentProjectBundleVersion": 1, + "created": "2023-09-01T12:00:00.000Z", + "createdBy": { + "_id": "user123", + "username": "testuser" + }, + "agents": [ + { + "_id": "agent-001", + "name": "test-agent", + "type": "llm", + "model": "gpt-4" + } + ] + } +} diff --git a/pkg/services/testdata/2023.2/agent-project-service/get.success.json b/pkg/services/testdata/2023.2/agent-project-service/get.success.json new file mode 100644 index 00000000..7793e531 --- /dev/null +++ b/pkg/services/testdata/2023.2/agent-project-service/get.success.json @@ -0,0 +1,28 @@ +{ + "message": "Successfully retrieved agent project", + "data": { + "_id": "6a1bc2d3e4f0123456789001", + "iid": 1, + "name": "test-agent-project-1", + "description": "First test agent project", + "components": [ + { + "type": "agent", + "iid": 1, + "reference": "agent-ref-1", + "folder": "/" + } + ], + "created": "2023-09-01T12:00:00.000Z", + "createdBy": { + "_id": "user123", + "username": "testuser" + }, + "lastUpdated": "2023-09-01T12:00:00.000Z", + "lastUpdatedBy": { + "_id": "user123", + "username": "testuser" + }, + "members": [] + } +} diff --git a/pkg/services/testdata/2023.2/agent-project-service/getall.success.json b/pkg/services/testdata/2023.2/agent-project-service/getall.success.json new file mode 100644 index 00000000..6d30c7da --- /dev/null +++ b/pkg/services/testdata/2023.2/agent-project-service/getall.success.json @@ -0,0 +1,53 @@ +{ + "message": "Successfully retrieved agent projects", + "data": { + "items": [ + { + "_id": "6a1bc2d3e4f0123456789001", + "iid": 1, + "name": "test-agent-project-1", + "description": "First test agent project", + "components": [ + { + "type": "agent", + "iid": 1, + "reference": "agent-ref-1", + "folder": "/" + } + ], + "created": "2023-09-01T12:00:00.000Z", + "createdBy": { + "_id": "user123", + "username": "testuser" + }, + "lastUpdated": "2023-09-01T12:00:00.000Z", + "lastUpdatedBy": { + "_id": "user123", + "username": "testuser" + }, + "members": [] + }, + { + "_id": "6a1bc2d3e4f0123456789002", + "iid": 2, + "name": "test-agent-project-2", + "description": "Second test agent project", + "components": [], + "created": "2023-09-02T12:00:00.000Z", + "createdBy": { + "_id": "user456", + "username": "anotheruser" + }, + "lastUpdated": "2023-09-02T12:00:00.000Z", + "lastUpdatedBy": { + "_id": "user456", + "username": "anotheruser" + }, + "members": [] + } + ], + "total": 2, + "skip": 0, + "limit": 100 + } +} diff --git a/pkg/services/testdata/2023.2/agent-project-service/import.success.json b/pkg/services/testdata/2023.2/agent-project-service/import.success.json new file mode 100644 index 00000000..50255b56 --- /dev/null +++ b/pkg/services/testdata/2023.2/agent-project-service/import.success.json @@ -0,0 +1,10 @@ +{ + "message": "Successfully imported agent project", + "data": { + "_id": "6a1bc2d3e4f0123456789099", + "name": "imported-agent-project", + "description": "An imported agent project", + "agentProjectBundleVersion": 1, + "agents": [] + } +}