From c29e8d4459f7c981f03203dbd94b5f6ed5d7d14d Mon Sep 17 00:00:00 2001 From: Gabriel Adrian Samfira Date: Fri, 23 Jan 2026 07:05:50 +0000 Subject: [PATCH] Add some tests, move some code around Signed-off-by: Gabriel Adrian Samfira --- cmd/garm/main.go | 22 +- database/sql/models.go | 4 +- params/interfaces.go | 12 +- runner/garm_tools.go | 2 +- runner/garm_tools_test.go | 16 +- runner/metadata.go | 35 +- runner/metadata_test.go | 763 +++++++++++++++++++++++++++++++++++- workers/cache/cache.go | 20 +- workers/cache/garm_agent.go | 65 ++- workers/cache/tool_cache.go | 8 - 10 files changed, 868 insertions(+), 79 deletions(-) diff --git a/cmd/garm/main.go b/cmd/garm/main.go index 91826309..81131b7e 100644 --- a/cmd/garm/main.go +++ b/cmd/garm/main.go @@ -245,7 +245,17 @@ func main() { log.Fatal(err) } - cacheWorker := cache.NewWorker(ctx, db) + instanceTokenGetter, err := auth.NewInstanceTokenGetter(cfg.JWTAuth.Secret) + if err != nil { + log.Fatalf("failed to create instance token getter: %+v", err) + } + + runner, err := runner.NewRunner(ctx, *cfg, db, instanceTokenGetter) + if err != nil { + log.Fatalf("failed to create controller: %+v", err) + } + + cacheWorker := cache.NewWorker(ctx, db, runner) if err := cacheWorker.Start(); err != nil { log.Fatalf("failed to start cache worker: %+v", err) } @@ -263,11 +273,6 @@ func main() { log.Fatalf("failed to start entity controller: %+v", err) } - instanceTokenGetter, err := auth.NewInstanceTokenGetter(cfg.JWTAuth.Secret) - if err != nil { - log.Fatalf("failed to create instance token getter: %+v", err) - } - providerWorker, err := provider.NewWorker(ctx, db, providers, instanceTokenGetter) if err != nil { log.Fatalf("failed to create provider worker: %+v", err) @@ -276,11 +281,6 @@ func main() { log.Fatalf("failed to start provider worker: %+v", err) } - runner, err := runner.NewRunner(ctx, *cfg, db, instanceTokenGetter) - if err != nil { - log.Fatalf("failed to create controller: %+v", err) - } - // If there are many repos/pools, this may take a long time. if err := runner.Start(); err != nil { log.Fatal(err) diff --git a/database/sql/models.go b/database/sql/models.go index e4cc02f1..c0f70915 100644 --- a/database/sql/models.go +++ b/database/sql/models.go @@ -58,7 +58,7 @@ type ControllerInfo struct { MetadataURL string // WebhookBaseURL is the base URL used to construct the controller webhook URL. WebhookBaseURL string - // AgentURL is the websocket enabled URL whenre garm agents connect to. + // AgentURL is the websocket enabled URL where garm agents connect to. AgentURL string // GARMAgentReleasesURL is the URL from which GARM can sync garm-agent binaries. Alternatively // the user can manually upload binaries. @@ -488,7 +488,7 @@ type FileObject struct { // Size is the file size in bytes Size int64 `gorm:"type:integer"` // SHA256 is the sha256 checksum (hex encoded) - SHA256 string `gorm:"type:text"` + SHA256 string `gorm:"type:text;index:idx_fo_chksum"` // Tags is a JSON array of tags TagsList []FileObjectTag `gorm:"foreignKey:FileObjectID;constraint:OnDelete:CASCADE"` // Content is a foreign key to a different table where the blob is actually stored. diff --git a/params/interfaces.go b/params/interfaces.go index 31ef635f..7fb836d3 100644 --- a/params/interfaces.go +++ b/params/interfaces.go @@ -14,7 +14,11 @@ package params -import "time" +import ( + "context" + "io" + "time" +) // EntityGetter is implemented by all github entities (repositories, organizations and enterprises). // It defines the GetEntity() function which returns a github entity. @@ -33,3 +37,9 @@ type CreationDateGetter interface { type ForgeCredentialsGetter interface { GetForgeCredentials() ForgeCredentials } + +type GARMToolsManager interface { + ListAllGARMTools(ctx context.Context) ([]GARMAgentTool, error) + CreateGARMTool(ctx context.Context, param CreateGARMToolParams, reader io.Reader) (FileObject, error) + DeleteGarmTool(ctx context.Context, osType, osArch string) error +} diff --git a/runner/garm_tools.go b/runner/garm_tools.go index e24ad34a..2e988f19 100644 --- a/runner/garm_tools.go +++ b/runner/garm_tools.go @@ -33,7 +33,7 @@ var ( garmAgentOSArchARM64Tag = "os_arch=arm64" ) -func (r *Runner) ListGARMTools(ctx context.Context) ([]params.GARMAgentTool, error) { +func (r *Runner) ListAllGARMTools(ctx context.Context) ([]params.GARMAgentTool, error) { if !auth.IsAdmin(ctx) { return nil, runnerErrors.ErrUnauthorized } diff --git a/runner/garm_tools_test.go b/runner/garm_tools_test.go index f8188f27..5ffa29db 100644 --- a/runner/garm_tools_test.go +++ b/runner/garm_tools_test.go @@ -375,19 +375,19 @@ func (s *GARMToolsTestSuite) TestDeleteGarmToolDeletesAllVersions() { s.Equal(uint64(0), results.TotalCount) } -func (s *GARMToolsTestSuite) TestListGARMToolsUnauthorized() { - _, err := s.Runner.ListGARMTools(s.UnauthorizedContext) +func (s *GARMToolsTestSuite) TestListAllGARMToolsUnauthorized() { + _, err := s.Runner.ListAllGARMTools(s.UnauthorizedContext) s.Require().Error(err) s.Equal(runnerErrors.ErrUnauthorized, err) } -func (s *GARMToolsTestSuite) TestListGARMToolsEmpty() { - tools, err := s.Runner.ListGARMTools(s.AdminContext) +func (s *GARMToolsTestSuite) TestListAllGARMToolsEmpty() { + tools, err := s.Runner.ListAllGARMTools(s.AdminContext) s.Require().NoError(err) s.Empty(tools) } -func (s *GARMToolsTestSuite) TestListGARMToolsSinglePlatform() { +func (s *GARMToolsTestSuite) TestListAllGARMToolsSinglePlatform() { // Create one tool param := params.CreateGARMToolParams{ Name: "garm-agent-linux-amd64", @@ -401,14 +401,14 @@ func (s *GARMToolsTestSuite) TestListGARMToolsSinglePlatform() { _, err := s.Runner.CreateGARMTool(s.AdminContext, param, reader) s.Require().NoError(err) - tools, err := s.Runner.ListGARMTools(s.AdminContext) + tools, err := s.Runner.ListAllGARMTools(s.AdminContext) s.Require().NoError(err) s.Len(tools, 1) s.Equal("linux", string(tools[0].OSType)) s.Equal("amd64", string(tools[0].OSArch)) } -func (s *GARMToolsTestSuite) TestListGARMToolsMultiplePlatforms() { +func (s *GARMToolsTestSuite) TestListAllGARMToolsMultiplePlatforms() { // Create tools for all supported platforms platforms := []struct { osType commonParams.OSType @@ -434,7 +434,7 @@ func (s *GARMToolsTestSuite) TestListGARMToolsMultiplePlatforms() { s.Require().NoError(err) } - tools, err := s.Runner.ListGARMTools(s.AdminContext) + tools, err := s.Runner.ListAllGARMTools(s.AdminContext) s.Require().NoError(err) s.Len(tools, 4) diff --git a/runner/metadata.go b/runner/metadata.go index b0ce97b1..12274b50 100644 --- a/runner/metadata.go +++ b/runner/metadata.go @@ -218,6 +218,12 @@ func (r *Runner) GetInstanceMetadata(ctx context.Context) (params.InstanceMetada return params.InstanceMetadata{}, fmt.Errorf("failed to get entity: %w", err) } + switch dbEntity.Credentials.ForgeType { + case params.GiteaEndpointType, params.GithubEndpointType: + default: + return params.InstanceMetadata{}, runnerErrors.NewUnprocessableError("invalid forge type: %s", dbEntity.Credentials.ForgeType) + } + ret := params.InstanceMetadata{ RunnerName: instance.Name, RunnerLabels: getLabelsForInstance(instance), @@ -236,17 +242,22 @@ func (r *Runner) GetInstanceMetadata(ctx context.Context) (params.InstanceMetada if dbEntity.AgentMode { agentTools, err := r.GetGARMTools(ctx, 0, 25) if err != nil { - return params.InstanceMetadata{}, fmt.Errorf("failed to find garm agent tools: %w", err) + if !errors.Is(err, runnerErrors.ErrNotFound) { + return params.InstanceMetadata{}, fmt.Errorf("failed to find garm agent tools: %w", err) + } + slog.ErrorContext(ctx, "failed to find agent tools", "error", err) } - if agentTools.TotalCount == 0 { - return params.InstanceMetadata{}, runnerErrors.NewConflictError("agent mode is enabled, but agent tools not available") + if agentTools.TotalCount > 0 { + ret.AgentTools = &agentTools.Results[0] + agentToken, err := r.GetAgentJWTToken(r.ctx, instance.Name) + if err != nil { + return params.InstanceMetadata{}, fmt.Errorf("failed to get agent token: %w", err) + } + ret.AgentToken = agentToken + } else { + slog.WarnContext(ctx, "agent mode enabled but no tools found", "runner_name", instance.Name, "pool_id", instance.PoolID) + ret.AgentMode = false } - ret.AgentTools = &agentTools.Results[0] - agentToken, err := r.GetAgentJWTToken(r.ctx, instance.Name) - if err != nil { - return params.InstanceMetadata{}, fmt.Errorf("failed to get agent token: %w", err) - } - ret.AgentToken = agentToken } if len(dbEntity.Credentials.Endpoint.CACertBundle) > 0 { @@ -273,12 +284,6 @@ func (r *Runner) GetInstanceMetadata(ctx context.Context) (params.InstanceMetada } ret.RunnerTools = filtered - switch dbEntity.Credentials.ForgeType { - case params.GiteaEndpointType: - case params.GithubEndpointType: - default: - return params.InstanceMetadata{}, fmt.Errorf("invalid forge type: %s", dbEntity.Credentials.ForgeType) - } return ret, nil } diff --git a/runner/metadata_test.go b/runner/metadata_test.go index 4d54decc..0c33c691 100644 --- a/runner/metadata_test.go +++ b/runner/metadata_test.go @@ -15,8 +15,10 @@ package runner import ( + "bytes" "context" "encoding/base64" + "encoding/json" "fmt" "os" "path/filepath" @@ -28,6 +30,7 @@ import ( runnerErrors "github.com/cloudbase/garm-provider-common/errors" commonParams "github.com/cloudbase/garm-provider-common/params" "github.com/cloudbase/garm/auth" + "github.com/cloudbase/garm/cache" "github.com/cloudbase/garm/database" dbCommon "github.com/cloudbase/garm/database/common" garmTesting "github.com/cloudbase/garm/internal/testing" @@ -37,6 +40,17 @@ import ( runnerMocks "github.com/cloudbase/garm/runner/mocks" ) +// mockTokenGetter is a simple mock implementation of auth.InstanceTokenGetter +type mockTokenGetter struct{} + +func (m *mockTokenGetter) NewInstanceJWTToken(instance params.Instance, entity params.ForgeEntity, ttlMinutes uint) (string, error) { + return "mock-instance-jwt-token", nil +} + +func (m *mockTokenGetter) NewAgentJWTToken(instance params.Instance, entity params.ForgeEntity) (string, error) { + return "mock-agent-jwt-token", nil +} + type MetadataTestFixtures struct { AdminContext context.Context Store dbCommon.Store @@ -149,12 +163,13 @@ func (s *MetadataTestSuite) SetupTest() { s.Fixtures = fixtures - // setup test runner + // setup test runner with mock token getter runner := &Runner{ providers: fixtures.Providers, ctx: fixtures.AdminContext, store: fixtures.Store, poolManagerCtrl: fixtures.PoolMgrCtrlMock, + tokenGetter: &mockTokenGetter{}, } s.Runner = runner @@ -285,9 +300,24 @@ func (s *MetadataTestSuite) TestGetLabelsForInstance() { } func (s *MetadataTestSuite) TestGetRunnerInstallScript() { - // This test requires complex cache setup for github tools - // Skipping for now as it would require significant test infrastructure - s.T().Skip("Skipping install script test - requires github tools cache setup") + // Set up github tools cache for the entity + tools := []commonParams.RunnerApplicationDownload{ + { + OS: garmTesting.Ptr("linux"), + Architecture: garmTesting.Ptr("x64"), + DownloadURL: garmTesting.Ptr("https://example.com/actions-runner-linux-x64-2.0.0.tar.gz"), + Filename: garmTesting.Ptr("actions-runner-linux-x64-2.0.0.tar.gz"), + SHA256Checksum: garmTesting.Ptr("abc123"), + }, + } + cache.SetGithubToolsCache(s.Fixtures.TestEntity, tools) + + script, err := s.Runner.GetRunnerInstallScript(s.instanceCtx) + + s.Require().Nil(err) + s.Require().NotEmpty(script) + // Should contain the template content + s.Require().Contains(string(script), "Installing runner") } func (s *MetadataTestSuite) TestGetRunnerInstallScriptUnauthorized() { @@ -298,12 +328,107 @@ func (s *MetadataTestSuite) TestGetRunnerInstallScriptUnauthorized() { } func (s *MetadataTestSuite) TestGetRunnerInstallScriptInvalidState() { + // Set up cache even for invalid state to ensure it's the state check that fails + tools := []commonParams.RunnerApplicationDownload{ + { + OS: garmTesting.Ptr("linux"), + Architecture: garmTesting.Ptr("x64"), + DownloadURL: garmTesting.Ptr("https://example.com/actions-runner.tar.gz"), + Filename: garmTesting.Ptr("actions-runner.tar.gz"), + SHA256Checksum: garmTesting.Ptr("abc123"), + }, + } + cache.SetGithubToolsCache(s.Fixtures.TestEntity, tools) + _, err := s.Runner.GetRunnerInstallScript(s.invalidInstanceCtx) s.Require().NotNil(err) s.Require().ErrorIs(err, runnerErrors.ErrUnauthorized) } +func (s *MetadataTestSuite) TestGetRunnerInstallScriptNoToolsInCache() { + // Don't set up cache - should fail with tools not found error + _, err := s.Runner.GetRunnerInstallScript(s.instanceCtx) + + s.Require().NotNil(err) + s.Require().Contains(err.Error(), "failed to get tools") +} + +func (s *MetadataTestSuite) TestGetRunnerInstallScriptWithExtraSpecs() { + // Set up github tools cache + tools := []commonParams.RunnerApplicationDownload{ + { + OS: garmTesting.Ptr("linux"), + Architecture: garmTesting.Ptr("x64"), + DownloadURL: garmTesting.Ptr("https://example.com/actions-runner.tar.gz"), + Filename: garmTesting.Ptr("actions-runner.tar.gz"), + SHA256Checksum: garmTesting.Ptr("abc123"), + }, + } + cache.SetGithubToolsCache(s.Fixtures.TestEntity, tools) + + // Update pool with extra specs containing custom context + extraSpecs := json.RawMessage(`{"extra_context": {"custom_var": "custom_value"}}`) + pool, err := s.Fixtures.Store.UpdateEntityPool(s.adminCtx, s.Fixtures.TestEntity, s.Fixtures.TestPool.ID, params.UpdatePoolParams{ + ExtraSpecs: extraSpecs, + }) + s.Require().NoError(err) + s.Require().NotNil(pool) + + script, err := s.Runner.GetRunnerInstallScript(s.instanceCtx) + + s.Require().Nil(err) + s.Require().NotEmpty(script) +} + +func (s *MetadataTestSuite) TestGetRunnerInstallScriptNoTemplate() { + // Set up github tools cache + tools := []commonParams.RunnerApplicationDownload{ + { + OS: garmTesting.Ptr("linux"), + Architecture: garmTesting.Ptr("x64"), + DownloadURL: garmTesting.Ptr("https://example.com/actions-runner.tar.gz"), + Filename: garmTesting.Ptr("actions-runner.tar.gz"), + SHA256Checksum: garmTesting.Ptr("abc123"), + }, + } + cache.SetGithubToolsCache(s.Fixtures.TestEntity, tools) + + // Create a new pool without a template + poolNoTemplate, err := s.Fixtures.Store.CreateEntityPool(s.adminCtx, s.Fixtures.TestEntity, params.CreatePoolParams{ + ProviderName: "test-provider", + MaxRunners: 2, + MinIdleRunners: 1, + Image: "ubuntu:22.04", + Flavor: "medium", + OSType: commonParams.Linux, + OSArch: commonParams.Amd64, + Tags: []string{"linux", "amd64"}, + RunnerBootstrapTimeout: 10, + // No TemplateID specified + }) + s.Require().NoError(err) + + // Create instance with this pool + instance, err := s.Fixtures.Store.CreateInstance(s.adminCtx, poolNoTemplate.ID, params.CreateInstanceParams{ + Name: "test-instance-no-template", + OSType: commonParams.Linux, + OSArch: commonParams.Amd64, + }) + s.Require().NoError(err) + + // Create context for this instance + ctx := auth.SetInstanceParams(context.Background(), instance) + ctx = auth.SetInstanceRunnerStatus(ctx, params.RunnerPending) + ctx = auth.SetInstanceEntity(ctx, s.Fixtures.TestEntity) + ctx = auth.SetInstanceAuthToken(ctx, "test-auth-token") + + _, err = s.Runner.GetRunnerInstallScript(ctx) + + s.Require().NotNil(err) + s.Require().Contains(err.Error(), "no template associated") +} + func (s *MetadataTestSuite) TestGenerateSystemdUnitFile() { tests := []struct { name string @@ -492,6 +617,636 @@ func (s *MetadataTestSuite) TestGetRootCertificateBundleInvalidBundle() { s.Require().Empty(bundle.RootCertificates) } +func (s *MetadataTestSuite) TestGetInstanceMetadata() { + // Set up github tools cache for the entity + tools := []commonParams.RunnerApplicationDownload{ + { + OS: garmTesting.Ptr("linux"), + Architecture: garmTesting.Ptr("x64"), + DownloadURL: garmTesting.Ptr("https://example.com/actions-runner-linux-x64-2.0.0.tar.gz"), + Filename: garmTesting.Ptr("actions-runner-linux-x64-2.0.0.tar.gz"), + SHA256Checksum: garmTesting.Ptr("abc123"), + }, + } + cache.SetGithubToolsCache(s.Fixtures.TestEntity, tools) + + metadata, err := s.Runner.GetInstanceMetadata(s.instanceCtx) + + s.Require().Nil(err) + s.Require().NotEmpty(metadata.RunnerName) + s.Require().Equal(s.Fixtures.TestInstance.Name, metadata.RunnerName) + s.Require().Equal(params.GithubEndpointType, metadata.ForgeType) + s.Require().False(metadata.JITEnabled) + s.Require().False(metadata.AgentMode) + s.Require().NotNil(metadata.RunnerTools) + // Metadata access details are populated from instance + s.Require().NotNil(metadata.MetadataAccess) +} + +func (s *MetadataTestSuite) TestGetInstanceMetadataUnauthorized() { + _, err := s.Runner.GetInstanceMetadata(s.unauthorizedCtx) + s.Require().NotNil(err) + s.Require().ErrorIs(err, runnerErrors.ErrUnauthorized) +} + +func (s *MetadataTestSuite) TestGetInstanceMetadataInvalidState() { + _, err := s.Runner.GetInstanceMetadata(s.invalidInstanceCtx) + s.Require().NotNil(err) + s.Require().ErrorIs(err, runnerErrors.ErrUnauthorized) +} + +func (s *MetadataTestSuite) TestGetInstanceMetadataNoPoolOrScaleSet() { + // Create instance without pool or scale set + instanceNoPool := s.Fixtures.TestInstance + instanceNoPool.PoolID = "" + instanceNoPool.ScaleSetID = 0 + + ctx := auth.SetInstanceParams(context.Background(), instanceNoPool) + ctx = auth.SetInstanceRunnerStatus(ctx, params.RunnerPending) + + _, err := s.Runner.GetInstanceMetadata(ctx) + s.Require().NotNil(err) + s.Require().ErrorIs(err, runnerErrors.ErrUnauthorized) +} + +func (s *MetadataTestSuite) TestGetInstanceMetadataWithJIT() { + // Set up github tools cache + tools := []commonParams.RunnerApplicationDownload{ + { + OS: garmTesting.Ptr("linux"), + Architecture: garmTesting.Ptr("x64"), + DownloadURL: garmTesting.Ptr("https://example.com/runner.tar.gz"), + Filename: garmTesting.Ptr("runner.tar.gz"), + SHA256Checksum: garmTesting.Ptr("abc123"), + }, + } + cache.SetGithubToolsCache(s.Fixtures.TestEntity, tools) + + metadata, err := s.Runner.GetInstanceMetadata(s.jitInstanceCtx) + + s.Require().Nil(err) + s.Require().True(metadata.JITEnabled) + s.Require().NotNil(metadata.RunnerTools) +} + +func (s *MetadataTestSuite) TestGetInstanceMetadataWithExtraSpecs() { + // Set up github tools cache + tools := []commonParams.RunnerApplicationDownload{ + { + OS: garmTesting.Ptr("linux"), + Architecture: garmTesting.Ptr("x64"), + DownloadURL: garmTesting.Ptr("https://example.com/runner.tar.gz"), + Filename: garmTesting.Ptr("runner.tar.gz"), + SHA256Checksum: garmTesting.Ptr("abc123"), + }, + } + cache.SetGithubToolsCache(s.Fixtures.TestEntity, tools) + + // Update pool with extra specs + extraSpecs := json.RawMessage(`{"custom_key": "custom_value", "another_key": 123}`) + _, err := s.Fixtures.Store.UpdateEntityPool(s.adminCtx, s.Fixtures.TestEntity, s.Fixtures.TestPool.ID, params.UpdatePoolParams{ + ExtraSpecs: extraSpecs, + }) + s.Require().NoError(err) + + metadata, err := s.Runner.GetInstanceMetadata(s.instanceCtx) + + s.Require().Nil(err) + s.Require().NotNil(metadata.ExtraSpecs) + s.Require().Contains(metadata.ExtraSpecs, "custom_key") + s.Require().Equal("custom_value", metadata.ExtraSpecs["custom_key"]) +} + +func (s *MetadataTestSuite) TestGetInstanceMetadataBasicFields() { + // Test that all basic metadata fields are populated correctly + tools := []commonParams.RunnerApplicationDownload{ + { + OS: garmTesting.Ptr("linux"), + Architecture: garmTesting.Ptr("x64"), + DownloadURL: garmTesting.Ptr("https://example.com/runner.tar.gz"), + Filename: garmTesting.Ptr("runner.tar.gz"), + SHA256Checksum: garmTesting.Ptr("abc123"), + }, + } + cache.SetGithubToolsCache(s.Fixtures.TestEntity, tools) + + metadata, err := s.Runner.GetInstanceMetadata(s.instanceCtx) + + s.Require().Nil(err) + s.Require().Equal(s.Fixtures.TestInstance.Name, metadata.RunnerName) + s.Require().NotEmpty(metadata.RunnerRegistrationURL) + s.Require().False(metadata.AgentShellEnabled) +} + +func (s *MetadataTestSuite) TestGetInstanceMetadataWithAgentModeNoTools() { + // Set up runner tools cache + tools := []commonParams.RunnerApplicationDownload{ + { + OS: garmTesting.Ptr("linux"), + Architecture: garmTesting.Ptr("x64"), + DownloadURL: garmTesting.Ptr("https://example.com/runner.tar.gz"), + Filename: garmTesting.Ptr("runner.tar.gz"), + SHA256Checksum: garmTesting.Ptr("abc123"), + }, + } + cache.SetGithubToolsCache(s.Fixtures.TestEntity, tools) + + // Enable agent mode on the organization + agentMode := true + _, err := s.Fixtures.Store.UpdateOrganization(s.adminCtx, s.Fixtures.TestEntity.ID, params.UpdateEntityParams{ + AgentMode: &agentMode, + }) + s.Require().NoError(err) + + metadata, err := s.Runner.GetInstanceMetadata(s.instanceCtx) + + s.Require().Nil(err) + // AgentMode should be disabled because no agent tools are available + s.Require().False(metadata.AgentMode, "AgentMode should be false when no agent tools available") + s.Require().Nil(metadata.AgentTools) + s.Require().Empty(metadata.AgentToken) +} + +func (s *MetadataTestSuite) TestGetInstanceMetadataAgentModeDisabledByDefault() { + // Test that agent mode is disabled by default + tools := []commonParams.RunnerApplicationDownload{ + { + OS: garmTesting.Ptr("linux"), + Architecture: garmTesting.Ptr("x64"), + DownloadURL: garmTesting.Ptr("https://example.com/runner.tar.gz"), + Filename: garmTesting.Ptr("runner.tar.gz"), + SHA256Checksum: garmTesting.Ptr("abc123"), + }, + } + cache.SetGithubToolsCache(s.Fixtures.TestEntity, tools) + + metadata, err := s.Runner.GetInstanceMetadata(s.instanceCtx) + + s.Require().Nil(err) + s.Require().False(metadata.AgentMode) + s.Require().Nil(metadata.AgentTools) + s.Require().Empty(metadata.AgentToken) +} + +func (s *MetadataTestSuite) TestGetInstanceMetadataWithAgentModeToolsCountZero() { + // Set up runner tools cache + tools := []commonParams.RunnerApplicationDownload{ + { + OS: garmTesting.Ptr("linux"), + Architecture: garmTesting.Ptr("x64"), + DownloadURL: garmTesting.Ptr("https://example.com/runner.tar.gz"), + Filename: garmTesting.Ptr("runner.tar.gz"), + SHA256Checksum: garmTesting.Ptr("abc123"), + }, + } + cache.SetGithubToolsCache(s.Fixtures.TestEntity, tools) + + // Enable agent mode on the organization + agentMode := true + _, err := s.Fixtures.Store.UpdateOrganization(s.adminCtx, s.Fixtures.TestEntity.ID, params.UpdateEntityParams{ + AgentMode: &agentMode, + }) + s.Require().NoError(err) + + // GetGARMTools will search for files with category=garm-agent tag + // Since no such files exist, it returns TotalCount=0 + metadata, err := s.Runner.GetInstanceMetadata(s.instanceCtx) + + s.Require().Nil(err) + // AgentMode should be disabled because TotalCount is 0 + s.Require().False(metadata.AgentMode, "AgentMode should be false when GetGARMTools returns TotalCount=0") + s.Require().Nil(metadata.AgentTools) + s.Require().Empty(metadata.AgentToken) +} + +func (s *MetadataTestSuite) TestGetInstanceMetadataWithAgentModeGetToolsReturnsNotFoundError() { + // Set up runner tools cache + tools := []commonParams.RunnerApplicationDownload{ + { + OS: garmTesting.Ptr("linux"), + Architecture: garmTesting.Ptr("x64"), + DownloadURL: garmTesting.Ptr("https://example.com/runner.tar.gz"), + Filename: garmTesting.Ptr("runner.tar.gz"), + SHA256Checksum: garmTesting.Ptr("abc123"), + }, + } + cache.SetGithubToolsCache(s.Fixtures.TestEntity, tools) + + // Enable agent mode on the organization + agentMode := true + _, err := s.Fixtures.Store.UpdateOrganization(s.adminCtx, s.Fixtures.TestEntity.ID, params.UpdateEntityParams{ + AgentMode: &agentMode, + }) + s.Require().NoError(err) + + // When GetGARMTools returns ErrNotFound (which happens when TotalCount=0 and no files found), + // it should log the error but continue and disable AgentMode + metadata, err := s.Runner.GetInstanceMetadata(s.instanceCtx) + + s.Require().Nil(err) + // Should continue execution and disable AgentMode + s.Require().False(metadata.AgentMode) + s.Require().Nil(metadata.AgentTools) + s.Require().Empty(metadata.AgentToken) +} + +func (s *MetadataTestSuite) TestGetInstanceMetadataWithAgentModeAndToolsAvailable() { + // Set up runner tools cache for GitHub runner + runnerTools := []commonParams.RunnerApplicationDownload{ + { + OS: garmTesting.Ptr("linux"), + Architecture: garmTesting.Ptr("x64"), + DownloadURL: garmTesting.Ptr("https://example.com/runner.tar.gz"), + Filename: garmTesting.Ptr("runner.tar.gz"), + SHA256Checksum: garmTesting.Ptr("abc123"), + }, + } + cache.SetGithubToolsCache(s.Fixtures.TestEntity, runnerTools) + + // Enable agent mode on the organization + agentMode := true + _, err := s.Fixtures.Store.UpdateOrganization(s.adminCtx, s.Fixtures.TestEntity.ID, params.UpdateEntityParams{ + AgentMode: &agentMode, + }) + s.Require().NoError(err) + + // Create GARM agent tools using the CreateGARMTool API + agentBinary := []byte("fake garm agent binary content") + agentToolParam := params.CreateGARMToolParams{ + Name: "garm-agent-linux-amd64", + Description: "GARM agent for Linux AMD64", + Size: int64(len(agentBinary)), + OSType: commonParams.Linux, + OSArch: commonParams.Amd64, + Version: "v1.0.0", + } + + agentTool, err := s.Runner.CreateGARMTool(s.adminCtx, agentToolParam, bytes.NewReader(agentBinary)) + s.Require().NoError(err) + s.Require().NotNil(agentTool) + + // Now GetInstanceMetadata should succeed with AgentMode enabled + metadata, err := s.Runner.GetInstanceMetadata(s.instanceCtx) + + s.Require().Nil(err) + // AgentMode should remain enabled because tools are available + s.Require().True(metadata.AgentMode, "AgentMode should be true when agent tools are available") + // Agent tools should be populated + s.Require().NotNil(metadata.AgentTools, "AgentTools should be populated") + s.Require().Equal(agentTool.ID, metadata.AgentTools.ID) + s.Require().Equal("garm-agent-linux-amd64", metadata.AgentTools.Name) + s.Require().Equal(commonParams.OSType("linux"), metadata.AgentTools.OSType) + s.Require().Equal(commonParams.OSArch("amd64"), metadata.AgentTools.OSArch) + s.Require().Equal("v1.0.0", metadata.AgentTools.Version) + s.Require().NotEmpty(metadata.AgentTools.DownloadURL) + // Agent token should be generated + s.Require().NotEmpty(metadata.AgentToken, "AgentToken should be generated when tools available") +} + +func (s *MetadataTestSuite) TestFileObjectToGARMTool() { + tests := []struct { + name string + fileObject params.FileObject + downloadURL string + wantErr bool + errMsg string + }{ + { + name: "Valid file object with all tags", + fileObject: params.FileObject{ + ID: 1, + Name: "garm-agent-linux-amd64", + Size: 1024, + SHA256: "abc123", + Description: "GARM agent for Linux AMD64", + FileType: "binary", + Tags: []string{"version=1.0.0", "os_type=linux", "os_arch=amd64"}, + }, + downloadURL: "http://example.com/download", + wantErr: false, + }, + { + name: "Missing version tag", + fileObject: params.FileObject{ + ID: 2, + Name: "garm-agent", + Tags: []string{"os_type=linux", "os_arch=amd64"}, + }, + downloadURL: "http://example.com/download", + wantErr: true, + errMsg: "missing version", + }, + { + name: "Missing os_type tag", + fileObject: params.FileObject{ + ID: 3, + Name: "garm-agent", + Tags: []string{"version=1.0.0", "os_arch=amd64"}, + }, + downloadURL: "http://example.com/download", + wantErr: true, + errMsg: "missing os_type", + }, + { + name: "Missing os_arch tag", + fileObject: params.FileObject{ + ID: 4, + Name: "garm-agent", + Tags: []string{"version=1.0.0", "os_type=linux"}, + }, + downloadURL: "http://example.com/download", + wantErr: true, + errMsg: "missing os_arch", + }, + } + + for _, tt := range tests { + s.Run(tt.name, func() { + result, err := fileObjectToGARMTool(tt.fileObject, tt.downloadURL) + + if tt.wantErr { + s.Require().NotNil(err) + s.Require().Contains(err.Error(), tt.errMsg) + } else { + s.Require().Nil(err) + s.Require().Equal(tt.fileObject.ID, result.ID) + s.Require().Equal(tt.fileObject.Name, result.Name) + s.Require().Equal(tt.fileObject.Size, result.Size) + s.Require().Equal(tt.fileObject.SHA256, result.SHA256SUM) + s.Require().Equal(tt.downloadURL, result.DownloadURL) + } + }) + } +} + +func (s *MetadataTestSuite) TestGetGARMTools() { + // GetGARMTools requires file objects in database + // This is tested in file_store_test.go and garm_tools_test.go + // Here we just test the authorization paths + _, err := s.Runner.GetGARMTools(s.instanceCtx, 0, 25) + + // Should not error on authorization (might have no results) + s.Require().NoError(err) +} + +func (s *MetadataTestSuite) TestGetGARMToolsUnauthorized() { + _, err := s.Runner.GetGARMTools(s.unauthorizedCtx, 0, 25) + s.Require().NotNil(err) + s.Require().ErrorIs(err, runnerErrors.ErrUnauthorized) +} + +func (s *MetadataTestSuite) TestGetGARMToolsInvalidState() { + _, err := s.Runner.GetGARMTools(s.invalidInstanceCtx, 0, 25) + s.Require().NotNil(err) + s.Require().ErrorIs(err, runnerErrors.ErrUnauthorized) +} + + +func (s *MetadataTestSuite) TestShowGARMToolsUnauthorized() { + _, err := s.Runner.ShowGARMTools(s.unauthorizedCtx, 1) + s.Require().NotNil(err) + s.Require().ErrorIs(err, runnerErrors.ErrUnauthorized) +} + +func (s *MetadataTestSuite) TestShowGARMToolsInvalidState() { + _, err := s.Runner.ShowGARMTools(s.invalidInstanceCtx, 1) + s.Require().NotNil(err) + s.Require().ErrorIs(err, runnerErrors.ErrUnauthorized) +} + +func (s *MetadataTestSuite) TestGetGARMToolsReadHandlerUnauthorized() { + _, err := s.Runner.GetGARMToolsReadHandler(s.unauthorizedCtx, 1) + s.Require().NotNil(err) + s.Require().ErrorIs(err, runnerErrors.ErrUnauthorized) +} + +func (s *MetadataTestSuite) TestValidateInstanceState() { + tests := []struct { + name string + ctx context.Context + wantErr bool + }{ + { + name: "Valid pending instance", + ctx: s.instanceCtx, + wantErr: false, + }, + { + name: "Valid installing instance", + ctx: func() context.Context { + ctx := auth.SetInstanceParams(context.Background(), s.Fixtures.TestInstance) + ctx = auth.SetInstanceRunnerStatus(ctx, params.RunnerInstalling) + return ctx + }(), + wantErr: false, + }, + { + name: "Invalid state - active", + ctx: s.invalidInstanceCtx, + wantErr: true, + }, + { + name: "Unauthorized - no instance", + ctx: s.unauthorizedCtx, + wantErr: true, + }, + } + + for _, tt := range tests { + s.Run(tt.name, func() { + instance, err := validateInstanceState(tt.ctx) + + if tt.wantErr { + s.Require().NotNil(err) + s.Require().ErrorIs(err, runnerErrors.ErrUnauthorized) + } else { + s.Require().Nil(err) + s.Require().NotEmpty(instance.Name) + } + }) + } +} + +func (s *MetadataTestSuite) TestGetJITConfigFileInvalidState() { + ctx := auth.SetInstanceParams(context.Background(), s.Fixtures.TestInstance) + ctx = auth.SetInstanceRunnerStatus(ctx, params.RunnerActive) + + jitInstance := s.Fixtures.TestInstance + jitInstance.JitConfiguration = map[string]string{ + ".runner": base64.StdEncoding.EncodeToString([]byte("runner config")), + } + ctx = auth.SetInstanceParams(ctx, jitInstance) + ctx = auth.SetInstanceHasJITConfig(ctx, jitInstance.JitConfiguration) + + _, err := s.Runner.GetJITConfigFile(ctx, ".runner") + s.Require().NotNil(err) + s.Require().ErrorIs(err, runnerErrors.ErrUnauthorized) +} + +func (s *MetadataTestSuite) TestGetJITConfigFileInvalidBase64() { + jitInstance := s.Fixtures.TestInstance + jitInstance.JitConfiguration = map[string]string{ + ".runner": "invalid-base64!!!", + } + + ctx := auth.SetInstanceParams(context.Background(), jitInstance) + ctx = auth.SetInstanceRunnerStatus(ctx, params.RunnerPending) + ctx = auth.SetInstanceHasJITConfig(ctx, jitInstance.JitConfiguration) + + _, err := s.Runner.GetJITConfigFile(ctx, ".runner") + s.Require().NotNil(err) + s.Require().Contains(err.Error(), "error decoding file contents") +} + +func (s *MetadataTestSuite) TestGetJITConfigFileMultipleFiles() { + jitInstance := s.Fixtures.TestInstance + jitInstance.JitConfiguration = map[string]string{ + ".runner": base64.StdEncoding.EncodeToString([]byte("runner config")), + ".credentials": base64.StdEncoding.EncodeToString([]byte("credentials config")), + ".env": base64.StdEncoding.EncodeToString([]byte("env config")), + } + + ctx := auth.SetInstanceParams(context.Background(), jitInstance) + ctx = auth.SetInstanceRunnerStatus(ctx, params.RunnerPending) + ctx = auth.SetInstanceHasJITConfig(ctx, jitInstance.JitConfiguration) + + // Test each file can be retrieved + runnerContent, err := s.Runner.GetJITConfigFile(ctx, ".runner") + s.Require().Nil(err) + s.Require().Equal("runner config", string(runnerContent)) + + credContent, err := s.Runner.GetJITConfigFile(ctx, ".credentials") + s.Require().Nil(err) + s.Require().Equal("credentials config", string(credContent)) + + envContent, err := s.Runner.GetJITConfigFile(ctx, ".env") + s.Require().Nil(err) + s.Require().Equal("env config", string(envContent)) +} + +func (s *MetadataTestSuite) TestGenerateSystemdUnitFileGiteaWithDefaultUser() { + entity := s.Fixtures.TestEntity + entity.Credentials.ForgeType = params.GiteaEndpointType + ctx := auth.SetInstanceEntity(context.Background(), entity) + + unitFile, err := s.Runner.GenerateSystemdUnitFile(ctx, "") + + s.Require().Nil(err) + s.Require().NotEmpty(unitFile) + s.Require().Contains(string(unitFile), "Act Runner") + s.Require().Contains(string(unitFile), "act_runner daemon --once") + s.Require().Contains(string(unitFile), "Restart=always") +} + +func (s *MetadataTestSuite) TestGetLabelsForInstanceWithCache() { + // This test would require setting up the cache properly + // For now, we test that it doesn't panic with empty cache + instance := s.Fixtures.TestInstance + labels := getLabelsForInstance(instance) + s.Require().NotNil(labels) +} + +func (s *MetadataTestSuite) TestGetLabelsForInstanceWithScaleSetAndJIT() { + // Test instance with both scale set and JIT config + // JIT should take precedence + instance := s.Fixtures.TestInstance + instance.ScaleSetID = 123 + instance.JitConfiguration = map[string]string{"test": "config"} + + labels := getLabelsForInstance(instance) + s.Require().Empty(labels) +} + +func (s *MetadataTestSuite) TestGetServiceNameForEntityAllTypes() { + tests := []struct { + name string + entityType params.ForgeEntityType + owner string + repoName string + expected string + wantErr bool + }{ + { + name: "Enterprise", + entityType: params.ForgeEntityTypeEnterprise, + owner: "my-enterprise", + expected: "actions.runner.my-enterprise", + wantErr: false, + }, + { + name: "Organization", + entityType: params.ForgeEntityTypeOrganization, + owner: "my-org", + expected: "actions.runner.my-org", + wantErr: false, + }, + { + name: "Repository", + entityType: params.ForgeEntityTypeRepository, + owner: "my-owner", + repoName: "my-repo", + expected: "actions.runner.my-owner.my-repo", + wantErr: false, + }, + { + name: "Invalid type", + entityType: "invalid-type", + owner: "test", + wantErr: true, + }, + } + + for _, tt := range tests { + s.Run(tt.name, func() { + entity := params.ForgeEntity{ + EntityType: tt.entityType, + Owner: tt.owner, + Name: tt.repoName, + } + + serviceName, err := s.Runner.getServiceNameForEntity(entity) + + if tt.wantErr { + s.Require().Error(err) + } else { + s.Require().NoError(err) + s.Require().Equal(tt.expected, serviceName) + } + }) + } +} + +func (s *MetadataTestSuite) TestFileObjectToGARMToolWithOptionalFields() { + fileObject := params.FileObject{ + ID: 10, + Name: "test-agent", + Size: 2048, + SHA256: "def456", + Description: "Test description", + FileType: "executable", + Tags: []string{ + "version=2.0.0", + "os_type=windows", + "os_arch=arm64", + "extra_tag=value", + }, + } + + result, err := fileObjectToGARMTool(fileObject, "http://test.com/dl") + + s.Require().NoError(err) + s.Require().Equal(uint(10), result.ID) + s.Require().Equal("test-agent", result.Name) + s.Require().Equal(int64(2048), result.Size) + s.Require().Equal("def456", result.SHA256SUM) + s.Require().Equal("Test description", result.Description) + s.Require().Equal("executable", result.FileType) + s.Require().Equal("2.0.0", result.Version) + s.Require().Equal(commonParams.OSType("windows"), result.OSType) + s.Require().Equal(commonParams.OSArch("arm64"), result.OSArch) + s.Require().Equal("http://test.com/dl", result.DownloadURL) +} + func TestMetadataTestSuite(t *testing.T) { suite.Run(t, new(MetadataTestSuite)) } diff --git a/workers/cache/cache.go b/workers/cache/cache.go index 23322222..1045ccbb 100644 --- a/workers/cache/cache.go +++ b/workers/cache/cache.go @@ -30,18 +30,19 @@ import ( "github.com/cloudbase/garm/util/github" ) -func NewWorker(ctx context.Context, store common.Store) *Worker { +func NewWorker(ctx context.Context, store common.Store, toolsManager params.GARMToolsManager) *Worker { consumerID := "cache" ctx = garmUtil.WithSlogContext( ctx, slog.Any("worker", consumerID)) return &Worker{ - ctx: ctx, - store: store, - consumerID: consumerID, - toolsWorkes: make(map[string]*toolsUpdater), - quit: make(chan struct{}), + ctx: ctx, + store: store, + garmToolsManager: toolsManager, + consumerID: consumerID, + toolsWorkes: make(map[string]*toolsUpdater), + quit: make(chan struct{}), } } @@ -49,9 +50,10 @@ type Worker struct { ctx context.Context consumerID string - consumer common.Consumer - store common.Store - toolsWorkes map[string]*toolsUpdater + consumer common.Consumer + store common.Store + garmToolsManager params.GARMToolsManager + toolsWorkes map[string]*toolsUpdater mux sync.Mutex running bool diff --git a/workers/cache/garm_agent.go b/workers/cache/garm_agent.go index 5dd1200b..7fb96b16 100644 --- a/workers/cache/garm_agent.go +++ b/workers/cache/garm_agent.go @@ -14,6 +14,12 @@ package cache import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "sync" "time" ) @@ -23,6 +29,7 @@ type GitHubReleaseAsset struct { Size uint `json:"size"` DownloadCount uint `json:"download_count"` CreatedAt time.Time `json:"created_at"` + Digest string `json:"digest"` DownloadURL string `json:"browser_download_url"` } @@ -34,26 +41,44 @@ type GitHubRelease struct { Assets []GitHubReleaseAsset `json:"assets"` } -// func getGithubReleaseFromURL(_ context.Context, releasesEndpoint string) (GitHubRelease, error) { -// resp, err := http.Get(releasesEndpoint) // nolint -// if err != nil { -// return GitHubRelease{}, fmt.Errorf("failed to fetch URL %s: %w", releasesEndpoint, err) -// } -// defer resp.Body.Close() -// data, err := io.ReadAll(resp.Body) -// if err != nil { -// return GitHubRelease{}, fmt.Errorf("failed to read response from URL %s: %w", releasesEndpoint, err) -// } +type GitHubReleases []GitHubRelease -// var tools GitHubRelease -// err = json.Unmarshal(data, &tools) -// if err != nil { -// return GitHubRelease{}, fmt.Errorf("failed to unmarshal response from URL %s: %w", releasesEndpoint, err) -// } +func getLatestGithubReleaseFromURL(_ context.Context, releasesEndpoint string) (GitHubRelease, error) { + resp, err := http.Get(releasesEndpoint) + if err != nil { + return GitHubRelease{}, fmt.Errorf("failed to fetch URL %s: %w", releasesEndpoint, err) + } + defer resp.Body.Close() + data, err := io.ReadAll(resp.Body) + if err != nil { + return GitHubRelease{}, fmt.Errorf("failed to read response from URL %s: %w", releasesEndpoint, err) + } -// if len(tools.Assets) == 0 { -// return GitHubRelease{}, fmt.Errorf("no tools found from URL %s", releasesEndpoint) -// } + var tools GitHubReleases + err = json.Unmarshal(data, &tools) + if err != nil { + return GitHubRelease{}, fmt.Errorf("failed to unmarshal response from URL %s: %w", releasesEndpoint, err) + } -// return tools, nil -// } + if len(tools) == 0 { + return GitHubRelease{}, fmt.Errorf("no tools found from URL %s", releasesEndpoint) + } + + if len(tools[0].Assets) == 0 { + return GitHubRelease{}, fmt.Errorf("no downloadable assets found from URL %s", releasesEndpoint) + } + + return tools[0], nil +} + +type garmToolsSync struct { + ctx context.Context + + mux sync.Mutex + running bool + quit chan struct{} +} + +func (g *garmToolsSync) loop() { + +} diff --git a/workers/cache/tool_cache.go b/workers/cache/tool_cache.go index 6f979eb8..62d6f1fe 100644 --- a/workers/cache/tool_cache.go +++ b/workers/cache/tool_cache.go @@ -163,14 +163,6 @@ func (t *toolsUpdater) sleepWithCancel(sleepTime time.Duration) (canceled bool) func (t *toolsUpdater) giteaUpdateLoop() { defer t.Stop() - // add some jitter. When spinning up multiple entities, we add - // jitter to prevent stampeeding herd. - randInt, err := rand.Int(rand.Reader, big.NewInt(3000)) - if err != nil { - randInt = big.NewInt(0) - } - t.sleepWithCancel(time.Duration(randInt.Int64()) * time.Millisecond) - // add some jitter timerJitter, err := rand.Int(rand.Reader, big.NewInt(120)) if err != nil {