public static async Task BuildFileTree(ILocalTempFileLocks tempFiles, ApplicationDbContext database,
                                               LfsProject project, ILogger logger, CancellationToken cancellationToken)
        {
            var semaphore = tempFiles.GetTempFilePath($"gitFileTrees/{project.Slug}", out string tempPath);

            await semaphore.WaitAsync(TimeSpan.FromMinutes(10), cancellationToken);

            try
            {
                await GitRunHelpers.EnsureRepoIsCloned(project.CloneUrl, tempPath, true, cancellationToken);

                try
                {
                    await GitRunHelpers.Checkout(tempPath, project.BranchToBuildFileTreeFor, true, cancellationToken);
                }
                catch (Exception)
                {
                    // In case the branch refers to a new branch
                    await GitRunHelpers.Fetch(tempPath, true, cancellationToken);

                    await GitRunHelpers.Checkout(tempPath, project.BranchToBuildFileTreeFor, true, cancellationToken);
                }

                await GitRunHelpers.Pull(tempPath, true, cancellationToken, true);

                // Skip if commit has not changed
                var newCommit = await GitRunHelpers.GetCurrentCommit(tempPath, cancellationToken);

                if (newCommit == project.FileTreeCommit)
                {
                    logger.LogDebug("Commit is still the same ({FileTreeCommit}), skipping tree update " +
                                    "for {Id}",
                                    project.FileTreeCommit, project.Id);
                    return;
                }

                logger.LogInformation("New commit {NewCommit} to build file tree from (previous: {FileTreeCommit}) " +
                                      "for project {Id}", newCommit, project.FileTreeCommit, project.Id);

                project.FileTreeCommit = newCommit;

                // Make sure we don't have any extra files locally
                await GitRunHelpers.Clean(tempPath, cancellationToken);

                // And then make sure the DB file tree entries are fine
                await UpdateFileTreeForProject(database, tempPath, project, cancellationToken);
            }
            finally
            {
                semaphore.Release();
            }

            project.FileTreeUpdated = DateTime.UtcNow;
            await database.SaveChangesAsync(cancellationToken);
        }
Exemple #2
0
        public async Task <IActionResult> ReceiveWebhook()
        {
            var hook = await database.GithubWebhooks.FindAsync(AppInfo.SingleResourceTableRowId);

            if (hook == null)
            {
                logger.LogWarning("Github webhook secret is not configured, can't process webhook");
                return(BadRequest("Incorrect secret"));
            }

            var payload = await CheckSignature(hook);

            GithubWebhookContent data;

            try
            {
                data = JsonSerializer.Deserialize <GithubWebhookContent>(payload,
                                                                         new JsonSerializerOptions(JsonSerializerDefaults.Web)) ?? throw new NullDecodedJsonException();

                if (data == null)
                {
                    throw new Exception("deserialized value is null");
                }
            }
            catch (Exception e)
            {
                logger.LogWarning("Error deserializing github webhook: {@E}", e);
                throw new HttpResponseException()
                      {
                          Value = new BasicJSONErrorResult("Invalid content",
                                                           "Failed to deserialize payload").ToString()
                      };
            }

            if (!HttpContext.Request.Headers.TryGetValue("X-GitHub-Event", out StringValues typeHeader) ||
                typeHeader.Count != 1)
            {
                throw new HttpResponseException()
                      {
                          Value = new BasicJSONErrorResult("Invalid request", "Missing X-GitHub-Event header").ToString()
                      };
            }

            var type = typeHeader[0];

            // TODO: check type on these first two event detections as well
            if (!string.IsNullOrEmpty(data.Ref) && data.RefType != "branch" && !string.IsNullOrEmpty(data.After))
            {
                // This is a push (commit)
                logger.LogInformation("Received a push event for ref: {Ref}", data.Ref);

                if (data.Deleted || data.After == AppInfo.NoCommitHash)
                {
                    logger.LogInformation("Push was about a deleted thing");
                }
                else
                {
                    if (data.Before == AppInfo.NoCommitHash)
                    {
                        logger.LogInformation(
                            "Received a push (probably a new branch) with no before set, setting to the after commit");
                        data.Before = data.After;
                    }

                    if (data.Repository == null)
                    {
                        throw new HttpResponseException()
                              {
                                  Value = new BasicJSONErrorResult("Invalid request",
                                                                   "Repository is needed for this event type").ToString(),
                              };
                    }

                    bool matched = false;

                    // Detect if this triggers any builds
                    foreach (var project in await database.CiProjects.Where(p =>
                                                                            p.ProjectType == CIProjectType.Github && p.Enabled && !p.Deleted &&
                                                                            p.RepositoryFullName == data.Repository.FullName).ToListAsync())
                    {
                        matched = true;

                        // Detect next id
                        var previousBuildId = await database.CiBuilds.Where(b => b.CiProjectId == project.Id)
                                              .MaxAsync(b => (long?)b.CiBuildId) ?? 0;

                        var build = new CiBuild()
                        {
                            CiProjectId    = project.Id,
                            CiBuildId      = ++previousBuildId,
                            CommitHash     = data.After,
                            RemoteRef      = data.Ref,
                            Branch         = GitRunHelpers.ParseRefBranch(data.Ref),
                            IsSafe         = !GitRunHelpers.IsPullRequestRef(data.Ref),
                            PreviousCommit = data.Before,
                            CommitMessage  = data.HeadCommit?.Message ?? data.Commits?.FirstOrDefault()?.Message,
                            ParsedCommits  = data.Commits,
                        };

                        await database.CiBuilds.AddAsync(build);

                        await database.SaveChangesAsync();

                        jobClient.Enqueue <CheckAndStartCIBuild>(x =>
                                                                 x.Execute(build.CiProjectId, build.CiBuildId, CancellationToken.None));
                    }

                    if (!matched)
                    {
                        logger.LogWarning("Push event didn't match any repos: {Fullname}", data.Repository.FullName);
                    }
                }
            }
            else if (!string.IsNullOrEmpty(data.Ref))
            {
                // This is a branch push (or maybe a tag?)
            }
            else if (type == "pull_request")
            {
                bool matched = false;

                if (data.Repository == null)
                {
                    throw new HttpResponseException()
                          {
                              Value = new BasicJSONErrorResult("Invalid request",
                                                               "Repository is needed for this event type").ToString(),
                          };
                }

                if (data.PullRequest == null)
                {
                    throw new HttpResponseException()
                          {
                              Value = new BasicJSONErrorResult("Invalid request",
                                                               "PullRequest data is needed for this event type").ToString(),
                          };
                }

                jobClient.Enqueue <CheckPullRequestStatusJob>(x => x.Execute(data.Repository.FullName,
                                                                             data.PullRequest.Number, data.PullRequest.Head.Sha, data.PullRequest.User.Login,
                                                                             !data.IsClosedPullRequest, CancellationToken.None));

                // Detect if this PR is for any of our repos
                foreach (var project in await database.CiProjects.Where(p =>
                                                                        p.ProjectType == CIProjectType.Github && p.Enabled && !p.Deleted &&
                                                                        p.RepositoryFullName == data.Repository.FullName).ToListAsync())
                {
                    matched = true;

                    if (data.IsClosedPullRequest)
                    {
                        logger.LogInformation("A pull request was closed");
                    }
                    else if (data.Action == "synchronize" || data.Action == "opened")
                    {
                        // PR content was changed so we should rebuild (we don't react to other actions to avoid
                        // duplicate builds)

                        // TODO: CLA checks for PRs
                        // Queue a CLA check

                        // Only non-primary repo PRs have CI jobs ran on them as main repo commits trigger
                        // the push event
                        if (data.PullRequest.Head.Repo.Id != data.Repository.Id)
                        {
                            logger.LogInformation("Received pull request event from a fork: {FullName}",
                                                  data.PullRequest.Head.Repo.FullName);

                            var headRef = GitRunHelpers.GenerateRefForPullRequest(data.PullRequest.Number);

                            // Detect next id
                            var previousBuildId = await database.CiBuilds.Where(b => b.CiProjectId == project.Id)
                                                  .MaxAsync(b => (long?)b.CiBuildId) ?? 0;

                            var build = new CiBuild()
                            {
                                CiProjectId    = project.Id,
                                CiBuildId      = ++previousBuildId,
                                CommitHash     = data.PullRequest.Head.Sha,
                                RemoteRef      = headRef,
                                Branch         = GitRunHelpers.ParseRefBranch(headRef),
                                IsSafe         = false,
                                PreviousCommit = data.PullRequest.Base.Sha,
                                CommitMessage  = $"Pull request #{data.PullRequest.Number}",

                                // TODO: commits would need to be retrieved from data.PullRequest.CommitsUrl
                                Commits = null,
                            };

                            await database.CiBuilds.AddAsync(build);

                            await database.SaveChangesAsync();

                            jobClient.Enqueue <CheckAndStartCIBuild>(x =>
                                                                     x.Execute(build.CiProjectId, build.CiBuildId, CancellationToken.None));
                        }

                        // TODO: could run some special actions on PR open
                        if (data.Action == "opened")
                        {
                        }
                    }
                }

                if (!matched)
                {
                    logger.LogWarning("Pull request event didn't match any repos: {Fullname}",
                                      data.Repository.FullName);
                }
            }

            // TODO: should this always be updated. Github might send us quite a few events if we subscribe to them all
            hook.LastUsed = DateTime.UtcNow;
            await database.SaveChangesAsync();

            return(Ok());
        }
Exemple #3
0
        public async Task Execute(long ciProjectId, long ciBuildId, CancellationToken cancellationToken)
        {
            var build = await database.CiBuilds.Include(c => c.CiProject)
                        .FirstOrDefaultAsync(c => c.CiProjectId == ciProjectId && c.CiBuildId == ciBuildId, cancellationToken);

            if (build == null)
            {
                logger.LogError("Failed to find CIBuild to start");
                return;
            }

            // Update our local repo copy and see what the wanted build config is
            var semaphore = localTempFileLocks.GetTempFilePath($"ciRepos/{ciProjectId}", out string tempPath);

            await semaphore.WaitAsync(cancellationToken);

            var deserializer = new DeserializerBuilder().WithNamingConvention(CamelCaseNamingConvention.Instance)
                               .Build();

            if (build.CiProject == null)
            {
                throw new NotLoadedModelNavigationException();
            }

            CiBuildConfiguration?configuration;

            try
            {
                await GitRunHelpers.EnsureRepoIsCloned(build.CiProject.RepositoryCloneUrl, tempPath, true,
                                                       cancellationToken);

                // Fetch the ref
                await GitRunHelpers.FetchRef(tempPath, build.RemoteRef, cancellationToken);

                // Then checkout the commit this build is actually for
                await GitRunHelpers.Checkout(tempPath, build.CommitHash, true, cancellationToken, true);

                // Clean out non-ignored files
                await GitRunHelpers.Clean(tempPath, cancellationToken);

                // Read the build configuration
                var text = await File.ReadAllTextAsync(Path.Join(tempPath, AppInfo.CIConfigurationFile), Encoding.UTF8,
                                                       cancellationToken);

                configuration = deserializer.Deserialize <CiBuildConfiguration>(text);
            }
            catch (Exception e)
            {
                configuration = null;
                logger.LogError("Error when trying to read repository for starting jobs: {@E}", e);
            }
            finally
            {
                semaphore.Release();
            }

            if (configuration == null)
            {
                await CreateFailedJob(build, "Failed to read repository or build configuration", cancellationToken);

                return;
            }

            // Check that configuration is valid
            var errors = new List <ValidationResult>();

            if (!Validator.TryValidateObject(configuration, new ValidationContext(configuration), errors))
            {
                logger.LogError("Build configuration object didn't pass validations, see following errors:");

                foreach (var error in errors)
                {
                    logger.LogError("Failure: {Error}", error);
                }

                // TODO: pass validation errors to the build output
                await CreateFailedJob(build, "Invalid configuration yaml", cancellationToken);

                return;
            }

            // TODO: refactor these checks to be cleaner
            if (configuration.Jobs.Select(j => j.Value.Cache).Any(c =>
                                                                  c.LoadFrom.Any(p => p.Contains("..") || p.StartsWith("/")) || c.WriteTo.Contains("..") ||
                                                                  c.WriteTo.Contains('/') ||
                                                                  (c.Shared != null && c.Shared.Any(s =>
                                                                                                    s.Key.Contains("..") || s.Key.StartsWith("/") || s.Value.Contains("..") ||
                                                                                                    s.Value.Contains('/'))) ||
                                                                  (c.System != null && c.System.Any(s =>
                                                                                                    s.Key.Contains("..") || s.Key.Contains('\'') || !s.Key.StartsWith("/") ||
                                                                                                    s.Value.Contains("..") || s.Value.Contains('/') || s.Value.Contains('\'')))))
            {
                logger.LogError("Build configuration cache paths have \"..\" in them or starts with a slash");

                await CreateFailedJob(build, "Invalid configuration yaml, forbidden cache path", cancellationToken);

                return;
            }

            if (configuration.Jobs.Select(j => j.Value.Image).Any(i => i.Contains("..") || i.StartsWith("/")))
            {
                logger.LogError("Build configuration image names have \"..\" in them or starts with a slash");

                await CreateFailedJob(build, "Invalid configuration yaml, forbidden image name", cancellationToken);

                return;
            }

            if (configuration.Jobs.SelectMany(j => j.Value.Artifacts.Paths).Any(p =>
                                                                                p.Length < 3 || p.Length > 250 || p.StartsWith("/") || p.Contains("..")))
            {
                logger.LogError("Build has a too long, short, or non-relative artifact path");

                await CreateFailedJob(build, "Invalid configuration yaml, invalid artifact path(s)", cancellationToken);

                return;
            }

            if (configuration.Jobs.Any(j => j.Key == "CLA"))
            {
                logger.LogError("Build configuration job contains 'CLA' in it");

                await CreateFailedJob(build, "Invalid configuration yaml, forbidden job name", cancellationToken);

                return;
            }

            // TODO: do something with the version number here...

            // Then queue the jobs we found in the configuration
            var  jobs  = new List <CiJob>();
            long jobId = 0;

            foreach (var jobEntry in configuration.Jobs)
            {
                if (string.IsNullOrWhiteSpace(jobEntry.Key) || jobEntry.Key.Length > 80)
                {
                    await CreateFailedJob(build, "Invalid job name in configuration", cancellationToken);

                    return;
                }

                var job = new CiJob()
                {
                    CiProjectId       = ciProjectId,
                    CiBuildId         = ciBuildId,
                    CiJobId           = ++jobId,
                    JobName           = jobEntry.Key,
                    Image             = jobEntry.Value.Image,
                    CacheSettingsJson = JsonSerializer.Serialize(jobEntry.Value.Cache),
                };

                await database.CiJobs.AddAsync(job, cancellationToken);

                jobs.Add(job);
            }

            await database.SaveChangesAsync(cancellationToken);

            // Send statuses to github
            foreach (var job in jobs)
            {
                if (!await statusReporter.SetCommitStatus(build.CiProject.RepositoryFullName, build.CommitHash,
                                                          GithubAPI.CommitStatus.Pending, statusReporter.CreateStatusUrlForJob(job), "CI checks starting",
                                                          job.JobName))
                {
                    logger.LogError("Failed to set commit status for a build's job: {JobName}", job.JobName);
                }
            }

            // Queue remote executor check task which will allocate a server to run the job(s) on
            jobClient.Enqueue <HandleControlledServerJobsJob>(x => x.Execute(CancellationToken.None));
        }