diff --git a/.vscode/settings.json b/.vscode/settings.json index 4eda2414327..25bda3320f3 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -8,7 +8,7 @@ }, "go.lintTool": "golangci-lint", "go.lintFlags": ["--fast"], - "go.buildTags": "test", + "go.buildTags": "test sqlite", "eslint.workingDirectories": ["./web"], "prettier.ignorePath": "./web/.prettierignore", // Auto fix diff --git a/cmd/agent/core/agent.go b/cmd/agent/core/agent.go index dea39a8a5ce..24919b16558 100644 --- a/cmd/agent/core/agent.go +++ b/cmd/agent/core/agent.go @@ -62,7 +62,7 @@ var ( shutdownCtx = context.Background() ) -func run(ctx context.Context, c *cli.Command, backends []types.Backend) error { +func Run(ctx context.Context, c *cli.Command, backends []types.Backend) error { agentCtx, ctxCancel := context.WithCancelCause(ctx) stopAgentFunc = func(err error) { msg := "shutdown of whole agent" @@ -300,7 +300,7 @@ func runWithRetry(backendEngines []types.Backend) func(ctx context.Context, c *c retryDelay := c.Duration("connect-retry-delay") var err error for i := 0; i < retryCount; i++ { - if err = run(ctx, c, backendEngines); status.Code(err) == codes.Unavailable { + if err = Run(ctx, c, backendEngines); status.Code(err) == codes.Unavailable { log.Warn().Err(err).Msg(fmt.Sprintf("cannot connect to server, retrying in %v", retryDelay)) time.Sleep(retryDelay) } else { diff --git a/tests/integration/fake_forge.go b/tests/integration/fake_forge.go new file mode 100644 index 00000000000..6206c205905 --- /dev/null +++ b/tests/integration/fake_forge.go @@ -0,0 +1,120 @@ +// Copyright 2024 Woodpecker Authors +// +// 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. + +//go:build test +// +build test + +package integration + +import ( + "context" + "net/http" + "sync" + "testing" + + "go.woodpecker-ci.org/woodpecker/v2/server/forge" + "go.woodpecker-ci.org/woodpecker/v2/server/forge/types" + "go.woodpecker-ci.org/woodpecker/v2/server/model" +) + +var ( + forgeLock = sync.Mutex{} + currentForge forge.Forge = nil +) + +func WithForge(t *testing.T, _forge forge.Forge, fn func()) { + forgeLock.Lock() + currentForge = _forge + defer forgeLock.Unlock() + fn() + currentForge = nil +} + +type fakeForge struct{} + +func (fakeForge) Name() string { + return currentForge.Name() +} + +func (fakeForge) URL() string { + return currentForge.URL() +} + +func (fakeForge) Login(ctx context.Context, r *types.OAuthRequest) (*model.User, string, error) { + return currentForge.Login(ctx, r) +} + +func (fakeForge) Auth(ctx context.Context, token, secret string) (string, error) { + return currentForge.Auth(ctx, token, secret) +} + +func (fakeForge) Teams(ctx context.Context, u *model.User) ([]*model.Team, error) { + return currentForge.Teams(ctx, u) +} + +func (fakeForge) Repo(ctx context.Context, u *model.User, remoteID model.ForgeRemoteID, owner, name string) (*model.Repo, error) { + return currentForge.Repo(ctx, u, remoteID, owner, name) +} + +func (fakeForge) Repos(ctx context.Context, u *model.User) ([]*model.Repo, error) { + return currentForge.Repos(ctx, u) +} + +func (fakeForge) File(ctx context.Context, u *model.User, r *model.Repo, b *model.Pipeline, f string) ([]byte, error) { + return currentForge.File(ctx, u, r, b, f) +} + +func (fakeForge) Dir(ctx context.Context, u *model.User, r *model.Repo, b *model.Pipeline, f string) ([]*types.FileMeta, error) { + return currentForge.Dir(ctx, u, r, b, f) +} + +func (fakeForge) Status(ctx context.Context, u *model.User, r *model.Repo, b *model.Pipeline, p *model.Workflow) error { + return currentForge.Status(ctx, u, r, b, p) +} + +func (fakeForge) Netrc(u *model.User, r *model.Repo) (*model.Netrc, error) { + return currentForge.Netrc(u, r) +} + +func (fakeForge) Activate(ctx context.Context, u *model.User, r *model.Repo, link string) error { + return currentForge.Activate(ctx, u, r, link) +} + +func (fakeForge) Deactivate(ctx context.Context, u *model.User, r *model.Repo, link string) error { + return currentForge.Deactivate(ctx, u, r, link) +} + +func (fakeForge) Branches(ctx context.Context, u *model.User, r *model.Repo, p *model.ListOptions) ([]string, error) { + return currentForge.Branches(ctx, u, r, p) +} + +func (fakeForge) BranchHead(ctx context.Context, u *model.User, r *model.Repo, branch string) (*model.Commit, error) { + return currentForge.BranchHead(ctx, u, r, branch) +} + +func (fakeForge) PullRequests(ctx context.Context, u *model.User, r *model.Repo, p *model.ListOptions) ([]*model.PullRequest, error) { + return currentForge.PullRequests(ctx, u, r, p) +} + +func (fakeForge) Hook(ctx context.Context, r *http.Request) (repo *model.Repo, pipeline *model.Pipeline, err error) { + return currentForge.Hook(ctx, r) +} + +func (fakeForge) OrgMembership(ctx context.Context, u *model.User, org string) (*model.OrgPerm, error) { + return currentForge.OrgMembership(ctx, u, org) +} + +func (fakeForge) Org(ctx context.Context, u *model.User, org string) (*model.Org, error) { + return currentForge.Org(ctx, u, org) +} diff --git a/tests/integration/hello_world_test.go b/tests/integration/hello_world_test.go new file mode 100644 index 00000000000..f08221485b1 --- /dev/null +++ b/tests/integration/hello_world_test.go @@ -0,0 +1,75 @@ +// Copyright 2024 Woodpecker Authors +// +// 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. + +//go:build test +// +build test + +package integration + +import ( + "context" + "testing" + + "github.com/stretchr/testify/mock" + + forge_mocks "go.woodpecker-ci.org/woodpecker/v2/server/forge/mocks" + "go.woodpecker-ci.org/woodpecker/v2/server/model" +) + +func TestHelloWorldPipeline(t *testing.T) { + mockForge := forge_mocks.NewForge(t) + ctx := context.Background() + + // Set up the mock forge expectations + repo := &model.Repo{ + ID: 1, + UserID: 1, + Owner: "test-owner", + Name: "test-repo", + Config: ".woodpecker.yml", + } + user := &model.User{ID: 1, Login: "test-user"} + + pipeline := &model.Pipeline{ + RepoID: repo.ID, + Status: model.StatusPending, + } + + config := ` +pipeline: + hello: + image: alpine + commands: + - echo "Hello, World!" +` + + mockForge.On("Repo", ctx, user, model.ForgeRemoteID(""), repo.Owner, repo.Name).Return(repo, nil) + mockForge.On("File", ctx, user, repo, pipeline, ".woodpecker.yml").Return([]byte(config), nil) + mockForge.On("Hook", ctx, mock.Anything).Return(repo, pipeline, nil) + + // Use the fake forge with our mock + WithForge(t, mockForge, func() { + /* TODOs: + - login as user + - activate an repo + - emulate webhook of forge + - check if pipeline was created + - check of pipeline run result + - cleanup + */ + }) + + // Assert that all expectations were met + mockForge.AssertExpectations(t) +} diff --git a/tests/integration/main_test.go b/tests/integration/main_test.go new file mode 100644 index 00000000000..8e53e5b59a0 --- /dev/null +++ b/tests/integration/main_test.go @@ -0,0 +1,254 @@ +// Copyright 2024 Woodpecker Authors +// +// 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. + +//go:build test +// +build test + +package integration + +import ( + "context" + "encoding/base32" + "net" + "net/http" + "os" + "testing" + "time" + + "github.com/gin-gonic/gin" + "github.com/gorilla/securecookie" + "github.com/rs/zerolog/log" + "github.com/urfave/cli/v3" + "google.golang.org/grpc" + "google.golang.org/grpc/keepalive" + + "go.woodpecker-ci.org/woodpecker/v2/cmd/agent/core" + "go.woodpecker-ci.org/woodpecker/v2/pipeline/backend/dummy" + backend_types "go.woodpecker-ci.org/woodpecker/v2/pipeline/backend/types" + "go.woodpecker-ci.org/woodpecker/v2/pipeline/rpc/proto" + "go.woodpecker-ci.org/woodpecker/v2/server" + "go.woodpecker-ci.org/woodpecker/v2/server/cache" + "go.woodpecker-ci.org/woodpecker/v2/server/forge" + woodpeckerGrpcServer "go.woodpecker-ci.org/woodpecker/v2/server/grpc" + "go.woodpecker-ci.org/woodpecker/v2/server/logging" + "go.woodpecker-ci.org/woodpecker/v2/server/model" + "go.woodpecker-ci.org/woodpecker/v2/server/pubsub" + "go.woodpecker-ci.org/woodpecker/v2/server/queue" + "go.woodpecker-ci.org/woodpecker/v2/server/router" + "go.woodpecker-ci.org/woodpecker/v2/server/router/middleware" + "go.woodpecker-ci.org/woodpecker/v2/server/services" + "go.woodpecker-ci.org/woodpecker/v2/server/services/permissions" + "go.woodpecker-ci.org/woodpecker/v2/server/store" + "go.woodpecker-ci.org/woodpecker/v2/server/store/datastore" + "go.woodpecker-ci.org/woodpecker/v2/shared/constant" +) + +var ( + testStore store.Store + mockForge forge.Forge + adminUser = "admin" + globalAgentToken = "global-agentSecret" + grpcPort = ":9020" + httpPort = ":8020" +) + +func TestMain(m *testing.M) { + ctx, ctxCancel := context.WithCancelCause(context.Background()) + defer ctxCancel(nil) + testStore = setupDatabase(ctx) + + // Create mock forge + mockForge = &fakeForge{} + + // Start Woodpecker server + serverCtx, serverCancel := context.WithCancelCause(ctx) + go startServer(serverCtx, testStore, mockForge) + time.Sleep(time.Second) + + // Start Woodpecker agent with dummy backend + agentCtx, agentCancel := context.WithCancelCause(ctx) + go startAgent(agentCtx, globalAgentToken) + + // Run tests + exitCode := m.Run() + + // Cleanup + serverCancel(nil) + agentCancel(nil) + + os.Exit(exitCode) +} + +func setupDatabase(ctx context.Context) store.Store { + testStore, err := datastore.NewEngine(&store.Opts{ + Driver: "sqlite", + Config: ":memory:", + }) + if err != nil { + log.Fatal().Err(err).Msg("Failed to initialize store") + } + if err != testStore.Migrate(ctx, true) { + log.Fatal().Err(err).Msg("Failed to migrate store") + } + go func() { + select { + case <-ctx.Done(): + _ = testStore.Close() + return + } + }() + return testStore +} + +func startServer(ctx context.Context, _store store.Store, _forge forge.Forge) { + // Set up server configuration + server.Config.Server.Port = httpPort + server.Config.Server.Host = "http://localhost" + httpPort + + // Initialize services + server.Config.Services.Pubsub = pubsub.New() + server.Config.Services.Queue = queue.New(ctx) + server.Config.Services.Logs = logging.New() + server.Config.Services.Membership = cache.NewMembershipService(_store) + server.Config.Services.LogStore = _store + + // Initialize service manager + manager, err := services.NewManager(&cli.Command{Flags: []cli.Flag{ + &cli.StringFlag{Name: "forge-oauth-client", Value: "oauth-client"}, + &cli.StringFlag{Name: "forge-oauth-secret", Value: "oauth-secret"}, + &cli.StringFlag{Name: "forge-url", Value: "https://example.forge"}, + &cli.StringFlag{Name: "forge-oauth-host", Value: ""}, + &cli.BoolFlag{Name: "forge-skip-verify", Value: false}, + &cli.StringFlag{Name: "addon-forge", Value: ""}, + &cli.BoolFlag{Name: "github", Value: false}, + &cli.BoolFlag{Name: "gitlab", Value: true}, // we pretend to have connected to gitlab + &cli.IntFlag{Name: "forge-retry", Value: 1}, + &cli.DurationFlag{Name: "forge-timeout", Value: 100 * time.Millisecond}, + &cli.StringFlag{Name: "config-service-endpoint", Value: ""}, + &cli.StringFlag{Name: "docker-config", Value: ""}, + &cli.StringSliceFlag{Name: "environment", Value: []string{}}, + }}, _store, func(*model.Forge) (forge.Forge, error) { + return _forge, nil + }) + if err != nil { + log.Fatal().Err(err).Msg("Failed to create new manager") + } + server.Config.Services.Manager = manager + + // Config + server.Config.Pipeline.DefaultClonePlugin = constant.DefaultClonePlugin + server.Config.Pipeline.TrustedClonePlugins = constant.TrustedClonePlugins + server.Config.Pipeline.DefaultCancelPreviousPipelineEvents = []model.WebhookEvent{"push", "pull_request"} + server.Config.Pipeline.DefaultTimeout = 60 + server.Config.Pipeline.MaxTimeout = 60 + server.Config.Server.JWTSecret = base32.StdEncoding.EncodeToString(securecookie.GenerateRandomKey(32)) + server.Config.Permissions.Admins = permissions.NewAdmins([]string{adminUser}) + server.Config.Server.AgentToken = globalAgentToken + server.Config.Server.StatusContext = "ci/woodpecker" + server.Config.Server.StatusContextFormat = "{{ .context }}/{{ .event }}/{{ .workflow }}{{if not (eq .axis_id 0)}}/{{.axis_id}}{{end}}" + server.Config.Server.SessionExpires = time.Hour + + startGrpcServer(ctx, _store) + + // Set up router + gin.SetMode(gin.TestMode) + handler := router.Load(func(http.ResponseWriter, *http.Request) {}, + gin.Recovery(), + middleware.Store(_store), + ) + + // Start server + srv := &http.Server{ + Addr: server.Config.Server.Port, + Handler: handler, + } + + go func() { + if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.Fatal().Err(err).Msg("Failed to start server") + } + }() + + log.Info().Msg("Woodpecker server started") + + // Wait for context cancellation + <-ctx.Done() + + // Shutdown server + ctxShutdown, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + if err := srv.Shutdown(ctxShutdown); err != nil { + log.Fatal().Err(err).Msg("Server shutdown failed") + } + + log.Info().Msg("Woodpecker server stopped") +} + +func startGrpcServer(ctx context.Context, _store store.Store) { + lis, err := net.Listen("tcp", grpcPort) + if err != nil { + log.Fatal().Err(err).Msg("failed to listen on grpc-addr") + } + + jwtManager := woodpeckerGrpcServer.NewJWTManager("jwtSecret") + authorizer := woodpeckerGrpcServer.NewAuthorizer(jwtManager) + grpcServer := grpc.NewServer( + grpc.StreamInterceptor(authorizer.StreamInterceptor), + grpc.UnaryInterceptor(authorizer.UnaryInterceptor), + grpc.KeepaliveEnforcementPolicy(keepalive.EnforcementPolicy{ + MinTime: time.Nanosecond, + }), + ) + woodpeckerServer := woodpeckerGrpcServer.NewWoodpeckerServer( + server.Config.Services.Queue, + server.Config.Services.Logs, + server.Config.Services.Pubsub, + _store, + ) + proto.RegisterWoodpeckerServer(grpcServer, woodpeckerServer) + woodpeckerAuthServer := woodpeckerGrpcServer.NewWoodpeckerAuthServer(jwtManager, globalAgentToken, _store) + proto.RegisterWoodpeckerAuthServer(grpcServer, woodpeckerAuthServer) + + go func() { + <-ctx.Done() + grpcServer.Stop() + }() + + go func() { + if err := grpcServer.Serve(lis); err != nil { + log.Fatal().Err(err).Msg("failed to start grpc server") + } + }() +} + +func startAgent(ctx context.Context, token string, filter ...string) { + cmd := &cli.Command{ + Flags: []cli.Flag{ + &cli.StringFlag{Name: "agent-config", Value: ""}, + &cli.StringFlag{Name: "hostname", Value: "dymmy-agent"}, + &cli.IntFlag{Name: "max-workflows", Value: 1}, + &cli.BoolFlag{Name: "healthcheck", Value: false}, + &cli.BoolFlag{Name: "grpc-secure", Value: false}, + &cli.StringFlag{Name: "server", Value: "localhost" + grpcPort}, + &cli.DurationFlag{Name: "grpc-keepalive-time", Value: 10 * time.Millisecond}, + &cli.DurationFlag{Name: "grpc-keepalive-timeout", Value: 10 * time.Second}, + &cli.StringFlag{Name: "grpc-token", Value: token}, + &cli.StringFlag{Name: "backend-engine", Value: "dummy"}, + + &cli.StringSliceFlag{Name: "filter", Value: filter}, + }, + } + core.Run(ctx, cmd, []backend_types.Backend{dummy.New()}) +}