garm/database/sql/templates_test.go
Gabriel Adrian Samfira 42cfd1b3c6 Add agent mode
This change adds a new "agent mode" to GARM. The agent enables GARM to
set up a persistent websocket connection between the garm server and the
runners it spawns. The goal is to be able to easier keep track of state,
even without subsequent webhooks from the forge.

The Agent will report via websockets when the runner is actually online,
when it started a job and when it finished a job.

Additionally, the agent allows us to enable optional remote shell between
the user and any runner that is spun up using agent mode. The remote shell
is multiplexed over the same persistent websocket connection the agent
sets up with the server (the agent never listens on a port).

Enablement has also been done in the web UI for this functionality.

Signed-off-by: Gabriel Adrian Samfira <gsamfira@cloudbasesolutions.com>
2026-02-08 00:27:47 +02:00

427 lines
15 KiB
Go

// 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 := params.SystemUser
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`,`templates`.`agent_mode` 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.CreateTemplate(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"}`),
IsSystem: true,
})
// 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))
}