diff --git a/apiserver/controllers/organizations.go b/apiserver/controllers/organizations.go index f850dc71..fccb22ea 100644 --- a/apiserver/controllers/organizations.go +++ b/apiserver/controllers/organizations.go @@ -23,7 +23,7 @@ func (a *APIController) CreateOrgHandler(w http.ResponseWriter, r *http.Request) repo, err := a.r.CreateOrganization(ctx, repoData) if err != nil { - log.Printf("error creating repository: %s", err) + log.Printf("error creating repository: %+v", err) handleError(w, err) return } @@ -86,7 +86,7 @@ func (a *APIController) DeleteOrgHandler(w http.ResponseWriter, r *http.Request) } if err := a.r.DeleteOrganization(ctx, orgID); err != nil { - log.Printf("fetching org: %s", err) + log.Printf("removing org: %+v", err) handleError(w, err) return } diff --git a/cmd/garm-cli/cmd/org_pool.go b/cmd/garm-cli/cmd/org_pool.go new file mode 100644 index 00000000..80fe8d19 --- /dev/null +++ b/cmd/garm-cli/cmd/org_pool.go @@ -0,0 +1,244 @@ +/* +Copyright © 2022 NAME HERE + +*/ +package cmd + +import ( + "fmt" + "garm/config" + "garm/params" + "strings" + + "github.com/spf13/cobra" +) + +// orgPoolCmd represents the pool command +var orgPoolCmd = &cobra.Command{ + Use: "pool", + SilenceUsage: true, + Aliases: []string{"pools"}, + Short: "Manage pools", + Long: `Manage pools for a organization. + +Repositories and organizations can define multiple pools with different +characteristics, which in turn will spawn github self hosted runners on +compute instances that reflect those characteristics. + +For example, one pool can define a runner with tags "GPU,ML" which will +spin up instances with access to a GPU, on the desired provider.`, + Run: nil, +} + +var orgPoolAddCmd = &cobra.Command{ + Use: "add", + Aliases: []string{"create"}, + Short: "Add pool", + Long: `Add a new pool organization to the manager.`, + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + if needsInit { + return needsInitError + } + + if len(args) == 0 { + return fmt.Errorf("requires a organization ID") + } + + if len(args) > 1 { + return fmt.Errorf("too many arguments") + } + + tags := strings.Split(poolTags, ",") + newPoolParams := params.CreatePoolParams{ + ProviderName: poolProvider, + MaxRunners: poolMaxRunners, + MinIdleRunners: poolMinIdleRunners, + Image: poolImage, + Flavor: poolFlavor, + OSType: config.OSType(poolOSType), + OSArch: config.OSArch(poolOSArch), + Tags: tags, + Enabled: poolEnabled, + } + if err := newPoolParams.Validate(); err != nil { + return err + } + pool, err := cli.CreateOrgPool(args[0], newPoolParams) + if err != nil { + return err + } + formatOnePool(pool) + return nil + }, +} + +var orgPoolListCmd = &cobra.Command{ + Use: "list", + Aliases: []string{"ls"}, + Short: "List organization pools", + Long: `List all configured pools for a given organization.`, + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + if needsInit { + return needsInitError + } + + if len(args) == 0 { + return fmt.Errorf("requires a organization ID") + } + + if len(args) > 1 { + return fmt.Errorf("too many arguments") + } + + pools, err := cli.ListOrgPools(args[0]) + if err != nil { + return err + } + formatPools(pools) + return nil + }, +} + +var orgPoolShowCmd = &cobra.Command{ + Use: "show", + Short: "Show details for one pool", + Long: `Displays detailed information about a single pool.`, + RunE: func(cmd *cobra.Command, args []string) error { + if needsInit { + return needsInitError + } + + if len(args) < 2 || len(args) > 2 { + return fmt.Errorf("command requires orgID and poolID") + } + + pool, err := cli.GetOrgPool(args[0], args[1]) + if err != nil { + return err + } + + formatOnePool(pool) + return nil + }, +} + +var orgPoolDeleteCmd = &cobra.Command{ + Use: "delete", + Aliases: []string{"remove", "rm", "del"}, + Short: "Removes one pool", + Long: `Delete one organization pool from the manager.`, + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + if needsInit { + return needsInitError + } + if len(args) < 2 || len(args) > 2 { + return fmt.Errorf("command requires orgID and poolID") + } + + if err := cli.DeleteOrgPool(args[0], args[1]); err != nil { + return err + } + return nil + }, +} + +var orgPoolUpdateCmd = &cobra.Command{ + Use: "update", + Short: "Update one pool", + Long: `Updates pool characteristics. + +This command updates the pool characteristics. Runners already created prior to updating +the pool, will not be recreated. IF they no longer suit your needs, you will need to +explicitly remove them using the runner delete command. + `, + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + if needsInit { + return needsInitError + } + + if len(args) < 2 || len(args) > 2 { + return fmt.Errorf("command requires orgID and poolID") + } + + poolUpdateParams := params.UpdatePoolParams{} + + if cmd.Flags().Changed("image") { + poolUpdateParams.Image = poolImage + } + + if cmd.Flags().Changed("flavor") { + poolUpdateParams.Flavor = poolFlavor + } + + if cmd.Flags().Changed("tags") { + poolUpdateParams.Tags = strings.Split(poolTags, ",") + } + + if cmd.Flags().Changed("os-type") { + poolUpdateParams.OSType = config.OSType(poolOSType) + } + + if cmd.Flags().Changed("os-arch") { + poolUpdateParams.OSArch = config.OSArch(poolOSArch) + } + + if cmd.Flags().Changed("max-runners") { + poolUpdateParams.MaxRunners = &poolMaxRunners + } + + if cmd.Flags().Changed("min-idle-runners") { + poolUpdateParams.MinIdleRunners = &poolMinIdleRunners + } + + if cmd.Flags().Changed("enabled") { + poolUpdateParams.Enabled = &poolEnabled + } + + pool, err := cli.UpdateOrgPool(args[0], args[1], poolUpdateParams) + if err != nil { + return err + } + + formatOnePool(pool) + return nil + }, +} + +func init() { + orgPoolAddCmd.Flags().StringVar(&poolProvider, "provider-name", "", "The name of the provider where runners will be created.") + orgPoolAddCmd.Flags().StringVar(&poolImage, "image", "", "The provider-specific image name to use for runners in this pool.") + orgPoolAddCmd.Flags().StringVar(&poolFlavor, "flavor", "", "The flavor to use for this runner.") + orgPoolAddCmd.Flags().StringVar(&poolTags, "tags", "", "A comma separated list of tags to assign to this runner.") + orgPoolAddCmd.Flags().StringVar(&poolOSType, "os-type", "linux", "Operating system type (windows, linux, etc).") + orgPoolAddCmd.Flags().StringVar(&poolOSArch, "os-arch", "amd64", "Operating system architecture (amd64, arm, etc).") + orgPoolAddCmd.Flags().UintVar(&poolMaxRunners, "max-runners", 5, "The maximum number of runner this pool will create.") + orgPoolAddCmd.Flags().UintVar(&poolMinIdleRunners, "min-idle-runners", 1, "Attempt to maintain a minimum of idle self-hosted runners of this type.") + orgPoolAddCmd.Flags().BoolVar(&poolEnabled, "enabled", false, "Enable this pool.") + orgPoolAddCmd.MarkFlagRequired("provider-name") + orgPoolAddCmd.MarkFlagRequired("image") + orgPoolAddCmd.MarkFlagRequired("flavor") + orgPoolAddCmd.MarkFlagRequired("tags") + + orgPoolUpdateCmd.Flags().StringVar(&poolImage, "image", "", "The provider-specific image name to use for runners in this pool.") + orgPoolUpdateCmd.Flags().StringVar(&poolFlavor, "flavor", "", "The flavor to use for this runner.") + orgPoolUpdateCmd.Flags().StringVar(&poolTags, "tags", "", "A comma separated list of tags to assign to this runner.") + orgPoolUpdateCmd.Flags().StringVar(&poolOSType, "os-type", "linux", "Operating system type (windows, linux, etc).") + orgPoolUpdateCmd.Flags().StringVar(&poolOSArch, "os-arch", "amd64", "Operating system architecture (amd64, arm, etc).") + orgPoolUpdateCmd.Flags().UintVar(&poolMaxRunners, "max-runners", 5, "The maximum number of runner this pool will create.") + orgPoolUpdateCmd.Flags().UintVar(&poolMinIdleRunners, "min-idle-runners", 1, "Attempt to maintain a minimum of idle self-hosted runners of this type.") + orgPoolUpdateCmd.Flags().BoolVar(&poolEnabled, "enabled", false, "Enable this pool.") + + orgPoolCmd.AddCommand( + orgPoolListCmd, + orgPoolAddCmd, + orgPoolShowCmd, + orgPoolDeleteCmd, + orgPoolUpdateCmd, + ) + + organizationCmd.AddCommand(orgPoolCmd) +} diff --git a/database/common/common.go b/database/common/common.go index f63a5ab4..27d22c87 100644 --- a/database/common/common.go +++ b/database/common/common.go @@ -10,14 +10,14 @@ type Store interface { GetRepository(ctx context.Context, owner, name string) (params.Repository, error) GetRepositoryByID(ctx context.Context, repoID string) (params.Repository, error) ListRepositories(ctx context.Context) ([]params.Repository, error) - DeleteRepository(ctx context.Context, repoID string, hardDelete bool) error + DeleteRepository(ctx context.Context, repoID string) error UpdateRepository(ctx context.Context, repoID string, param params.UpdateRepositoryParams) (params.Repository, error) CreateOrganization(ctx context.Context, name, credentialsName, webhookSecret string) (params.Organization, error) GetOrganization(ctx context.Context, name string) (params.Organization, error) GetOrganizationByID(ctx context.Context, orgID string) (params.Organization, error) ListOrganizations(ctx context.Context) ([]params.Organization, error) - DeleteOrganization(ctx context.Context, name string) error + DeleteOrganization(ctx context.Context, orgID string) error UpdateOrganization(ctx context.Context, orgID string, param params.UpdateRepositoryParams) (params.Organization, error) CreateRepositoryPool(ctx context.Context, repoId string, param params.CreatePoolParams) (params.Pool, error) diff --git a/database/sql/models.go b/database/sql/models.go index a5bca860..cd3f4126 100644 --- a/database/sql/models.go +++ b/database/sql/models.go @@ -50,10 +50,10 @@ type Pool struct { Tags []*Tag `gorm:"many2many:pool_tags;"` Enabled bool - RepoID uuid.UUID + RepoID uuid.UUID `gorm:"index"` Repository Repository `gorm:"foreignKey:RepoID"` - OrgID uuid.UUID + OrgID uuid.UUID `gorm:"index"` Organization Organization `gorm:"foreignKey:OrgID"` Instances []Instance `gorm:"foreignKey:PoolID"` diff --git a/database/sql/organizations.go b/database/sql/organizations.go index 28347746..f6c0e425 100644 --- a/database/sql/organizations.go +++ b/database/sql/organizations.go @@ -41,7 +41,7 @@ func (s *sqlDatabase) CreateOrganization(ctx context.Context, name, credentialsN func (s *sqlDatabase) GetOrganization(ctx context.Context, name string) (params.Organization, error) { org, err := s.getOrg(ctx, name) if err != nil { - return params.Organization{}, errors.Wrap(err, "fetching repo") + return params.Organization{}, errors.Wrap(err, "fetching org") } param := s.sqlToCommonOrganization(org) @@ -69,10 +69,10 @@ func (s *sqlDatabase) ListOrganizations(ctx context.Context) ([]params.Organizat return ret, nil } -func (s *sqlDatabase) DeleteOrganization(ctx context.Context, name string) error { - org, err := s.getOrg(ctx, name) +func (s *sqlDatabase) DeleteOrganization(ctx context.Context, orgID string) error { + org, err := s.getOrgByID(ctx, orgID) if err != nil { - return errors.Wrap(err, "fetching repo") + return errors.Wrap(err, "fetching org") } q := s.conn.Unscoped().Delete(&org) @@ -86,7 +86,7 @@ func (s *sqlDatabase) DeleteOrganization(ctx context.Context, name string) error func (s *sqlDatabase) UpdateOrganization(ctx context.Context, orgID string, param params.UpdateRepositoryParams) (params.Organization, error) { org, err := s.getOrgByID(ctx, orgID) if err != nil { - return params.Organization{}, errors.Wrap(err, "fetching repo") + return params.Organization{}, errors.Wrap(err, "fetching org") } if param.CredentialsName != "" { @@ -103,7 +103,7 @@ func (s *sqlDatabase) UpdateOrganization(ctx context.Context, orgID string, para q := s.conn.Save(&org) if q.Error != nil { - return params.Organization{}, errors.Wrap(err, "saving repo") + return params.Organization{}, errors.Wrap(err, "saving org") } newParams := s.sqlToCommonOrganization(org) @@ -118,7 +118,7 @@ func (s *sqlDatabase) UpdateOrganization(ctx context.Context, orgID string, para func (s *sqlDatabase) GetOrganizationByID(ctx context.Context, orgID string) (params.Organization, error) { org, err := s.getOrgByID(ctx, orgID, "Pools") if err != nil { - return params.Organization{}, errors.Wrap(err, "fetching repo") + return params.Organization{}, errors.Wrap(err, "fetching org") } param := s.sqlToCommonOrganization(org) @@ -149,28 +149,49 @@ func (s *sqlDatabase) CreateOrganizationPool(ctx context.Context, orgId string, Flavor: param.Flavor, OSType: param.OSType, OSArch: param.OSArch, + OrgID: org.ID, Enabled: param.Enabled, } - tags := make([]*Tag, len(param.Tags)) - for idx, val := range param.Tags { + _, err = s.getOrgPoolByUniqueFields(ctx, orgId, newPool.ProviderName, newPool.Image, newPool.Flavor) + if err != nil { + if !errors.Is(err, runnerErrors.ErrNotFound) { + return params.Pool{}, errors.Wrap(err, "creating pool") + } + } else { + return params.Pool{}, runnerErrors.NewConflictError("pool with the same image and flavor already exists on this provider") + } + + tags := []Tag{} + for _, val := range param.Tags { t, err := s.getOrCreateTag(val) if err != nil { return params.Pool{}, errors.Wrap(err, "fetching tag") } - tags[idx] = &t + tags = append(tags, t) } - newPool.Tags = append(newPool.Tags, tags...) - err = s.conn.Model(&org).Association("Pools").Append(&newPool) - if err != nil { + q := s.conn.Create(&newPool) + if q.Error != nil { return params.Pool{}, errors.Wrap(err, "adding pool") } - return s.sqlToCommonPool(newPool), nil + + for _, tt := range tags { + if err := s.conn.Model(&newPool).Association("Tags").Append(&tt); err != nil { + return params.Pool{}, errors.Wrap(err, "saving tag") + } + } + + pool, err := s.getPoolByID(ctx, newPool.ID.String(), "Tags") + if err != nil { + return params.Pool{}, errors.Wrap(err, "fetching pool") + } + + return s.sqlToCommonPool(pool), nil } func (s *sqlDatabase) ListOrgPools(ctx context.Context, orgID string) ([]params.Pool, error) { - pools, err := s.getOrgPools(ctx, orgID) + pools, err := s.getOrgPools(ctx, orgID, "Tags") if err != nil { return nil, errors.Wrap(err, "fetching pools") } @@ -194,7 +215,7 @@ func (s *sqlDatabase) GetOrganizationPool(ctx context.Context, orgID, poolID str func (s *sqlDatabase) DeleteOrganizationPool(ctx context.Context, orgID, poolID string) error { pool, err := s.getOrgPool(ctx, orgID, poolID) if err != nil { - return errors.Wrap(err, "looking up repo pool") + return errors.Wrap(err, "looking up org pool") } q := s.conn.Unscoped().Delete(&pool) if q.Error != nil && !errors.Is(q.Error, gorm.ErrRecordNotFound) { @@ -261,8 +282,9 @@ func (s *sqlDatabase) getPoolByID(ctx context.Context, poolID string, preload .. func (s *sqlDatabase) getOrgPool(ctx context.Context, orgID, poolID string, preload ...string) (Pool, error) { org, err := s.getOrgByID(ctx, orgID) if err != nil { - return Pool{}, errors.Wrap(err, "fetching repo") + return Pool{}, errors.Wrap(err, "fetching org") } + u, err := uuid.FromString(poolID) if err != nil { return Pool{}, errors.Wrap(runnerErrors.ErrBadRequest, "parsing id") @@ -278,7 +300,7 @@ func (s *sqlDatabase) getOrgPool(ctx context.Context, orgID, poolID string, prel var pool []Pool err = q.Model(&org).Association("Pools").Find(&pool, "id = ?", u) if err != nil { - return Pool{}, errors.Wrap(q.Error, "fetching pool") + return Pool{}, errors.Wrap(err, "fetching pool") } if len(pool) == 0 { return Pool{}, runnerErrors.ErrNotFound @@ -290,7 +312,7 @@ func (s *sqlDatabase) getOrgPool(ctx context.Context, orgID, poolID string, prel func (s *sqlDatabase) getOrgPools(ctx context.Context, orgID string, preload ...string) ([]Pool, error) { org, err := s.getOrgByID(ctx, orgID) if err != nil { - return nil, errors.Wrap(err, "fetching repo") + return nil, errors.Wrap(err, "fetching org") } var pools []Pool @@ -351,7 +373,7 @@ func (s *sqlDatabase) getOrg(ctx context.Context, name string) (Organization, er func (s *sqlDatabase) getOrgPoolByUniqueFields(ctx context.Context, orgID string, provider, image, flavor string) (Pool, error) { org, err := s.getOrgByID(ctx, orgID) if err != nil { - return Pool{}, errors.Wrap(err, "fetching repo") + return Pool{}, errors.Wrap(err, "fetching org") } q := s.conn diff --git a/database/sql/repositories.go b/database/sql/repositories.go index 0095a4de..01939d93 100644 --- a/database/sql/repositories.go +++ b/database/sql/repositories.go @@ -77,17 +77,13 @@ func (s *sqlDatabase) ListRepositories(ctx context.Context) ([]params.Repository return ret, nil } -func (s *sqlDatabase) DeleteRepository(ctx context.Context, repoID string, hardDelete bool) error { +func (s *sqlDatabase) DeleteRepository(ctx context.Context, repoID string) error { repo, err := s.getRepoByID(ctx, repoID) if err != nil { return errors.Wrap(err, "fetching repo") } - q := s.conn - if hardDelete { - q = q.Unscoped() - } - q = q.Delete(&repo) + q := s.conn.Unscoped().Delete(&repo) if q.Error != nil && !errors.Is(q.Error, gorm.ErrRecordNotFound) { return errors.Wrap(q.Error, "deleting repo") } diff --git a/runner/organizations.go b/runner/organizations.go index c3ef9da1..5edc1d18 100644 --- a/runner/organizations.go +++ b/runner/organizations.go @@ -113,7 +113,7 @@ func (r *Runner) DeleteOrganization(ctx context.Context, orgID string) error { } if err := r.store.DeleteOrganization(ctx, orgID); err != nil { - return errors.Wrap(err, "removing repository") + return errors.Wrapf(err, "removing organization %s", orgID) } return nil } diff --git a/runner/pool/common.go b/runner/pool/common.go index e8b2cccf..3b16f9bd 100644 --- a/runner/pool/common.go +++ b/runner/pool/common.go @@ -253,6 +253,8 @@ func (r *basePool) addInstanceToProvider(instance params.Instance) error { return runnerErrors.NewNotFoundError("invalid provider ID") } + log.Printf(">>> %v", pool.Tags) + labels := []string{} for _, tag := range pool.Tags { labels = append(labels, tag.Name) diff --git a/runner/pool/organization.go b/runner/pool/organization.go index c7b1a460..0c2edbff 100644 --- a/runner/pool/organization.go +++ b/runner/pool/organization.go @@ -95,7 +95,7 @@ func (r *organization) FetchTools() ([]*github.RunnerApplicationDownload, error) } func (r *organization) FetchDbInstances() ([]params.Instance, error) { - return r.store.ListRepoInstances(r.ctx, r.id) + return r.store.ListOrgInstances(r.ctx, r.id) } func (r *organization) RemoveGithubRunner(runnerID int64) error { @@ -104,7 +104,7 @@ func (r *organization) RemoveGithubRunner(runnerID int64) error { } func (r *organization) ListPools() ([]params.Pool, error) { - pools, err := r.store.ListRepoPools(r.ctx, r.id) + pools, err := r.store.ListOrgPools(r.ctx, r.id) if err != nil { return nil, errors.Wrap(err, "fetching pools") } @@ -141,7 +141,7 @@ func (r *organization) GetCallbackURL() string { } func (r *organization) FindPoolByTags(labels []string) (params.Pool, error) { - pool, err := r.store.FindRepositoryPoolByTags(r.ctx, r.id, labels) + pool, err := r.store.FindOrganizationPoolByTags(r.ctx, r.id, labels) if err != nil { return params.Pool{}, errors.Wrap(err, "fetching suitable pool") } @@ -149,7 +149,7 @@ func (r *organization) FindPoolByTags(labels []string) (params.Pool, error) { } func (r *organization) GetPoolByID(poolID string) (params.Pool, error) { - pool, err := r.store.GetRepositoryPool(r.ctx, r.id, poolID) + pool, err := r.store.GetOrganizationPool(r.ctx, r.id, poolID) if err != nil { return params.Pool{}, errors.Wrap(err, "fetching pool") } diff --git a/runner/repositories.go b/runner/repositories.go index 0007f296..d0c0a5a3 100644 --- a/runner/repositories.go +++ b/runner/repositories.go @@ -43,7 +43,7 @@ func (r *Runner) CreateRepository(ctx context.Context, param params.CreateRepoPa defer func() { if err != nil { - r.store.DeleteRepository(ctx, repo.ID, true) + r.store.DeleteRepository(ctx, repo.ID) } }() @@ -112,7 +112,7 @@ func (r *Runner) DeleteRepository(ctx context.Context, repoID string) error { return runnerErrors.NewBadRequestError("repo has pools defined (%s)", strings.Join(poolIds, ", ")) } - if err := r.store.DeleteRepository(ctx, repoID, true); err != nil { + if err := r.store.DeleteRepository(ctx, repoID); err != nil { return errors.Wrap(err, "removing repository") } return nil