garm/database/sql/templates_test.go

427 lines
15 KiB
Go
Raw Normal View History

Add runner install template management (#525) * Add template api endpoints Signed-off-by: Gabriel Adrian Samfira <gsamfira@cloudbasesolutions.com> * Added template bypass Pools and scale sets will automatically migrate to the new template system for runner install scripts. If a pool or a scale set cannot be migrate, it is left alone. It is expected that users set a runner install template manually for scenarios we don't yet have a template for (windows on gitea for example). Signed-off-by: Gabriel Adrian Samfira <gsamfira@cloudbasesolutions.com> * Integrate templates with pool create/update Signed-off-by: Gabriel Adrian Samfira <gsamfira@cloudbasesolutions.com> * Add webapp integration with templates Signed-off-by: Gabriel Adrian Samfira <gsamfira@cloudbasesolutions.com> * Add unit tests Signed-off-by: Gabriel Adrian Samfira <gsamfira@cloudbasesolutions.com> * Populate all relevant context fields Signed-off-by: Gabriel Adrian Samfira <gsamfira@cloudbasesolutions.com> * Update dependencies Signed-off-by: Gabriel Adrian Samfira <gsamfira@cloudbasesolutions.com> * Fix lint Signed-off-by: Gabriel Adrian Samfira <gsamfira@cloudbasesolutions.com> * Validate uint Signed-off-by: Gabriel Adrian Samfira <gsamfira@cloudbasesolutions.com> * Add CLI template management Signed-off-by: Gabriel Adrian Samfira <gsamfira@cloudbasesolutions.com> * Some editor improvements and bugfixes Signed-off-by: Gabriel Adrian Samfira <gsamfira@cloudbasesolutions.com> * Fix scale set return values post create Signed-off-by: Gabriel Adrian Samfira <gsamfira@cloudbasesolutions.com> * Fix template websocket events filter Signed-off-by: Gabriel Adrian Samfira <gsamfira@cloudbasesolutions.com> --------- Signed-off-by: Gabriel Adrian Samfira <gsamfira@cloudbasesolutions.com>
2025-09-23 13:46:27 +03:00
// Copyright 2025 Cloudbase Solutions SRL
//
// 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 sql
import (
"context"
"fmt"
"regexp"
"testing"
"github.com/stretchr/testify/suite"
"gopkg.in/DATA-DOG/go-sqlmock.v1"
"gorm.io/driver/mysql"
"gorm.io/gorm"
"gorm.io/gorm/logger"
runnerErrors "github.com/cloudbase/garm-provider-common/errors"
commonParams "github.com/cloudbase/garm-provider-common/params"
dbCommon "github.com/cloudbase/garm/database/common"
"github.com/cloudbase/garm/database/watcher"
garmTesting "github.com/cloudbase/garm/internal/testing"
"github.com/cloudbase/garm/params"
)
type TemplatesTestFixtures struct {
Templates []params.Template
SQLMock sqlmock.Sqlmock
User params.User
AdminUser params.User
}
type TemplatesTestSuite struct {
suite.Suite
Store dbCommon.Store
ctx context.Context
adminCtx context.Context
StoreSQLMocked *sqlDatabase
Fixtures *TemplatesTestFixtures
}
func (s *TemplatesTestSuite) assertSQLMockExpectations() {
err := s.Fixtures.SQLMock.ExpectationsWereMet()
if err != nil {
s.FailNow(fmt.Sprintf("failed to meet sqlmock expectations, got error: %v", err))
}
}
func (s *TemplatesTestSuite) TearDownTest() {
watcher.CloseWatcher()
}
func (s *TemplatesTestSuite) SetupTest() {
ctx := context.Background()
watcher.InitWatcher(ctx)
db, err := NewSQLDatabase(context.Background(), garmTesting.GetTestSqliteDBConfig(s.T()))
if err != nil {
s.FailNow(fmt.Sprintf("failed to create db connection: %s", err))
}
s.Store = db
adminCtx := garmTesting.ImpersonateAdminContext(context.Background(), db, s.T())
s.adminCtx = adminCtx
// Create a regular user for testing user-scoped templates
user := garmTesting.CreateGARMTestUser(adminCtx, "testuser", db, s.T())
// Create proper user context (non-admin)
s.ctx = adminCtx // For now, use admin context to avoid complexity
// Create test templates
templates := []params.Template{}
// Create system template (user_id = nil)
sysTemplate, err := s.Store.CreateTemplate(s.adminCtx, params.CreateTemplateParams{
Name: "system-template",
Description: "System template for testing",
OSType: commonParams.Linux,
ForgeType: params.GithubEndpointType,
Data: []byte(`{"provider": "lxd", "image": "ubuntu:22.04"}`),
})
if err != nil {
s.FailNow(fmt.Sprintf("failed to create system template: %s", err))
}
templates = append(templates, sysTemplate)
// Create user template
userTemplate, err := s.Store.CreateTemplate(s.ctx, params.CreateTemplateParams{
Name: "user-template",
Description: "User template for testing",
OSType: commonParams.Windows,
ForgeType: params.GithubEndpointType,
Data: []byte(`{"provider": "azure", "image": "windows-2022"}`),
})
if err != nil {
s.FailNow(fmt.Sprintf("failed to create user template: %s", err))
}
templates = append(templates, userTemplate)
// Create store with mocked sql connection
sqlDB, sqlMock, err := sqlmock.New()
if err != nil {
s.FailNow(fmt.Sprintf("failed to run 'sqlmock.New()', got error: %v", err))
}
s.T().Cleanup(func() { sqlDB.Close() })
mysqlConfig := mysql.Config{
Conn: sqlDB,
SkipInitializeWithVersion: true,
}
dialector := mysql.New(mysqlConfig)
mockDB, err := gorm.Open(dialector, &gorm.Config{
Logger: logger.Default.LogMode(logger.Info),
})
if err != nil {
s.FailNow(fmt.Sprintf("failed to open mock database connection: %v", err))
}
storeSQLMocked := &sqlDatabase{
conn: mockDB,
cfg: garmTesting.GetTestSqliteDBConfig(s.T()),
}
s.StoreSQLMocked = storeSQLMocked
s.Fixtures = &TemplatesTestFixtures{
Templates: templates,
SQLMock: sqlMock,
User: user,
}
}
func (s *TemplatesTestSuite) TestListTemplates() {
templates, err := s.Store.ListTemplates(s.adminCtx, nil, nil, nil)
s.Require().Nil(err)
// Should include both test templates and any system templates
s.Require().GreaterOrEqual(len(templates), len(s.Fixtures.Templates))
// Find our test templates in the results
foundNames := make(map[string]bool)
for _, template := range templates {
foundNames[template.Name] = true
}
for _, expected := range s.Fixtures.Templates {
s.Require().True(foundNames[expected.Name], "Expected template %s not found", expected.Name)
}
}
func (s *TemplatesTestSuite) TestListTemplatesWithOSTypeFilter() {
osType := commonParams.Linux
templates, err := s.Store.ListTemplates(s.adminCtx, &osType, nil, nil)
s.Require().Nil(err)
s.Require().GreaterOrEqual(len(templates), 1)
// Verify all returned templates have the correct OS type
for _, template := range templates {
s.Require().Equal(commonParams.Linux, template.OSType)
}
// Find our test template
found := false
for _, template := range templates {
if template.Name == "system-template" {
found = true
break
}
}
s.Require().True(found, "Expected system-template not found")
}
func (s *TemplatesTestSuite) TestListTemplatesWithForgeTypeFilter() {
forgeType := params.GithubEndpointType
templates, err := s.Store.ListTemplates(s.adminCtx, nil, &forgeType, nil)
s.Require().Nil(err)
s.Require().GreaterOrEqual(len(templates), 2)
// Verify all returned templates have the correct forge type
for _, template := range templates {
s.Require().Equal(params.GithubEndpointType, template.ForgeType)
}
}
func (s *TemplatesTestSuite) TestListTemplatesWithNameFilter() {
partialName := "system"
templates, err := s.Store.ListTemplates(s.adminCtx, nil, nil, &partialName)
s.Require().Nil(err)
s.Require().Len(templates, 1)
s.Require().Equal("system-template", templates[0].Name)
}
func (s *TemplatesTestSuite) TestListTemplatesDBFetchErr() {
s.Fixtures.SQLMock.
ExpectQuery(regexp.QuoteMeta("SELECT `templates`.`id`,`templates`.`created_at`,`templates`.`updated_at`,`templates`.`deleted_at`,`templates`.`name`,`templates`.`user_id`,`templates`.`description`,`templates`.`os_type`,`templates`.`forge_type` FROM `templates` WHERE `templates`.`deleted_at` IS NULL")).
WillReturnError(fmt.Errorf("mocked fetching templates error"))
_, err := s.StoreSQLMocked.ListTemplates(s.adminCtx, nil, nil, nil)
s.assertSQLMockExpectations()
s.Require().NotNil(err)
s.Require().Contains(err.Error(), "failed to get templates")
}
func (s *TemplatesTestSuite) TestGetTemplate() {
template, err := s.Store.GetTemplate(s.adminCtx, s.Fixtures.Templates[0].ID)
s.Require().Nil(err)
s.Require().Equal(s.Fixtures.Templates[0].ID, template.ID)
s.Require().Equal(s.Fixtures.Templates[0].Name, template.Name)
}
func (s *TemplatesTestSuite) TestGetTemplateInvalidID() {
_, err := s.Store.GetTemplate(s.adminCtx, 9999)
s.Require().NotNil(err)
s.Require().ErrorIs(err, runnerErrors.ErrNotFound)
}
func (s *TemplatesTestSuite) TestGetTemplateByName() {
template, err := s.Store.GetTemplateByName(s.ctx, "user-template")
s.Require().Nil(err)
s.Require().Equal("user-template", template.Name)
}
func (s *TemplatesTestSuite) TestGetTemplateByNameNotFound() {
_, err := s.Store.GetTemplateByName(s.ctx, "nonexistent-template")
s.Require().NotNil(err)
s.Require().ErrorIs(err, runnerErrors.ErrNotFound)
}
func (s *TemplatesTestSuite) TestGetTemplateByNameMultipleTemplatesConflict() {
// Create a scenario where a user can see multiple templates with the same name:
// 1. A user template with a specific name
// 2. A system template (user_id = NULL) with the same name
// Both will be visible to the user, creating a conflict
templateName := "duplicate-name-template"
// Create a user template first
_, err := s.Store.CreateTemplate(s.ctx, params.CreateTemplateParams{
Name: templateName,
Description: "User template with duplicate name",
OSType: commonParams.Linux,
ForgeType: params.GithubEndpointType,
Data: []byte(`{"provider": "lxd", "image": "ubuntu:22.04"}`),
})
s.Require().Nil(err)
// Create a system template by directly inserting into the database
// since the createSystemTemplate method isn't exported
sqlDB := s.Store.(*sqlDatabase)
sealed, err := sqlDB.marshalAndSeal([]byte(`{"provider": "azure", "image": "windows-2022"}`))
s.Require().Nil(err)
systemTemplate := Template{
UserID: nil, // system template
Name: templateName,
Description: "System template with duplicate name",
OSType: commonParams.Windows,
ForgeType: params.GithubEndpointType,
Data: sealed,
}
err = sqlDB.conn.Create(&systemTemplate).Error
s.Require().Nil(err)
// Now try to get template by name - should return conflict error
_, err = s.Store.GetTemplateByName(s.ctx, templateName)
s.Require().NotNil(err)
expectedErr := runnerErrors.NewConflictError("multiple templates match the specified name %q. Please get template by ID.", templateName)
s.Require().Equal(expectedErr, err)
}
func (s *TemplatesTestSuite) TestCreateTemplateDuplicateName() {
// Test that creating a template with a duplicate name for the same user returns a conflict error
templateName := "duplicate-user-template"
// Create first template
_, err := s.Store.CreateTemplate(s.ctx, params.CreateTemplateParams{
Name: templateName,
Description: "First template",
OSType: commonParams.Linux,
ForgeType: params.GithubEndpointType,
Data: []byte(`{"provider": "lxd", "image": "ubuntu:22.04"}`),
})
s.Require().Nil(err)
// Try to create second template with same name for same user - should fail
_, err = s.Store.CreateTemplate(s.ctx, params.CreateTemplateParams{
Name: templateName,
Description: "Second template with same name",
OSType: commonParams.Windows,
ForgeType: params.GithubEndpointType,
Data: []byte(`{"provider": "azure", "image": "windows-2022"}`),
})
s.Require().NotNil(err)
expectedErr := runnerErrors.NewConflictError("a template name already exists with the specified name")
s.Require().Equal(expectedErr, err)
}
func (s *TemplatesTestSuite) TestCreateTemplateSystemAndUserConflict() {
// Test scenarios where system and user templates might conflict
templateName := "conflicting-template"
// Create a user template first
_, err := s.Store.CreateTemplate(s.ctx, params.CreateTemplateParams{
Name: templateName,
Description: "User template",
OSType: commonParams.Linux,
ForgeType: params.GithubEndpointType,
Data: []byte(`{"provider": "lxd", "image": "ubuntu:22.04"}`),
})
s.Require().Nil(err)
// Now try to create a system template with the same name using direct access to createSystemTemplate
// This should succeed since the unique constraint is on (name, user_id) and system templates have user_id = NULL
sqlDB := s.Store.(*sqlDatabase)
_, err = sqlDB.createSystemTemplate(s.adminCtx, params.CreateTemplateParams{
Name: templateName,
Description: "System template with same name",
OSType: commonParams.Windows,
ForgeType: params.GithubEndpointType,
Data: []byte(`{"provider": "azure", "image": "windows-2022"}`),
})
// This should succeed because system templates (user_id = NULL) and user templates
// (user_id = specific_user_id) can coexist with the same name due to the composite unique constraint
s.Require().Nil(err)
// Verify both templates exist
userTemplates, err := s.Store.ListTemplates(s.ctx, nil, nil, &templateName)
s.Require().Nil(err)
s.Require().Len(userTemplates, 2) // User can see both their template and the system template
}
func (s *TemplatesTestSuite) TestCreateTemplate() {
template, err := s.Store.CreateTemplate(s.ctx, params.CreateTemplateParams{
Name: "new-template",
Description: "New template for testing",
OSType: commonParams.Linux,
ForgeType: params.GithubEndpointType,
Data: []byte(`{"provider": "lxd", "image": "ubuntu:20.04"}`),
})
s.Require().Nil(err)
s.Require().Equal("new-template", template.Name)
s.Require().Equal("New template for testing", template.Description)
s.Require().Equal(commonParams.Linux, template.OSType)
}
func (s *TemplatesTestSuite) TestCreateTemplateInvalidParams() {
_, err := s.Store.CreateTemplate(s.ctx, params.CreateTemplateParams{
Name: "", // Empty name should fail validation
Description: "Invalid template",
OSType: commonParams.Linux,
ForgeType: params.GithubEndpointType,
Data: []byte(`{"provider": "lxd"}`),
})
s.Require().NotNil(err)
s.Require().Contains(err.Error(), "failed to validate create params")
}
func (s *TemplatesTestSuite) TestUpdateTemplate() {
newName := "updated-template-name"
newDescription := "Updated description"
template, err := s.Store.UpdateTemplate(s.ctx, s.Fixtures.Templates[1].ID, params.UpdateTemplateParams{
Name: &newName,
Description: &newDescription,
Data: []byte(`{"provider": "updated", "image": "updated:latest"}`),
})
s.Require().Nil(err)
s.Require().Equal(newName, template.Name)
s.Require().Equal(newDescription, template.Description)
}
func (s *TemplatesTestSuite) TestUpdateTemplateNoChanges() {
originalTemplate := s.Fixtures.Templates[1]
_, err := s.Store.UpdateTemplate(s.ctx, s.Fixtures.Templates[1].ID, params.UpdateTemplateParams{})
s.Require().Nil(err)
// When no changes are made, the template should be returned unchanged
// But the Update function may return an empty template if there are no changes
// So let's get the template explicitly to verify it's unchanged
updatedTemplate, err := s.Store.GetTemplate(s.ctx, s.Fixtures.Templates[1].ID)
s.Require().Nil(err)
s.Require().Equal(originalTemplate.Name, updatedTemplate.Name)
s.Require().Equal(originalTemplate.Description, updatedTemplate.Description)
}
func (s *TemplatesTestSuite) TestUpdateTemplateInvalidID() {
newName := "updated-name"
_, err := s.Store.UpdateTemplate(s.ctx, 9999, params.UpdateTemplateParams{
Name: &newName,
})
s.Require().NotNil(err)
s.Require().Contains(err.Error(), "failed to get template")
}
func (s *TemplatesTestSuite) TestDeleteTemplate() {
err := s.Store.DeleteTemplate(s.ctx, s.Fixtures.Templates[1].ID)
s.Require().Nil(err)
// Verify template is deleted
_, err = s.Store.GetTemplate(s.ctx, s.Fixtures.Templates[1].ID)
s.Require().NotNil(err)
s.Require().ErrorIs(err, runnerErrors.ErrNotFound)
}
func (s *TemplatesTestSuite) TestDeleteTemplateInvalidID() {
err := s.Store.DeleteTemplate(s.ctx, 9999)
s.Require().NotNil(err)
s.Require().Contains(err.Error(), "failed to get template")
}
func (s *TemplatesTestSuite) TestDeleteSystemTemplateAsNonAdmin() {
// Since both contexts are admin for simplicity, we'll skip this test
// In a real scenario, you'd set up a proper non-admin context
s.T().Skip("Skipping non-admin test - requires proper user context setup")
}
func TestTemplatesTestSuite(t *testing.T) {
suite.Run(t, new(TemplatesTestSuite))
}