drone/app/api/controller/check/check_report.go

235 lines
6.7 KiB
Go

// Copyright 2023 Harness, Inc.
//
// 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 check
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"regexp"
"time"
"github.com/harness/gitness/app/api/usererror"
"github.com/harness/gitness/app/auth"
"github.com/harness/gitness/git"
"github.com/harness/gitness/store"
"github.com/harness/gitness/types"
"github.com/harness/gitness/types/enum"
)
type ReportInput struct {
// TODO [CODE-1363]: remove after identifier migration.
CheckUID string `json:"check_uid" deprecated:"true"`
Identifier string `json:"identifier"`
Status enum.CheckStatus `json:"status"`
Summary string `json:"summary"`
Link string `json:"link"`
Payload types.CheckPayload `json:"payload"`
Started int64 `json:"started,omitempty"`
Ended int64 `json:"ended,omitempty"`
}
// TODO: Can we drop the '$' - depends on whether harness allows it.
var regexpCheckIdentifier = "^[0-9a-zA-Z-_.$]{1,127}$"
var matcherCheckIdentifier = regexp.MustCompile(regexpCheckIdentifier)
// Sanitize validates and sanitizes the ReportInput data.
func (in *ReportInput) Sanitize(
sanitizers map[enum.CheckPayloadKind]func(in *ReportInput, s *auth.Session) error, session *auth.Session,
) error {
// TODO [CODE-1363]: remove after identifier migration.
if in.Identifier == "" {
in.Identifier = in.CheckUID
}
if in.Identifier == "" {
return usererror.BadRequest("Identifier is missing")
}
if !matcherCheckIdentifier.MatchString(in.Identifier) {
return usererror.BadRequestf("Identifier must match the regular expression: %s", regexpCheckIdentifier)
}
_, ok := in.Status.Sanitize()
if !ok {
return usererror.BadRequest("Invalid value provided for status check status")
}
validatorFn, ok := sanitizers[in.Payload.Kind]
if !ok {
return usererror.BadRequest("Invalid value provided for the payload kind")
}
// Validate and sanitize the input data based on version; Require a link... and similar operations.
if err := validatorFn(in, session); err != nil {
return fmt.Errorf("payload validation failed: %w", err)
}
if in.Ended != 0 && in.Ended < in.Started {
return usererror.BadRequest("started time reported after ended time")
}
return nil
}
func SanitizeJSONPayload(source json.RawMessage, data any) (json.RawMessage, error) {
if len(source) == 0 {
return json.Marshal(data) // marshal the empty object
}
decoder := json.NewDecoder(bytes.NewReader(source))
decoder.DisallowUnknownFields()
if err := decoder.Decode(&data); err != nil {
return nil, usererror.BadRequestf("Payload data doesn't match the required format: %s", err.Error())
}
buffer := bytes.NewBuffer(nil)
buffer.Grow(512)
encoder := json.NewEncoder(buffer)
encoder.SetEscapeHTML(false)
if err := encoder.Encode(data); err != nil {
return nil, fmt.Errorf("failed to sanitize json payload: %w", err)
}
result := buffer.Bytes()
if result[len(result)-1] == '\n' {
result = result[:len(result)-1]
}
return result, nil
}
// Report modifies an existing or creates a new (if none yet exists) status check report for a specific commit.
func (c *Controller) Report(
ctx context.Context,
session *auth.Session,
repoRef string,
commitSHA string,
in *ReportInput,
metadata map[string]string,
) (*types.Check, error) {
repo, err := c.getRepoCheckAccess(ctx, session, repoRef, enum.PermissionRepoReportCommitCheck)
if err != nil {
return nil, fmt.Errorf("failed to acquire access access to repo: %w", err)
}
if errValidate := in.Sanitize(c.sanitizers, session); errValidate != nil {
return nil, errValidate
}
if !git.ValidateCommitSHA(commitSHA) {
return nil, usererror.BadRequest("invalid commit SHA provided")
}
_, err = c.git.GetCommit(ctx, &git.GetCommitParams{
ReadParams: git.ReadParams{RepoUID: repo.GitUID},
SHA: commitSHA,
})
if err != nil {
return nil, fmt.Errorf("failed to commit sha=%s: %w", commitSHA, err)
}
now := time.Now().UnixMilli()
metadataJSON, _ := json.Marshal(metadata)
existingCheck, err := c.checkStore.FindByIdentifier(ctx, repo.ID, commitSHA, in.Identifier)
if err != nil && !errors.Is(err, store.ErrResourceNotFound) {
return nil, fmt.Errorf("failed to find existing check for Identifier %q: %w", in.Identifier, err)
}
started := getStartTime(in, existingCheck, now)
ended := getEndTime(in, now)
statusCheckReport := &types.Check{
CreatedBy: session.Principal.ID,
Created: now,
Updated: now,
RepoID: repo.ID,
CommitSHA: commitSHA,
Identifier: in.Identifier,
Status: in.Status,
Summary: in.Summary,
Link: in.Link,
Payload: in.Payload,
Metadata: metadataJSON,
ReportedBy: *session.Principal.ToPrincipalInfo(),
Started: started,
Ended: ended,
}
err = c.checkStore.Upsert(ctx, statusCheckReport)
if err != nil {
return nil, fmt.Errorf("failed to upsert status check result for repo=%s: %w", repo.Identifier, err)
}
return statusCheckReport, nil
}
func getStartTime(in *ReportInput, check types.Check, now int64) int64 {
// start value came in api
if in.Started != 0 {
return in.Started
}
// in.started has no value we smartly put value for started
// in case of pending we assume check has not started running
if in.Status == enum.CheckStatusPending {
return 0
}
// new check
if check.Started == 0 {
return now
}
// The incoming check status can now be running or terminal.
// in case we already have running status we don't update time else we return current time as check has started
// running.
if check.Status == enum.CheckStatusRunning {
return check.Started
}
// Note: In case of reporting terminal statuses again and again we have assumed its
// a report of new status check everytime.
// In case someone reports any status before marking running return current time.
// This can happen if someone only reports terminal status or marks running status again after terminal.
return now
}
func getEndTime(in *ReportInput, now int64) int64 {
// end value came in api
if in.Ended != 0 {
return in.Ended
}
// if we get terminal status i.e. error, failure or success we return current time.
if in.Status.IsCompleted() {
return now
}
// in case of other status we return value as 0, which means we have not yet completed the check.
return 0
}