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>
427 lines
15 KiB
Go
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))
|
|
}
|